//! Shared OAuth infrastructure: built-in credentials, callback server, landing pages. //! //! Every OAuth flow in the codebase (WASM tool auth, MCP server auth, NEAR AI login) //! uses the same callback port, landing page, and listener logic from this module. //! //! # Built-in Credentials //! //! Some providers ship with built-in OAuth credentials so users don't need to //! register their own OAuth app just to get started. Today this module only //! includes built-in defaults for Google-family tools, and those defaults can //! be overridden by provider-specific environment variables when needed. use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::sync::RwLock; use crate::secrets::{CreateSecretParams, SecretsStore}; // ── Built-in credentials ──────────────────────────────────────────────── pub struct OAuthCredentials { pub client_id: &'static str, pub client_secret: &'static str, } /// Google OAuth "Desktop App" credentials, shared across all Google tools. /// Compile-time env vars override the hardcoded defaults below. const GOOGLE_CLIENT_ID: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_ID") { Some(v) => v, None => "564604149681-efo25d43rs85v0tibdepsmdv5dsrhhr0.apps.googleusercontent.com", }; const GOOGLE_CLIENT_SECRET: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_SECRET") { Some(v) => v, None => "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", }; /// Returns built-in OAuth credentials for a provider, keyed by secret_name. /// /// The secret_name comes from the tool's capabilities.json `auth.secret_name` field. /// Returns `None` if no built-in credentials are configured for that provider. pub fn builtin_credentials(secret_name: &str) -> Option { match secret_name { "google_oauth_token" => Some(OAuthCredentials { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, }), _ => None, } } /// Returns the compile-time override env var name, if this provider supports one. pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> { match secret_name { "google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"), _ => None, } } /// Suppress the baked-in desktop OAuth client secret when a hosted proxy is configured. /// /// In hosted deployments, IronClaw may resolve the platform Google client ID from /// environment variables while still falling back to the baked-in desktop secret. /// That client_id/client_secret mismatch breaks Google token exchange and refresh. /// /// When the proxy is configured, the platform will inject the correct server-side /// secret for matching platform credentials, so the baked-in secret must be omitted. pub fn hosted_proxy_client_secret( client_secret: &Option, builtin: Option<&OAuthCredentials>, exchange_proxy_configured: bool, ) -> Option { if !exchange_proxy_configured { return client_secret.clone(); } let builtin_secret = builtin.map(|credentials| credentials.client_secret); match (client_secret, builtin_secret) { (Some(resolved), Some(baked_in)) if resolved == baked_in => None, _ => client_secret.clone(), } } // ── Shared callback server ────────────────────────────────────────────── // Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers` // and re-exported here for backward compatibility. pub use crate::llm::oauth_helpers::{ OAUTH_CALLBACK_PORT, OAuthCallbackError, bind_callback_listener, callback_host, callback_url, is_loopback_host, landing_html, wait_for_callback, }; // ── Shared OAuth flow steps ───────────────────────────────────────── /// Response from the OAuth token exchange. pub struct OAuthTokenResponse { pub access_token: String, pub refresh_token: Option, pub expires_in: Option, } /// Result of building an OAuth 2.0 authorization URL. pub struct OAuthUrlResult { /// The full authorization URL to redirect the user to. pub url: String, /// PKCE code verifier (must be sent with the token exchange request). pub code_verifier: Option, /// Random state parameter for CSRF protection (must be validated in callback). pub state: String, } /// Build an OAuth 2.0 authorization URL with optional PKCE and CSRF state. /// /// Returns an `OAuthUrlResult` containing the authorization URL, optional PKCE /// code verifier, and a random `state` parameter for CSRF protection. The caller /// must validate the `state` value in the callback before exchanging the code. pub fn build_oauth_url( authorization_url: &str, client_id: &str, redirect_uri: &str, scopes: &[String], use_pkce: bool, extra_params: &HashMap, ) -> OAuthUrlResult { // Generate PKCE verifier and challenge let (code_verifier, code_challenge) = if use_pkce { let mut verifier_bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut verifier_bytes); let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); (Some(verifier), Some(challenge)) } else { (None, None) }; // Generate random state for CSRF protection let mut state_bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut state_bytes); let state = URL_SAFE_NO_PAD.encode(state_bytes); // Build authorization URL let mut auth_url = format!( "{}?client_id={}&response_type=code&redirect_uri={}&state={}", authorization_url, urlencoding::encode(client_id), urlencoding::encode(redirect_uri), urlencoding::encode(&state), ); if !scopes.is_empty() { auth_url.push_str(&format!( "&scope={}", urlencoding::encode(&scopes.join(" ")) )); } if let Some(ref challenge) = code_challenge { auth_url.push_str(&format!( "&code_challenge={}&code_challenge_method=S256", challenge )); } for (key, value) in extra_params { auth_url.push_str(&format!( "&{}={}", urlencoding::encode(key), urlencoding::encode(value) )); } OAuthUrlResult { url: auth_url, code_verifier, state, } } /// Exchange an OAuth authorization code for tokens. /// /// POSTs to `token_url` with the authorization code and optional PKCE verifier. /// If `client_secret` is provided, uses HTTP Basic auth; otherwise includes /// `client_id` in the form body (for public clients). pub async fn exchange_oauth_code( token_url: &str, client_id: &str, client_secret: Option<&str>, code: &str, redirect_uri: &str, code_verifier: Option<&str>, access_token_field: &str, ) -> Result { let extra_token_params = HashMap::new(); exchange_oauth_code_with_params( token_url, client_id, client_secret, code, redirect_uri, code_verifier, access_token_field, &extra_token_params, ) .await } /// Exchange an OAuth authorization code for tokens with generic extra form parameters. #[allow(clippy::too_many_arguments)] pub async fn exchange_oauth_code_with_params( token_url: &str, client_id: &str, client_secret: Option<&str>, code: &str, redirect_uri: &str, code_verifier: Option<&str>, access_token_field: &str, extra_token_params: &HashMap, ) -> Result { let client = reqwest::Client::new(); let mut token_params = vec![ ("grant_type", "authorization_code".to_string()), ("code", code.to_string()), ("redirect_uri", redirect_uri.to_string()), ]; if let Some(verifier) = code_verifier { token_params.push(("code_verifier", verifier.to_string())); } for (key, value) in extra_token_params { token_params.push((key.as_str(), value.clone())); } let mut request = client.post(token_url); if let Some(secret) = client_secret { request = request.basic_auth(client_id, Some(secret)); } else { token_params.push(("client_id", client_id.to_string())); } let token_response = request .form(&token_params) .send() .await .map_err(|e| OAuthCallbackError::Io(format!("Token exchange request failed: {}", e)))?; if !token_response.status().is_success() { let status = token_response.status(); let body = token_response.text().await.unwrap_or_default(); return Err(OAuthCallbackError::Io(format!( "Token exchange failed: {} - {}", status, body ))); } let token_data: serde_json::Value = token_response .json() .await .map_err(|e| OAuthCallbackError::Io(format!("Failed to parse token response: {}", e)))?; let access_token = token_data .get(access_token_field) .and_then(|v| v.as_str()) .ok_or_else(|| { // Log only the field names present, not values (which may contain tokens) let fields: Vec<&str> = token_data .as_object() .map(|o| o.keys().map(|k| k.as_str()).collect()) .unwrap_or_default(); OAuthCallbackError::Io(format!( "No '{}' field in token response (fields present: {:?})", access_token_field, fields )) })? .to_string(); let refresh_token = token_data .get("refresh_token") .and_then(|v| v.as_str()) .map(String::from); let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64()); Ok(OAuthTokenResponse { access_token, refresh_token, expires_in, }) } /// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter. /// /// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth). #[allow(clippy::too_many_arguments)] pub async fn exchange_oauth_code_with_resource( token_url: &str, client_id: &str, client_secret: Option<&str>, code: &str, redirect_uri: &str, code_verifier: Option<&str>, access_token_field: &str, resource: Option<&str>, ) -> Result { let mut extra_token_params = HashMap::new(); if let Some(resource) = resource { extra_token_params.insert("resource".to_string(), resource.to_string()); } exchange_oauth_code_with_params( token_url, client_id, client_secret, code, redirect_uri, code_verifier, access_token_field, &extra_token_params, ) .await } /// Store OAuth tokens (access + refresh) in the secrets store. /// /// Also stores the granted scopes as `{secret_name}_scopes` so that scope /// expansion can be detected on subsequent activations. #[allow(clippy::too_many_arguments)] pub async fn store_oauth_tokens( store: &(dyn SecretsStore + Send + Sync), user_id: &str, secret_name: &str, provider: Option<&str>, access_token: &str, refresh_token: Option<&str>, expires_in: Option, scopes: &[String], ) -> Result<(), OAuthCallbackError> { let mut params = CreateSecretParams::new(secret_name, access_token); if let Some(prov) = provider { params = params.with_provider(prov); } if let Some(secs) = expires_in { let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64); params = params.with_expiry(expires_at); } store .create(user_id, params) .await .map_err(|e| OAuthCallbackError::Io(format!("Failed to save token: {}", e)))?; // Store refresh token separately (no expiry, it's long-lived) if let Some(rt) = refresh_token { let refresh_name = format!("{}_refresh_token", secret_name); let mut refresh_params = CreateSecretParams::new(&refresh_name, rt); if let Some(prov) = provider { refresh_params = refresh_params.with_provider(prov); } store .create(user_id, refresh_params) .await .map_err(|e| OAuthCallbackError::Io(format!("Failed to save refresh token: {}", e)))?; } // Store granted scopes for scope expansion detection if !scopes.is_empty() { let scopes_name = format!("{}_scopes", secret_name); let scopes_value = scopes.join(" "); let scopes_params = CreateSecretParams::new(&scopes_name, &scopes_value); // Best-effort: scope tracking failure shouldn't block auth let _ = store.create(user_id, scopes_params).await; } Ok(()) } /// Validate an OAuth token against a tool's validation endpoint. /// /// Sends a request to the configured endpoint with the token as a Bearer header. /// Returns `Ok(())` if the response status matches the expected success status, /// or an error with details if validation fails (wrong account, expired token, etc.). pub async fn validate_oauth_token( token: &str, validation: &crate::tools::wasm::ValidationEndpointSchema, ) -> Result<(), OAuthCallbackError> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?; let request = match validation.method.to_uppercase().as_str() { "POST" => client.post(&validation.url), _ => client.get(&validation.url), }; let mut request = request.header("Authorization", format!("Bearer {}", token)); // Add custom headers from the validation schema (e.g., Notion-Version) for (key, value) in &validation.headers { request = request.header(key, value); } let response = request .send() .await .map_err(|e| OAuthCallbackError::Io(format!("Validation request failed: {}", e)))?; if response.status().as_u16() == validation.success_status { Ok(()) } else { let status = response.status(); let body = response.text().await.unwrap_or_default(); let truncated: String = if body.len() > 200 { let mut end = 200; while end > 0 && !body.is_char_boundary(end) { end -= 1; } format!("{}...", &body[..end]) } else { body }; Err(OAuthCallbackError::Io(format!( "Token validation failed: HTTP {} (expected {}): {}", status, validation.success_status, truncated ))) } } // ── Gateway callback support ───────────────────────────────────────── /// State for an in-progress OAuth flow, keyed by CSRF `state` parameter. /// /// Created by `start_wasm_oauth()` and consumed by the web gateway's /// `/oauth/callback` handler when running in hosted mode. pub struct PendingOAuthFlow { /// Extension name (e.g., "google_calendar"). pub extension_name: String, /// Human-readable display name (e.g., "Google Calendar"). pub display_name: String, /// OAuth token exchange URL. pub token_url: String, /// OAuth client ID. pub client_id: String, /// OAuth client secret (optional for PKCE-only flows). pub client_secret: Option, /// The redirect_uri used in the authorization request. pub redirect_uri: String, /// PKCE code verifier (must match the code_challenge sent in the auth URL). pub code_verifier: Option, /// Field name in token response containing the access token. pub access_token_field: String, /// Secret name for storage (e.g., "google_oauth_token"). pub secret_name: String, /// Provider hint (e.g., "google"). pub provider: Option, /// Token validation endpoint (optional). pub validation_endpoint: Option, /// Scopes that were requested. pub scopes: Vec, /// User ID for secret storage. pub user_id: String, /// Secrets store reference for token persistence. pub secrets: Arc, /// SSE broadcast manager for notifying the web UI. pub sse_manager: Option>, /// OAuth proxy auth token for authenticating with the hosted token exchange proxy. /// Kept as `gateway_token` for public API compatibility. pub gateway_token: Option, /// Additional form params for the token exchange request. /// Used for provider-specific requirements such as RFC 8707 `resource`. pub token_exchange_extra_params: HashMap, /// Secret name for persisting the client ID (MCP OAuth only). /// Needed so token refresh can find the client_id after the session ends. pub client_id_secret_name: Option, /// When this flow was created (for expiry). pub created_at: std::time::Instant, } impl std::fmt::Debug for PendingOAuthFlow { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PendingOAuthFlow") .field("extension_name", &self.extension_name) .field("display_name", &self.display_name) .field("secret_name", &self.secret_name) .field("created_at", &self.created_at) .finish_non_exhaustive() } } impl PendingOAuthFlow { pub fn oauth_proxy_auth_token(&self) -> Option<&str> { self.gateway_token.as_deref() } } /// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter. pub type PendingOAuthRegistry = Arc>>; /// Create a new empty pending OAuth flow registry. pub fn new_pending_oauth_registry() -> PendingOAuthRegistry { Arc::new(RwLock::new(HashMap::new())) } /// Returns `true` if OAuth callbacks should be routed through the web gateway /// instead of the local TCP listener. /// /// This is the case when `IRONCLAW_OAUTH_CALLBACK_URL` is set to a non-loopback /// URL, meaning the user's browser will redirect to a hosted gateway rather than /// localhost. pub fn use_gateway_callback() -> bool { crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL") .map(|raw| { url::Url::parse(&raw) .ok() .and_then(|u| u.host_str().map(String::from)) .map(|host| !is_loopback_host(&host)) .unwrap_or(false) }) .unwrap_or(false) } /// Returns the configured OAuth token-exchange proxy URL, if any. pub fn exchange_proxy_url() -> Option { crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL") .map(|url| url.trim().to_string()) .filter(|url| !url.is_empty()) } /// Returns the configured OAuth proxy auth token, if any. /// /// New hosted infra can inject a dedicated shared proxy secret via /// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to /// work by falling back to `GATEWAY_AUTH_TOKEN`. pub fn oauth_proxy_auth_token() -> Option { fn normalized_env_value(key: &str) -> Option { crate::config::helpers::env_or_override(key) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN") .or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN")) } /// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout). pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300); /// Remove expired flows from the registry. /// /// Called when inserting new flows to prevent accumulation from abandoned /// OAuth attempts. pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) { let mut flows = registry.write().await; flows.retain(|_, flow| flow.created_at.elapsed() < OAUTH_FLOW_EXPIRY); } // ── Platform routing helpers ──────────────────────────────────────── const HOSTED_STATE_PREFIX: &str = "ic2"; const HOSTED_STATE_CHECKSUM_BYTES: usize = 12; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DecodedHostedOAuthState { pub flow_id: String, pub instance_name: Option, pub is_legacy: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] struct HostedOAuthStatePayload { flow_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] instance_name: Option, issued_at: u64, } fn current_instance_name() -> Option { crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME") .or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME")) .filter(|v| !v.is_empty()) } fn hosted_state_checksum(payload_bytes: &[u8]) -> String { let digest = Sha256::digest(payload_bytes); URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES]) } /// Build a versioned hosted OAuth state envelope. /// /// The encoded value is opaque to providers and can be decoded by both /// IronClaw and the external auth proxy for routing and callback lookup. pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String { let payload = HostedOAuthStatePayload { flow_id: flow_id.to_string(), instance_name: instance_name .map(str::trim) .filter(|v| !v.is_empty()) .map(str::to_string), issued_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), }; let payload_json = match serde_json::to_vec(&payload) { Ok(payload_json) => payload_json, Err(error) => { tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload"); return payload.flow_id; } }; let payload = URL_SAFE_NO_PAD.encode(&payload_json); let checksum = hosted_state_checksum(&payload_json); format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}") } /// Decode hosted OAuth state in either the new versioned format or the /// legacy `instance:nonce`/`nonce` forms. pub fn decode_hosted_oauth_state(state: &str) -> Result { if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}.")) { let (payload_b64, checksum) = rest .rsplit_once('.') .ok_or("Hosted OAuth versioned state missing checksum separator")?; let payload_json = URL_SAFE_NO_PAD .decode(payload_b64) .map_err(|e| format!("Hosted OAuth versioned state base64 decode failed: {e}"))?; let expected_checksum = hosted_state_checksum(&payload_json); if checksum != expected_checksum { return Err("Hosted OAuth state checksum mismatch".to_string()); } let payload: HostedOAuthStatePayload = serde_json::from_slice(&payload_json) .map_err(|e| format!("Hosted OAuth versioned state JSON parse failed: {e}"))?; if payload.flow_id.trim().is_empty() { return Err("Hosted OAuth versioned state has empty flow_id".to_string()); } return Ok(DecodedHostedOAuthState { flow_id: payload.flow_id, instance_name: payload.instance_name.filter(|v| !v.is_empty()), is_legacy: false, }); } if let Some((instance_name, flow_id)) = state.split_once(':') { if flow_id.is_empty() { return Err("Hosted OAuth legacy state is missing flow_id".to_string()); } return Ok(DecodedHostedOAuthState { flow_id: flow_id.to_string(), instance_name: if instance_name.is_empty() { None } else { Some(instance_name.to_string()) }, is_legacy: true, }); } if state.is_empty() { return Err("Hosted OAuth state is empty".to_string()); } Ok(DecodedHostedOAuthState { flow_id: state.to_string(), instance_name: None, is_legacy: true, }) } /// Build the hosted callback state used by the public OAuth callback endpoint. /// /// New flows emit a versioned opaque envelope, while callback decoding accepts /// both the envelope and the legacy `instance:nonce` contract. pub fn build_platform_state(nonce: &str) -> String { encode_hosted_oauth_state(nonce, current_instance_name().as_deref()) } /// Strip the instance prefix from a state parameter to recover the lookup nonce. /// /// `"myinstance:abc123"` → `"abc123"`, `"abc123"` → `"abc123"` (no prefix). /// /// Safe because nonces are base64url-encoded (`[A-Za-z0-9_-]`, no colons). pub fn strip_instance_prefix(state: &str) -> &str { state .split_once(':') .map(|(_, nonce)| nonce) .unwrap_or(state) } pub struct ProxyTokenExchangeRequest<'a> { pub proxy_url: &'a str, /// OAuth proxy auth token. /// Kept as `gateway_token` for public API compatibility. pub gateway_token: &'a str, pub token_url: &'a str, pub client_id: &'a str, pub client_secret: Option<&'a str>, pub code: &'a str, pub redirect_uri: &'a str, pub code_verifier: Option<&'a str>, pub access_token_field: &'a str, pub extra_token_params: &'a HashMap, } pub struct ProxyRefreshTokenRequest<'a> { pub proxy_url: &'a str, /// OAuth proxy auth token. /// Kept as `gateway_token` for public API compatibility. pub gateway_token: &'a str, pub token_url: &'a str, pub client_id: &'a str, pub client_secret: Option<&'a str>, pub refresh_token: &'a str, pub provider: Option<&'a str>, } fn oauth_token_response_from_json( token_data: serde_json::Value, access_token_field: &str, ) -> Result { let access_token = token_data .get(access_token_field) .and_then(|v| v.as_str()) .ok_or_else(|| { let fields: Vec<&str> = token_data .as_object() .map(|o| o.keys().map(|k| k.as_str()).collect()) .unwrap_or_default(); OAuthCallbackError::Io(format!( "No '{}' field in proxy response (fields present: {:?})", access_token_field, fields )) })? .to_string(); let refresh_token = token_data .get("refresh_token") .and_then(|v| v.as_str()) .map(String::from); let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64()); Ok(OAuthTokenResponse { access_token, refresh_token, expires_in, }) } /// Exchange an OAuth authorization code via the platform's token exchange proxy. /// /// Authenticated via an OAuth proxy auth token (Bearer header). The caller may /// either rely on proxy-side secret lookup or forward a `client_secret` when /// the provider requires it. /// /// The proxy expects standard OAuth form params plus optional provider-specific /// token params and returns a standard token response such as /// `{access_token, refresh_token, expires_in}`. pub async fn exchange_via_proxy( request: ProxyTokenExchangeRequest<'_>, ) -> Result { if request.gateway_token.is_empty() { return Err(OAuthCallbackError::Io( "OAuth proxy auth token is required for proxy token exchange".to_string(), )); } let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/')); let client = reqwest::Client::builder() .timeout(Duration::from_secs(60)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?; let mut params = vec![ ("code", request.code.to_string()), ("redirect_uri", request.redirect_uri.to_string()), ("token_url", request.token_url.to_string()), ("client_id", request.client_id.to_string()), ("access_token_field", request.access_token_field.to_string()), ]; if let Some(verifier) = request.code_verifier { params.push(("code_verifier", verifier.to_string())); } if let Some(secret) = request.client_secret { params.push(("client_secret", secret.to_string())); } for (key, value) in request.extra_token_params { params.push((key.as_str(), value.clone())); } let response = client .post(&exchange_url) .bearer_auth(request.gateway_token) .form(¶ms) .send() .await .map_err(|e| { OAuthCallbackError::Io(format!("Token exchange proxy request failed: {}", e)) })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(OAuthCallbackError::Io(format!( "Token exchange proxy failed: {} - {}", status, body ))); } let token_data: serde_json::Value = response .json() .await .map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?; oauth_token_response_from_json(token_data, request.access_token_field) } /// Refresh an OAuth access token via the platform's token refresh proxy. /// /// Authenticated via an OAuth proxy auth token (Bearer header). The caller may /// either rely on proxy-side secret lookup or forward a `client_secret` when /// the provider requires it. pub async fn refresh_token_via_proxy( request: ProxyRefreshTokenRequest<'_>, ) -> Result { if request.gateway_token.is_empty() { return Err(OAuthCallbackError::Io( "OAuth proxy auth token is required for proxy token refresh".to_string(), )); } let refresh_url = format!("{}/oauth/refresh", request.proxy_url.trim_end_matches('/')); let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?; let mut params = vec![ ("refresh_token", request.refresh_token.to_string()), ("token_url", request.token_url.to_string()), ("client_id", request.client_id.to_string()), ]; if let Some(secret) = request.client_secret { params.push(("client_secret", secret.to_string())); } if let Some(provider) = request.provider { params.push(("provider", provider.to_string())); } let response = client .post(&refresh_url) .bearer_auth(request.gateway_token) .form(¶ms) .send() .await .map_err(|e| { OAuthCallbackError::Io(format!("Token refresh proxy request failed: {}", e)) })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(OAuthCallbackError::Io(format!( "Token refresh proxy failed: {} - {}", status, body ))); } let token_data: serde_json::Value = response .json() .await .map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?; oauth_token_response_from_json(token_data, "access_token") } #[cfg(test)] mod tests { use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use axum::extract::{Form, State}; use axum::http::HeaderMap; use axum::response::Redirect; use axum::routing::post; use axum::{Json, Router}; use serde_json::json; use tokio::net::TcpListener; use tokio::sync::{Mutex, oneshot}; use crate::cli::oauth_defaults::{ builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html, }; use crate::config::helpers::lock_env; use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET}; #[derive(Clone, Debug, PartialEq, Eq)] struct RecordedProxyRequest { authorization: Option, form: HashMap, } #[derive(Clone)] struct MockProxyState { requests: Arc>>, exchange_redirect_target: String, refresh_redirect_target: String, } struct MockProxyServer { addr: SocketAddr, requests: Arc>>, shutdown_tx: Option>, server_task: Option>, } impl MockProxyServer { async fn start() -> Self { async fn exchange_handler( State(state): State, headers: HeaderMap, Form(form): Form>, ) -> Json { state.requests.lock().await.push(RecordedProxyRequest { authorization: headers .get(axum::http::header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .map(str::to_string), form, }); Json(json!({ "access_token": "proxy-access-token", "refresh_token": "proxy-refresh-token", "expires_in": 7200 })) } async fn refresh_handler( State(state): State, headers: HeaderMap, Form(form): Form>, ) -> Json { state.requests.lock().await.push(RecordedProxyRequest { authorization: headers .get(axum::http::header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .map(str::to_string), form, }); Json(json!({ "access_token": "proxy-access-token", "refresh_token": "proxy-refresh-token", "expires_in": 7200 })) } async fn exchange_redirect_handler(State(state): State) -> Redirect { Redirect::temporary(&state.exchange_redirect_target) } async fn refresh_redirect_handler(State(state): State) -> Redirect { Redirect::temporary(&state.refresh_redirect_target) } let requests = Arc::new(Mutex::new(Vec::new())); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind mock proxy"); let addr = listener.local_addr().expect("read mock proxy addr"); let exchange_redirect_target = format!("http://{addr}/oauth/exchange"); let refresh_redirect_target = format!("http://{addr}/oauth/refresh"); let app = Router::new() .route("/oauth/exchange", post(exchange_handler)) .route("/oauth/refresh", post(refresh_handler)) .route("/redirect/oauth/exchange", post(exchange_redirect_handler)) .route("/redirect/oauth/refresh", post(refresh_redirect_handler)) .with_state(MockProxyState { requests: Arc::clone(&requests), exchange_redirect_target, refresh_redirect_target, }); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let server_task = tokio::spawn(async move { let _ = axum::serve(listener, app) .with_graceful_shutdown(async { let _ = shutdown_rx.await; }) .await; }); Self { addr, requests, shutdown_tx: Some(shutdown_tx), server_task: Some(server_task), } } fn base_url(&self) -> String { format!("http://{}", self.addr) } fn redirecting_base_url(&self) -> String { format!("{}/redirect", self.base_url()) } async fn requests(&self) -> Vec { self.requests.lock().await.clone() } async fn shutdown(mut self) { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } if let Some(task) = self.server_task.take() { let _ = task.await; } } } impl Drop for MockProxyServer { fn drop(&mut self) { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } if let Some(task) = self.server_task.take() { task.abort(); } } } struct EnvVarGuard { key: &'static str, original: Option, } impl Drop for EnvVarGuard { fn drop(&mut self) { // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { if let Some(ref value) = self.original { std::env::set_var(self.key, value); } else { std::env::remove_var(self.key); } } } } fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard { let original = std::env::var(key).ok(); // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { if let Some(value) = value { std::env::set_var(key, value); } else { std::env::remove_var(key); } } EnvVarGuard { key, original } } #[test] fn test_hosted_proxy_client_secret_suppresses_builtin_secret() { let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds"); let client_secret = Some(builtin.client_secret.to_string()); let result = super::hosted_proxy_client_secret(&client_secret, Some(&builtin), true); assert_eq!(result, None); } #[test] fn test_hosted_proxy_client_secret_preserves_explicit_secret() { let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds"); let client_secret = Some("hosted-server-secret".to_string()); let result = super::hosted_proxy_client_secret(&client_secret, Some(&builtin), true); assert_eq!(result, client_secret); } #[tokio::test] async fn test_exchange_via_proxy_sends_auth_and_form() { let server = MockProxyServer::start().await; let mut extra_token_params = HashMap::new(); extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string()); let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest { proxy_url: &server.base_url(), gateway_token: "shared-oauth-proxy-secret", code: "auth-code-123", redirect_uri: "https://oauth.example.com/oauth/callback", token_url: "https://oauth2.googleapis.com/token", client_id: TEST_OAUTH_CLIENT_ID, client_secret: Some(TEST_OAUTH_CLIENT_SECRET), access_token_field: "access_token", code_verifier: Some("code-verifier-123"), extra_token_params: &extra_token_params, }) .await .expect("proxy exchange succeeds"); assert_eq!(response.access_token, "proxy-access-token"); assert_eq!( response.refresh_token.as_deref(), Some("proxy-refresh-token") ); assert_eq!(response.expires_in, Some(7200)); let requests = server.requests().await; assert_eq!(requests.len(), 1); assert_eq!( requests[0].authorization.as_deref(), Some("Bearer shared-oauth-proxy-secret") ); assert_eq!( requests[0].form.get("code").map(String::as_str), Some("auth-code-123") ); assert_eq!( requests[0].form.get("redirect_uri").map(String::as_str), Some("https://oauth.example.com/oauth/callback") ); assert_eq!( requests[0].form.get("token_url").map(String::as_str), Some("https://oauth2.googleapis.com/token") ); assert_eq!( requests[0].form.get("client_id").map(String::as_str), Some(TEST_OAUTH_CLIENT_ID) ); assert_eq!( requests[0].form.get("client_secret").map(String::as_str), Some(TEST_OAUTH_CLIENT_SECRET) ); assert_eq!( requests[0] .form .get("access_token_field") .map(String::as_str), Some("access_token") ); assert_eq!( requests[0].form.get("code_verifier").map(String::as_str), Some("code-verifier-123") ); assert_eq!( requests[0].form.get("resource").map(String::as_str), Some("https://mcp.notion.com") ); server.shutdown().await; } #[tokio::test] async fn test_refresh_token_via_proxy_sends_auth_and_form() { let server = MockProxyServer::start().await; let response = super::refresh_token_via_proxy(super::ProxyRefreshTokenRequest { proxy_url: &server.base_url(), gateway_token: "gateway-test-token", token_url: "https://oauth2.googleapis.com/token", client_id: TEST_OAUTH_CLIENT_ID, client_secret: Some(TEST_OAUTH_CLIENT_SECRET), refresh_token: "refresh-token-123", provider: Some("google"), }) .await .expect("proxy refresh succeeds"); assert_eq!(response.access_token, "proxy-access-token"); assert_eq!( response.refresh_token.as_deref(), Some("proxy-refresh-token") ); assert_eq!(response.expires_in, Some(7200)); let requests = server.requests().await; assert_eq!(requests.len(), 1); assert_eq!( requests[0].authorization.as_deref(), Some("Bearer gateway-test-token") ); assert_eq!( requests[0].form.get("token_url").map(String::as_str), Some("https://oauth2.googleapis.com/token") ); assert_eq!( requests[0].form.get("client_id").map(String::as_str), Some(TEST_OAUTH_CLIENT_ID) ); assert_eq!( requests[0].form.get("client_secret").map(String::as_str), Some(TEST_OAUTH_CLIENT_SECRET) ); assert_eq!( requests[0].form.get("refresh_token").map(String::as_str), Some("refresh-token-123") ); assert_eq!( requests[0].form.get("provider").map(String::as_str), Some("google") ); server.shutdown().await; } #[tokio::test] async fn test_exchange_via_proxy_does_not_follow_redirects() { let server = MockProxyServer::start().await; let error = match super::exchange_via_proxy(super::ProxyTokenExchangeRequest { proxy_url: &server.redirecting_base_url(), gateway_token: "gateway-test-token", code: "auth-code-123", redirect_uri: "http://localhost:3000/oauth/callback", token_url: "https://oauth2.googleapis.com/token", client_id: TEST_OAUTH_CLIENT_ID, client_secret: Some(TEST_OAUTH_CLIENT_SECRET), access_token_field: "access_token", code_verifier: Some("code-verifier-123"), extra_token_params: &HashMap::new(), }) .await { Ok(_) => panic!("redirected proxy exchange should fail"), Err(error) => error, }; assert!(error.to_string().contains("307")); assert!(server.requests().await.is_empty()); server.shutdown().await; } #[tokio::test] async fn test_refresh_token_via_proxy_does_not_follow_redirects() { let server = MockProxyServer::start().await; let error = match super::refresh_token_via_proxy(super::ProxyRefreshTokenRequest { proxy_url: &server.redirecting_base_url(), gateway_token: "gateway-test-token", token_url: "https://oauth2.googleapis.com/token", client_id: TEST_OAUTH_CLIENT_ID, client_secret: Some(TEST_OAUTH_CLIENT_SECRET), refresh_token: "refresh-token-123", provider: Some("google"), }) .await { Ok(_) => panic!("redirected proxy refresh should fail"), Err(error) => error, }; assert!(error.to_string().contains("307")); assert!(server.requests().await.is_empty()); server.shutdown().await; } #[test] fn test_is_loopback_host() { assert!(is_loopback_host("127.0.0.1")); assert!(is_loopback_host("127.0.0.2")); // full 127.0.0.0/8 range assert!(is_loopback_host("127.255.255.254")); assert!(is_loopback_host("::1")); assert!(is_loopback_host("localhost")); assert!(is_loopback_host("LOCALHOST")); assert!(!is_loopback_host("203.0.113.10")); assert!(!is_loopback_host("my-server.example.com")); assert!(!is_loopback_host("0.0.0.0")); } #[test] fn test_callback_host_default() { let _guard = lock_env(); let original = std::env::var("OAUTH_CALLBACK_HOST").ok(); // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST"); } assert_eq!(callback_host(), "127.0.0.1"); // Restore unsafe { if let Some(val) = original { std::env::set_var("OAUTH_CALLBACK_HOST", val); } } } #[test] fn test_callback_host_env_override() { let _guard = lock_env(); let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok(); let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "203.0.113.10"); std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); } assert_eq!(callback_host(), "203.0.113.10"); // callback_url() fallback should incorporate the custom host let url = callback_url(); assert!(url.contains("203.0.113.10"), "url was: {url}"); // Restore unsafe { if let Some(val) = original_host { std::env::set_var("OAUTH_CALLBACK_HOST", val); } else { std::env::remove_var("OAUTH_CALLBACK_HOST"); } if let Some(val) = original_url { std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); } } } #[test] fn test_callback_url_default() { let _guard = lock_env(); // Clear both env vars to test default behavior let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok(); // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); std::env::remove_var("OAUTH_CALLBACK_HOST"); } let url = callback_url(); assert_eq!(url, "http://127.0.0.1:9876"); // Restore unsafe { if let Some(val) = original_url { std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); } if let Some(val) = original_host { std::env::set_var("OAUTH_CALLBACK_HOST", val); } } } #[test] fn test_callback_url_env_override() { let _guard = lock_env(); let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); // SAFETY: Under ENV_MUTEX, no concurrent env access. unsafe { std::env::set_var( "IRONCLAW_OAUTH_CALLBACK_URL", "https://myserver.example.com:9876", ); } let url = callback_url(); assert_eq!(url, "https://myserver.example.com:9876"); // Restore unsafe { if let Some(val) = original { std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); } else { std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); } } } #[test] fn test_unknown_provider_returns_none() { assert!(builtin_credentials("unknown_token").is_none()); } #[test] fn test_google_returns_based_on_compile_env() { let creds = builtin_credentials("google_oauth_token"); assert!(creds.is_some()); let creds = creds.unwrap(); assert!(!creds.client_id.is_empty()); assert!(!creds.client_secret.is_empty()); } #[test] fn test_landing_html_success_contains_key_elements() { let html = landing_html("Google", true); assert!(html.contains("Google Connected")); assert!(html.contains("charset")); assert!(html.contains("IronClaw")); assert!(html.contains("#22c55e")); // green accent assert!(!html.contains("Failed")); } #[test] fn test_landing_html_escapes_provider_name() { let html = landing_html("", true); assert!(!html.contains("