diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ac74c8..b48576a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,19 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Verify YouTube OAuth secrets are present + env: + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + run: | + missing=() + [ -z "$GOOGLE_CLIENT_ID" ] && missing+=(GOOGLE_CLIENT_ID) + [ -z "$GOOGLE_CLIENT_SECRET" ] && missing+=(GOOGLE_CLIENT_SECRET) + if [ ${#missing[@]} -ne 0 ]; then + echo "::error::Missing required repo secret(s): ${missing[*]}. Set them under Settings → Secrets and variables → Actions." + exit 1 + fi + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 @@ -74,6 +87,8 @@ jobs: - uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} with: projectPath: apps/desktop tagName: ${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index 02f25ce..d785f40 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ .env .env.* +# Secrets — never commit OAuth client secret JSON downloads +client_secret_*.json +*.client_secret.json + # OS .DS_Store Thumbs.db diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index d860e1e..7ae5ae0 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,3 +1,7 @@ fn main() { + // Re-run when the YouTube OAuth credentials change so cached + // builds don't bake in stale values from a previous environment. + println!("cargo:rerun-if-env-changed=GOOGLE_CLIENT_ID"); + println!("cargo:rerun-if-env-changed=GOOGLE_CLIENT_SECRET"); tauri_build::build() } diff --git a/apps/desktop/src-tauri/src/youtube_auth/mod.rs b/apps/desktop/src-tauri/src/youtube_auth/mod.rs index f9c5642..5d98053 100644 --- a/apps/desktop/src-tauri/src/youtube_auth/mod.rs +++ b/apps/desktop/src-tauri/src/youtube_auth/mod.rs @@ -37,28 +37,47 @@ pub use tokens::YouTubeTokens; /// OAuth `client_id` for the registered Prismoid Google application. /// -/// Replace with the real value from your Google Cloud Console "OAuth -/// 2.0 Client IDs → Desktop app" credential before shipping. The +/// Sourced from the `GOOGLE_CLIENT_ID` env var at compile time so the +/// real Desktop client credential never lands in the public repo. +/// Falls back to a placeholder when the env var is unset *or empty* +/// (GitHub Actions expands a missing `${{ secrets.X }}` to `""`, and +/// `option_env!` returns `Some("")` in that case), in which case the /// surrounding code paths (start_login → complete_login → exchange) -/// will all return `AuthError::OAuth("invalid_client")` until this is -/// set to a registered Desktop client. +/// all return `AuthError::OAuth("invalid_client")`. /// /// Per RFC 8252 §8.4 and Google's own docs, this `client_id` is a /// public identifier — it appears in browser URLs during the /// authorization flow and is bundled in source the same way the /// Twitch DCF flow handles `TWITCH_CLIENT_ID` (see ADR 37). -pub const GOOGLE_CLIENT_ID: &str = "REPLACE_ME.apps.googleusercontent.com"; +pub const GOOGLE_CLIENT_ID: &str = or_placeholder( + option_env!("GOOGLE_CLIENT_ID"), + "REPLACE_ME.apps.googleusercontent.com", +); /// OAuth `client_secret` for the registered Prismoid Google application. /// -/// Google issues a `client_secret` for "Desktop app" credentials and -/// requires it on the token-exchange POST, but their own -/// [installed-app docs](https://developers.google.com/identity/protocols/oauth2/native-app) +/// Sourced from the `GOOGLE_CLIENT_SECRET` env var at compile time. +/// Empty values are treated as unset (see [`GOOGLE_CLIENT_ID`] for the +/// rationale). Google issues a `client_secret` for "Desktop app" +/// credentials and requires it on the token-exchange POST, but their +/// own [installed-app docs](https://developers.google.com/identity/protocols/oauth2/native-app) /// note: *"In this context, the client secret is obviously not treated /// as a secret."* PKCE S256 is what cryptographically protects the /// flow on a public client; this string is included on the wire only /// because Google's endpoint won't accept the request without it. -pub const GOOGLE_CLIENT_SECRET: &str = "REPLACE_ME"; +pub const GOOGLE_CLIENT_SECRET: &str = + or_placeholder(option_env!("GOOGLE_CLIENT_SECRET"), "REPLACE_ME"); + +/// Returns `env` when it's a non-empty string, otherwise `default`. +/// Used so a build env var explicitly set to `""` (the GitHub Actions +/// expansion of a missing secret) is treated as unset rather than +/// silently embedding empty credentials in the binary. +const fn or_placeholder(env: Option<&'static str>, default: &'static str) -> &'static str { + match env { + Some(v) if !v.is_empty() => v, + _ => default, + } +} /// Google OAuth 2.0 authorization endpoint. Hard-coded to the v2 /// endpoint per Google's [installed-app guide](https://developers.google.com/identity/protocols/oauth2/native-app#step-2-send-a-request-to-googles-oauth-20-server). @@ -78,3 +97,26 @@ pub const SCOPE_YOUTUBE_READONLY: &str = "https://www.googleapis.com/auth/youtub /// Full YouTube scope (read + write + moderation). Required to send /// messages and ban/timeout/delete on YouTube live chat. pub const SCOPE_YOUTUBE: &str = "https://www.googleapis.com/auth/youtube"; + +#[cfg(test)] +mod tests { + use super::or_placeholder; + + #[test] + fn or_placeholder_uses_env_when_non_empty() { + assert_eq!(or_placeholder(Some("value"), "fallback"), "value"); + } + + #[test] + fn or_placeholder_falls_back_when_unset() { + assert_eq!(or_placeholder(None, "fallback"), "fallback"); + } + + #[test] + fn or_placeholder_falls_back_when_empty() { + // GitHub Actions expands a missing `${{ secrets.X }}` to `""` + // and option_env! surfaces that as Some(""). The helper must + // treat it as unset so release builds don't embed empty creds. + assert_eq!(or_placeholder(Some(""), "fallback"), "fallback"); + } +}