//! 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, } } // ── 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>, /// Gateway auth token for authenticating with the platform token exchange proxy. 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() } } /// 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()) } /// 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, 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, } /// Exchange an OAuth authorization code via the platform's token exchange proxy. /// /// Authenticated via the gateway 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( "Gateway 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)) .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)))?; let access_token = token_data .get(request.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: {:?})", request.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, }) } #[cfg(test)] mod tests { use crate::cli::oauth_defaults::{ builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html, }; use crate::config::helpers::lock_env; #[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("