From 1cb2944947d923c930299f86aa55c5cbb27190cb Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Mon, 2 Mar 2026 14:53:27 +0200 Subject: [PATCH 1/5] fix: use cloud-platform OAuth scope for Google Gemini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope 'generative-language' is not a valid Google OAuth scope and causes a 400 invalid_scope error during login. The Gemini API's generateContent endpoint has no required scopes in Google's discovery document — the correct scope is 'cloud-platform', which is what Google's own Gemini CLI uses. --- src/auth/google_oauth.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/auth/google_oauth.rs b/src/auth/google_oauth.rs index d719fa4..a53e281 100644 --- a/src/auth/google_oauth.rs +++ b/src/auth/google_oauth.rs @@ -11,7 +11,7 @@ const CLIENT_ID: &str = "701529528334-otljpqp2bjvhm7lp2eqktu5ja8uo05g6.apps.goog const CLIENT_SECRET: &str = "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"; const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; -const SCOPES: &str = "https://www.googleapis.com/auth/generative-language"; +const SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform"; /// 5-minute buffer (in ms) subtracted from token expiry. const EXPIRY_BUFFER_MS: u64 = 5 * 60 * 1000; @@ -421,8 +421,8 @@ mod tests { #[test] fn urlencoded_encodes_slashes() { assert_eq!( - urlencoded("https://www.googleapis.com/auth/generative-language"), - "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgenerative-language" + urlencoded("https://www.googleapis.com/auth/cloud-platform"), + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform" ); } From a666fb7dc06b10667b0396edd2b663d78d3402bc Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Mon, 2 Mar 2026 15:09:44 +0200 Subject: [PATCH 2/5] feat: add device code flow for Google OAuth over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-detect headless/SSH environment and use Google's device code flow instead of loopback redirect. This lets users authenticate over SSH by visiting a URL and entering a code on any device. Implementation: - New TV/Limited Input OAuth client for device code flow - is_headless() detects SSH_CONNECTION, SSH_TTY, missing DISPLAY - Device code polling with backoff per Google spec - client_hint field on OAuthCredentials so refresh uses correct client - Loopback flow uses cloud-platform scope (browser flow) - Device flow uses openid+email scope (Gemini API has no scope requirements) Both flows store credentials identically — existing loopback auth and API key auth continue to work unchanged. --- src/auth/google_oauth.rs | 166 +++++++++++++++++++++++++++++++++++++-- src/auth/oauth.rs | 10 +++ src/auth/storage.rs | 8 +- src/provider.rs | 33 ++++++++ tests/auth_test.rs | 2 + 5 files changed, 213 insertions(+), 6 deletions(-) diff --git a/src/auth/google_oauth.rs b/src/auth/google_oauth.rs index a53e281..2450365 100644 --- a/src/auth/google_oauth.rs +++ b/src/auth/google_oauth.rs @@ -7,11 +7,24 @@ use tokio::net::TcpListener; use super::oauth::OAuthCredentials; +/// Desktop app client (loopback redirect flow). const CLIENT_ID: &str = "701529528334-otljpqp2bjvhm7lp2eqktu5ja8uo05g6.apps.googleusercontent.com"; const CLIENT_SECRET: &str = "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"; + +/// TV / Limited Input client (device code flow — works over SSH). +const DEVICE_CLIENT_ID: &str = "701529528334-7buapusrvqo9ogqio29gd8i3ka96j3qg.apps.googleusercontent.com"; +const DEVICE_CLIENT_SECRET: &str = "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"; + const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; -const SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform"; +const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code"; +/// Loopback flow scope — broad, works in browser-based authorization. +const LOOPBACK_SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform"; + +/// Device code flow scope — Google blocks sensitive scopes (like cloud-platform) +/// in device flow. The Gemini API's generateContent has no scope requirements, +/// so any valid OAuth token works. +const DEVICE_SCOPES: &str = "openid email"; /// 5-minute buffer (in ms) subtracted from token expiry. const EXPIRY_BUFFER_MS: u64 = 5 * 60 * 1000; @@ -85,7 +98,7 @@ pub async fn prepare_authorize() -> Result<(AuthResult, TcpListener)> { ("client_id", CLIENT_ID), ("response_type", "code"), ("redirect_uri", redirect_uri.as_str()), - ("scope", SCOPES), + ("scope", LOOPBACK_SCOPES), ("code_challenge", pkce.challenge.as_str()), ("code_challenge_method", "S256"), ("access_type", "offline"), @@ -221,15 +234,25 @@ pub async fn exchange_code(code: &str, verifier: &str, port: u16) -> Result Result { +/// +/// `client_hint` selects which OAuth client to use: +/// - `Some("device")` → TV / Limited Input client (device code flow) +/// - anything else → Desktop client (loopback flow) +pub async fn refresh_token(refresh: &str, client_hint: Option<&str>) -> Result { + let (cid, csecret) = match client_hint { + Some("device") => (DEVICE_CLIENT_ID, DEVICE_CLIENT_SECRET), + _ => (CLIENT_ID, CLIENT_SECRET), + }; + let params = [ ("grant_type", "refresh_token"), - ("client_id", CLIENT_ID), - ("client_secret", CLIENT_SECRET), + ("client_id", cid), + ("client_secret", csecret), ("refresh_token", refresh), ]; @@ -248,6 +271,7 @@ pub async fn refresh_token(refresh: &str) -> Result { access: data.access_token, refresh: data.refresh_token.unwrap_or_else(|| refresh.to_string()), expires: expiry_with_buffer(data.expires_in), + client_hint: client_hint.map(String::from), }) } @@ -258,6 +282,138 @@ struct TokenResponse { expires_in: u64, } +// --------------------------------------------------------------------------- +// Device code flow (for SSH / headless environments) +// --------------------------------------------------------------------------- + +/// Response from Google's device code endpoint. +#[derive(serde::Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_url: String, + expires_in: u64, + interval: u64, +} + +/// Response while polling — may be a pending status or final tokens. +#[derive(serde::Deserialize)] +struct DevicePollResponse { + /// Present on error (e.g. "authorization_pending", "slow_down", "access_denied"). + error: Option, + /// Present on success. + access_token: Option, + refresh_token: Option, + expires_in: Option, +} + +/// Initiate the device code flow. Returns the user code and verification URL +/// for display, plus the device code for polling. +pub async fn device_code_authorize() -> Result { + let params = [ + ("client_id", DEVICE_CLIENT_ID), + ("scope", DEVICE_SCOPES), + ]; + + let client = reqwest::Client::new(); + let resp = client.post(DEVICE_CODE_URL).form(¶ms).send().await?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + bail!("Google device code request failed: {text}"); + } + + let data: DeviceCodeResponse = resp.json().await?; + + Ok(DeviceAuth { + device_code: data.device_code, + user_code: data.user_code, + verification_url: data.verification_url, + expires_in: data.expires_in, + interval: data.interval, + }) +} + +/// Everything needed to complete the device code flow. +pub struct DeviceAuth { + pub device_code: String, + pub user_code: String, + pub verification_url: String, + pub expires_in: u64, + pub interval: u64, +} + +/// Poll Google's token endpoint until the user approves (or the code expires). +pub async fn poll_device_token(auth: &DeviceAuth) -> Result { + let deadline = std::time::Instant::now() + + std::time::Duration::from_secs(auth.expires_in); + let mut interval = std::time::Duration::from_secs(auth.interval.max(5)); + + let client = reqwest::Client::new(); + + loop { + tokio::time::sleep(interval).await; + + if std::time::Instant::now() > deadline { + bail!("device code expired — please try again"); + } + + let params = [ + ("client_id", DEVICE_CLIENT_ID), + ("client_secret", DEVICE_CLIENT_SECRET), + ("device_code", auth.device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]; + + let resp = client.post(TOKEN_URL).form(¶ms).send().await?; + let data: DevicePollResponse = resp.json().await?; + + match data.error.as_deref() { + Some("authorization_pending") => continue, + Some("slow_down") => { + // Back off by 5 seconds as required by Google + interval += std::time::Duration::from_secs(5); + continue; + } + Some(err) => bail!("Google device auth failed: {err}"), + None => { + // Success — tokens present + let access = data.access_token + .ok_or_else(|| anyhow::anyhow!("missing access_token in device response"))?; + let refresh = data.refresh_token + .ok_or_else(|| anyhow::anyhow!( + "Google did not return a refresh token. \ + Try revoking access at https://myaccount.google.com/permissions \ + and logging in again." + ))?; + let expires_in = data.expires_in.unwrap_or(3600); + + return Ok(OAuthCredentials { + access, + refresh, + expires: expiry_with_buffer(expires_in), + client_hint: Some("device".into()), + }); + } + } + } +} + +/// Detect whether we're in a headless / SSH environment where loopback +/// redirect won't work. +pub fn is_headless() -> bool { + // SSH session — browser redirect to 127.0.0.1 on remote won't work + if std::env::var("SSH_CONNECTION").is_ok() || std::env::var("SSH_TTY").is_ok() { + return true; + } + // No display server on Linux + #[cfg(target_os = "linux")] + if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() { + return true; + } + false +} + /// Decode a percent-encoded string (e.g. `hello%20world` → `hello world`). fn urldecode(s: &str) -> String { let mut out = Vec::with_capacity(s.len()); diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index c7d4833..83ce63d 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -16,6 +16,11 @@ pub struct OAuthCredentials { pub refresh: String, /// Expiration timestamp in milliseconds since epoch. pub expires: u64, + /// Which OAuth client issued these tokens (e.g. `"device"` for the device + /// code flow). Used to pick the correct client ID/secret during refresh. + /// `None` means the default (Desktop/loopback) client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_hint: Option, } impl OAuthCredentials { @@ -114,6 +119,7 @@ pub async fn exchange_code(auth_code_raw: &str, verifier: &str) -> Result Result { access: data.access_token, refresh: data.refresh_token, expires, + client_hint: None, }) } @@ -289,6 +296,7 @@ mod tests { access: "token".to_string(), refresh: "refresh".to_string(), expires: now_ms() + 3_600_000, // 1 hour from now + client_hint: None, }; assert!(!creds.is_expired()); } @@ -299,6 +307,7 @@ mod tests { access: "token".to_string(), refresh: "refresh".to_string(), expires: 1000, // epoch + 1 second + client_hint: None, }; assert!(creds.is_expired()); } @@ -309,6 +318,7 @@ mod tests { access: "token".to_string(), refresh: "refresh".to_string(), expires: 0, + client_hint: None, }; assert!(creds.is_expired()); } diff --git a/src/auth/storage.rs b/src/auth/storage.rs index cf68bf6..bd2078d 100644 --- a/src/auth/storage.rs +++ b/src/auth/storage.rs @@ -108,7 +108,13 @@ impl AuthStorage { Credential::OAuth(mut oauth) => { if oauth.is_expired() { let refreshed = match provider { - "google" => super::google_oauth::refresh_token(&oauth.refresh).await?, + "google" => { + super::google_oauth::refresh_token( + &oauth.refresh, + oauth.client_hint.as_deref(), + ) + .await? + } _ => super::oauth::refresh_token(&oauth.refresh).await?, }; oauth = refreshed.clone(); diff --git a/src/provider.rs b/src/provider.rs index 457b783..663455f 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -103,6 +103,7 @@ mod anthropic_provider { mod google_provider { use super::*; use crate::auth::google_oauth; + use crate::auth::storage::Credential; use crate::thinker::gemini::GeminiThinker; pub struct Google; @@ -131,6 +132,17 @@ mod google_provider { } async fn login(&self, db_path: &str) -> Result<()> { + if google_oauth::is_headless() { + self.login_device_code(db_path).await + } else { + self.login_loopback(db_path).await + } + } + } + + impl Google { + /// Loopback redirect flow — opens browser, Google redirects to localhost. + async fn login_loopback(&self, db_path: &str) -> Result<()> { let (auth_result, listener) = google_oauth::prepare_authorize().await?; let _ = open::that(&auth_result.url); @@ -154,6 +166,27 @@ mod google_provider { .await?; Ok(()) } + + /// Device code flow — works over SSH / headless. + async fn login_device_code(&self, db_path: &str) -> Result<()> { + println!("Headless environment detected — using device code flow.\n"); + + let auth = google_oauth::device_code_authorize().await?; + + println!("Go to: {}\n", auth.verification_url); + println!("Enter code: {}\n", auth.user_code); + println!("Waiting for approval..."); + + let creds = google_oauth::poll_device_token(&auth).await?; + + let storage = AuthStorage::open(db_path)?; + storage.set( + self.id(), + Credential::OAuth(creds), + )?; + + Ok(()) + } } } diff --git a/tests/auth_test.rs b/tests/auth_test.rs index 240220a..9586c6b 100644 --- a/tests/auth_test.rs +++ b/tests/auth_test.rs @@ -42,6 +42,7 @@ fn set_and_get_oauth() { access: "access-token".to_string(), refresh: "refresh-token".to_string(), expires: 9999999999999, + client_hint: None, }; storage.set("anthropic", Credential::OAuth(oauth)).unwrap(); @@ -190,6 +191,7 @@ async fn get_api_key_from_oauth_non_expired() { access: "sk-ant-oat01-valid".to_string(), refresh: "refresh".to_string(), expires: u64::MAX, // far future + client_hint: None, }; storage.set("anthropic", Credential::OAuth(oauth)).unwrap(); From 0a5337d65e8881f0f1c35d50130c4c81839ee81d Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Mon, 2 Mar 2026 15:14:04 +0200 Subject: [PATCH 3/5] refactor: use minimal 'openid email' scope for both OAuth flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gemini API's generateContent has no scope requirements (confirmed via Google's discovery doc). Using cloud-platform was massively over-privileged — it grants full read/write to all Google Cloud resources. Both flows now use 'openid email' (minimum for auth). --- src/auth/google_oauth.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/auth/google_oauth.rs b/src/auth/google_oauth.rs index 2450365..e81429f 100644 --- a/src/auth/google_oauth.rs +++ b/src/auth/google_oauth.rs @@ -18,13 +18,10 @@ const DEVICE_CLIENT_SECRET: &str = "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"; const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code"; -/// Loopback flow scope — broad, works in browser-based authorization. -const LOOPBACK_SCOPES: &str = "https://www.googleapis.com/auth/cloud-platform"; - -/// Device code flow scope — Google blocks sensitive scopes (like cloud-platform) -/// in device flow. The Gemini API's generateContent has no scope requirements, -/// so any valid OAuth token works. -const DEVICE_SCOPES: &str = "openid email"; +/// OAuth scopes for both flows. The Gemini API's generateContent has no scope +/// requirements (confirmed via Google's discovery doc), so any valid OAuth +/// token works. We request only the minimum needed for authentication. +const SCOPES: &str = "openid email"; /// 5-minute buffer (in ms) subtracted from token expiry. const EXPIRY_BUFFER_MS: u64 = 5 * 60 * 1000; @@ -98,7 +95,7 @@ pub async fn prepare_authorize() -> Result<(AuthResult, TcpListener)> { ("client_id", CLIENT_ID), ("response_type", "code"), ("redirect_uri", redirect_uri.as_str()), - ("scope", LOOPBACK_SCOPES), + ("scope", SCOPES), ("code_challenge", pkce.challenge.as_str()), ("code_challenge_method", "S256"), ("access_type", "offline"), @@ -312,7 +309,7 @@ struct DevicePollResponse { pub async fn device_code_authorize() -> Result { let params = [ ("client_id", DEVICE_CLIENT_ID), - ("scope", DEVICE_SCOPES), + ("scope", SCOPES), ]; let client = reqwest::Client::new(); From a3452f368f9541ff4c819373673c665ff7a2fa9d Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Mon, 2 Mar 2026 15:15:19 +0200 Subject: [PATCH 4/5] style: cargo fmt --- src/auth/google_oauth.rs | 21 ++++++++++----------- src/provider.rs | 5 +---- tests/login_test.rs | 1 + 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/auth/google_oauth.rs b/src/auth/google_oauth.rs index e81429f..ef1947e 100644 --- a/src/auth/google_oauth.rs +++ b/src/auth/google_oauth.rs @@ -12,7 +12,8 @@ const CLIENT_ID: &str = "701529528334-otljpqp2bjvhm7lp2eqktu5ja8uo05g6.apps.goog const CLIENT_SECRET: &str = "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"; /// TV / Limited Input client (device code flow — works over SSH). -const DEVICE_CLIENT_ID: &str = "701529528334-7buapusrvqo9ogqio29gd8i3ka96j3qg.apps.googleusercontent.com"; +const DEVICE_CLIENT_ID: &str = + "701529528334-7buapusrvqo9ogqio29gd8i3ka96j3qg.apps.googleusercontent.com"; const DEVICE_CLIENT_SECRET: &str = "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"; const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -307,10 +308,7 @@ struct DevicePollResponse { /// Initiate the device code flow. Returns the user code and verification URL /// for display, plus the device code for polling. pub async fn device_code_authorize() -> Result { - let params = [ - ("client_id", DEVICE_CLIENT_ID), - ("scope", SCOPES), - ]; + let params = [("client_id", DEVICE_CLIENT_ID), ("scope", SCOPES)]; let client = reqwest::Client::new(); let resp = client.post(DEVICE_CODE_URL).form(¶ms).send().await?; @@ -342,8 +340,7 @@ pub struct DeviceAuth { /// Poll Google's token endpoint until the user approves (or the code expires). pub async fn poll_device_token(auth: &DeviceAuth) -> Result { - let deadline = std::time::Instant::now() - + std::time::Duration::from_secs(auth.expires_in); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(auth.expires_in); let mut interval = std::time::Duration::from_secs(auth.interval.max(5)); let client = reqwest::Client::new(); @@ -375,14 +372,16 @@ pub async fn poll_device_token(auth: &DeviceAuth) -> Result { Some(err) => bail!("Google device auth failed: {err}"), None => { // Success — tokens present - let access = data.access_token + let access = data + .access_token .ok_or_else(|| anyhow::anyhow!("missing access_token in device response"))?; - let refresh = data.refresh_token - .ok_or_else(|| anyhow::anyhow!( + let refresh = data.refresh_token.ok_or_else(|| { + anyhow::anyhow!( "Google did not return a refresh token. \ Try revoking access at https://myaccount.google.com/permissions \ and logging in again." - ))?; + ) + })?; let expires_in = data.expires_in.unwrap_or(3600); return Ok(OAuthCredentials { diff --git a/src/provider.rs b/src/provider.rs index 663455f..26dfa11 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -180,10 +180,7 @@ mod google_provider { let creds = google_oauth::poll_device_token(&auth).await?; let storage = AuthStorage::open(db_path)?; - storage.set( - self.id(), - Credential::OAuth(creds), - )?; + storage.set(self.id(), Credential::OAuth(creds))?; Ok(()) } diff --git a/tests/login_test.rs b/tests/login_test.rs index 5730390..90e91ff 100644 --- a/tests/login_test.rs +++ b/tests/login_test.rs @@ -152,6 +152,7 @@ fn build_provider_detects_oauth_credentials() { access: "token".to_string(), refresh: "refresh".to_string(), expires: u64::MAX, + client_hint: None, }), ) .unwrap(); From 7c30388ed176a5c97a8bd8705bc4fb3e36ff4185 Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Mon, 2 Mar 2026 15:35:29 +0200 Subject: [PATCH 5/5] fix: address Copilot review comments - Make client_hint doc comment provider-agnostic (OAuthCredentials is shared) - Use neutral example URL in urlencoded test (no stale scope reference) --- src/auth/google_oauth.rs | 4 ++-- src/auth/oauth.rs | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/auth/google_oauth.rs b/src/auth/google_oauth.rs index ef1947e..13aa90a 100644 --- a/src/auth/google_oauth.rs +++ b/src/auth/google_oauth.rs @@ -573,8 +573,8 @@ mod tests { #[test] fn urlencoded_encodes_slashes() { assert_eq!( - urlencoded("https://www.googleapis.com/auth/cloud-platform"), - "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform" + urlencoded("https://example.com/foo/bar"), + "https%3A%2F%2Fexample.com%2Ffoo%2Fbar" ); } diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index 83ce63d..bae5629 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -16,9 +16,10 @@ pub struct OAuthCredentials { pub refresh: String, /// Expiration timestamp in milliseconds since epoch. pub expires: u64, - /// Which OAuth client issued these tokens (e.g. `"device"` for the device - /// code flow). Used to pick the correct client ID/secret during refresh. - /// `None` means the default (Desktop/loopback) client. + /// Optional hint identifying which OAuth client issued these tokens. + /// Used by providers with multiple OAuth clients to select the correct + /// client configuration during token refresh. `None` means the default + /// client configuration will be used. #[serde(default, skip_serializing_if = "Option::is_none")] pub client_hint: Option, }