use std::time::Duration; use forge_app::{AuthStrategy, OAuthHttpProvider, StrategyFactory}; use forge_domain::{ ApiKey, ApiKeyRequest, AuthContextRequest, AuthContextResponse, AuthCredential, CodeRequest, DeviceCodeRequest, OAuthConfig, OAuthTokenResponse, OAuthTokens, ProviderId, URLParam, URLParamSpec, }; use google_cloud_auth::credentials::Builder; use oauth2::basic::BasicClient; use oauth2::{ClientId, DeviceAuthorizationUrl, Scope, TokenUrl}; use reqwest::header::{HeaderMap, HeaderValue}; use url::Url; use crate::auth::error::Error as AuthError; use crate::auth::http::{AnthropicHttpProvider, GithubHttpProvider, StandardHttpProvider}; use crate::auth::util::*; /// API Key Strategy - Simple static key authentication pub struct ApiKeyStrategy { provider_id: ProviderId, required_params: Vec, } impl ApiKeyStrategy { pub fn new(provider_id: ProviderId, required_params: Vec) -> Self { Self { provider_id, required_params } } } #[async_trait::async_trait] impl AuthStrategy for ApiKeyStrategy { async fn init(&self) -> anyhow::Result { Ok(AuthContextRequest::ApiKey(ApiKeyRequest { required_params: self.required_params.clone(), existing_params: None, api_key: None, })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::ApiKey(ctx) => Ok(AuthCredential::new_api_key( self.provider_id.clone(), ctx.response.api_key, ) .url_params(ctx.response.url_params)), _ => Err(AuthError::InvalidContext("Expected ApiKey context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { // API keys don't expire - return as-is Ok(credential.clone()) } } /// Extract the ChatGPT account ID from a JWT token's claims. /// /// Checks `chatgpt_account_id`, `https://api.openai.com/auth.chatgpt_account_id`, /// and `organizations[0].id` in that order, matching the opencode /// implementation. fn extract_chatgpt_account_id(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return None; } use base64::Engine; let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(parts.get(1)?) .ok()?; let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?; // Try chatgpt_account_id first if let Some(id) = claims.get("chatgpt_account_id").and_then(|v| v.as_str()) { return Some(id.to_string()); } // Try nested auth claim if let Some(id) = claims .get("https://api.openai.com/auth") .and_then(|v| v.get("chatgpt_account_id")) .and_then(|v| v.as_str()) { return Some(id.to_string()); } // Fall back to organizations[0].id if let Some(id) = claims .get("organizations") .and_then(|v| v.as_array()) .and_then(|orgs| orgs.first()) .and_then(|org| org.get("id").and_then(|v| v.as_str())) { return Some(id.to_string()); } None } /// Adds Codex-specific credential metadata derived from OAuth tokens. /// /// Tries to extract the account ID from the `id_token` first (which typically /// contains the user identity claims in OpenID Connect flows), then falls back /// to the `access_token` if needed. fn enrich_codex_oauth_credential( provider_id: &ProviderId, credential: &mut AuthCredential, id_token: Option<&str>, access_token: &str, ) { if *provider_id != ProviderId::CODEX { return; } // Try id_token first (preferred for user identity claims) let account_id = id_token .and_then(extract_chatgpt_account_id) .or_else(|| extract_chatgpt_account_id(access_token)); if let Some(account_id) = account_id { credential .url_params .insert("chatgpt_account_id".to_string().into(), account_id.into()); } } /// OAuth Code Strategy - Browser redirect flow pub struct OAuthCodeStrategy { provider_id: ProviderId, config: OAuthConfig, adapter: T, } impl OAuthCodeStrategy { pub fn new(adapter: T, provider_id: ProviderId, config: OAuthConfig) -> Self { Self { config, provider_id, adapter } } } #[async_trait::async_trait] impl AuthStrategy for OAuthCodeStrategy { async fn init(&self) -> anyhow::Result { let auth_params = self .adapter .build_auth_url(&self.config) .await .map_err(|e| AuthError::InitiationFailed(format!("Failed to build auth URL: {e}")))?; Ok(AuthContextRequest::Code(CodeRequest { authorization_url: Url::parse(&auth_params.auth_url)?, state: auth_params.state.into(), pkce_verifier: auth_params.code_verifier.map(Into::into), oauth_config: self.config.clone(), })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::Code(ctx) => { let token_response = self .adapter .exchange_code( &ctx.request.oauth_config, ctx.response.code.as_str(), ctx.request.pkce_verifier.as_ref().map(|v| v.as_str()), ) .await .map_err(|e| { AuthError::CompletionFailed(format!( "Failed to exchange authorization code: {e}" )) })?; let access_token = token_response.access_token.clone(); let id_token = token_response.id_token.clone(); let mut credential = build_oauth_credential( self.provider_id.clone(), token_response, &ctx.request.oauth_config, chrono::Duration::hours(1), // Code flow default )?; enrich_codex_oauth_credential( &self.provider_id, &mut credential, id_token.as_deref(), &access_token, ); Ok(credential) } _ => Err(AuthError::InvalidContext("Expected Code context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { refresh_oauth_credential( credential, &self.config, chrono::Duration::hours(1), false, // No API key exchange ) .await } } /// OAuth Device Strategy - Device code flow pub struct OAuthDeviceStrategy { provider_id: ProviderId, config: OAuthConfig, } impl OAuthDeviceStrategy { pub fn new(provider_id: ProviderId, config: OAuthConfig) -> Self { Self { provider_id, config } } } #[async_trait::async_trait] impl AuthStrategy for OAuthDeviceStrategy { async fn init(&self) -> anyhow::Result { // Build oauth2 client let client = BasicClient::new(ClientId::new(self.config.client_id.to_string())) .set_device_authorization_url( DeviceAuthorizationUrl::new(self.config.auth_url.to_string()) .map_err(|e| AuthError::InitiationFailed(format!("Invalid auth_url: {e}")))?, ) .set_token_uri( TokenUrl::new(self.config.token_url.to_string()) .map_err(|e| AuthError::InitiationFailed(format!("Invalid token_url: {e}")))?, ); // Request device authorization let mut request = client.exchange_device_code(); for scope in &self.config.scopes { request = request.add_scope(Scope::new(scope.clone())); } // Build HTTP client with custom headers let http_client = build_http_client(self.config.custom_headers.as_ref()).map_err(|e| { AuthError::InitiationFailed(format!("Failed to build HTTP client: {e}")) })?; let http_fn = |req| github_compliant_http_request(http_client.clone(), req); let device_auth_response: oauth2::StandardDeviceAuthorizationResponse = request.request_async(&http_fn).await.map_err(|e| { AuthError::InitiationFailed(format!("Device authorization request failed: {e}")) })?; // Build the type-safe context Ok(AuthContextRequest::DeviceCode(DeviceCodeRequest { user_code: device_auth_response.user_code().secret().to_string().into(), device_code: device_auth_response .device_code() .secret() .to_string() .into(), verification_uri: Url::parse(&device_auth_response.verification_uri().to_string())?, verification_uri_complete: device_auth_response .verification_uri_complete() .map(|u| Url::parse(&u.secret().to_string()).unwrap()), expires_in: device_auth_response.expires_in().as_secs(), interval: device_auth_response.interval().as_secs(), oauth_config: self.config.clone(), })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::DeviceCode(ctx) => { let token_response = poll_for_tokens( &ctx.request.device_code, &self.config, Duration::from_secs(600), false, ) .await?; build_oauth_credential( self.provider_id.clone(), token_response, &self.config, chrono::Duration::days(365), // Device flow default ) } _ => Err(AuthError::InvalidContext("Expected DeviceCode context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { refresh_oauth_credential( credential, &self.config, chrono::Duration::days(30), false, // No API key exchange ) .await } } /// OAuth-with-API-Key Strategy - Hybrid flow (GitHub Copilot pattern) pub struct OAuthWithApiKeyStrategy { provider_id: ProviderId, oauth_config: OAuthConfig, api_key_exchange_url: Url, } impl OAuthWithApiKeyStrategy { pub fn new(provider_id: ProviderId, oauth_config: OAuthConfig) -> anyhow::Result { let api_key_exchange_url = oauth_config .token_refresh_url .clone() .ok_or_else(|| AuthError::InitiationFailed("Missing token_refresh_url".to_string()))?; Ok(Self { provider_id, oauth_config, api_key_exchange_url }) } } #[async_trait::async_trait] impl AuthStrategy for OAuthWithApiKeyStrategy { async fn init(&self) -> anyhow::Result { // Same as OAuth Device init let client = BasicClient::new(ClientId::new(self.oauth_config.client_id.to_string())) .set_device_authorization_url( DeviceAuthorizationUrl::new(self.oauth_config.auth_url.to_string()) .map_err(|e| AuthError::InitiationFailed(format!("Invalid auth_url: {e}")))?, ) .set_token_uri( TokenUrl::new(self.oauth_config.token_url.to_string()) .map_err(|e| AuthError::InitiationFailed(format!("Invalid token_url: {e}")))?, ); let mut request = client.exchange_device_code(); for scope in &self.oauth_config.scopes { request = request.add_scope(Scope::new(scope.clone())); } let http_client = build_http_client(self.oauth_config.custom_headers.as_ref()).map_err(|e| { AuthError::InitiationFailed(format!("Failed to build HTTP client: {e}")) })?; let http_fn = |req| github_compliant_http_request(http_client.clone(), req); let device_auth_response: oauth2::StandardDeviceAuthorizationResponse = request.request_async(&http_fn).await.map_err(|e| { AuthError::InitiationFailed(format!("Device authorization request failed: {e}")) })?; Ok(AuthContextRequest::DeviceCode(DeviceCodeRequest { user_code: device_auth_response.user_code().secret().to_string().into(), device_code: device_auth_response .device_code() .secret() .to_string() .into(), verification_uri: Url::parse(&device_auth_response.verification_uri().to_string())?, verification_uri_complete: device_auth_response .verification_uri_complete() .map(|u| Url::parse(&u.secret().to_string()).unwrap()), expires_in: device_auth_response.expires_in().as_secs(), interval: device_auth_response.interval().as_secs(), oauth_config: self.oauth_config.clone(), })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::DeviceCode(ctx) => { // Poll for OAuth tokens (GitHub-compatible) let token_response = poll_for_tokens( &ctx.request.device_code, &self.oauth_config, Duration::from_secs(600), true, ) .await?; // Exchange for API key let (api_key, expires_at) = exchange_oauth_for_api_key( &token_response.access_token, &self.api_key_exchange_url, &self.oauth_config, ) .await?; let oauth_tokens = OAuthTokens::new( token_response.access_token, token_response.refresh_token, expires_at, ); Ok(AuthCredential::new_oauth_with_api_key( self.provider_id.clone(), oauth_tokens, api_key, self.oauth_config.clone(), )) } _ => Err(AuthError::InvalidContext("Expected DeviceCode context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { refresh_oauth_credential( credential, &self.oauth_config, chrono::Duration::hours(1), // Unused for API key flow true, // WITH API key exchange ) .await } } /// Google Application Default Credentials (ADC) Strategy /// Uses Google Cloud SDK's ADC mechanism with automatic token refresh pub struct GoogleAdcStrategy { provider_id: ProviderId, required_params: Vec, } impl GoogleAdcStrategy { pub fn new(provider_id: ProviderId, required_params: Vec) -> Self { Self { provider_id, required_params } } } #[async_trait::async_trait] impl AuthStrategy for GoogleAdcStrategy { async fn init(&self) -> anyhow::Result { // For Google ADC, we don't need any user interaction for the API key // The credentials are automatically discovered from: // 1. GOOGLE_APPLICATION_CREDENTIALS env var (service account) // 2. gcloud ADC credentials (user credentials) // 3. Metadata server (GCP environment) // However, we still need to collect URL params like PROJECT_ID and LOCATION Ok(AuthContextRequest::ApiKey(ApiKeyRequest { required_params: self.required_params.clone(), existing_params: None, api_key: Some("google_adc_marker".to_string().into()), // Marker to indicate ADC usage })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::ApiKey(ctx) => { // Validate that gcloud auth is properly configured before completing // authentication This ensures the user has run 'gcloud auth // application-default login' use google_cloud_auth::credentials::Builder; const VERTEX_AI_SCOPES: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"]; let credentials = Builder::default() .with_scopes(VERTEX_AI_SCOPES.iter().map(|s| s.to_string())) .build_access_token_credentials() .map_err(|e| { AuthError::CompletionFailed(format!( "Google ADC not configured: {e}. Please run 'gcloud auth application-default login' to set up credentials." )) })?; // Try to fetch a token to verify authentication works credentials .access_token() .await .map_err(|e| { AuthError::CompletionFailed(format!( "Failed to obtain access token: {e}. Your ADC credentials may be expired — run 'gcloud auth application-default login' to re-authenticate." )) })?; // For Google ADC, we save a marker instead of the actual token // The token will be refreshed on every use // But we still need to save the url_params (PROJECT_ID, LOCATION) Ok(AuthCredential::new_google_adc( self.provider_id.clone(), ApiKey::from("google_adc_marker".to_string()), /* Marker that will trigger * refresh */ ) .url_params(ctx.response.url_params)) } _ => Err(AuthError::InvalidContext("Expected ApiKey context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { // Google ADC handles token refresh automatically // We just need to get a fresh token using the Builder API // Vertex AI requires the cloud-platform scope const VERTEX_AI_SCOPES: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"]; let credentials = Builder::default() .with_scopes(VERTEX_AI_SCOPES.iter().map(|s| s.to_string())) .build_access_token_credentials() .map_err(|e| { AuthError::RefreshFailed(format!( "Failed to create Google credentials builder: {e}" )) })?; let access_token = credentials.access_token().await.map_err(|e| { AuthError::RefreshFailed(format!( "Failed to refresh Google access token: {e}. Your ADC credentials may be expired — run 'gcloud auth application-default login' to re-authenticate." )) })?; Ok(AuthCredential::new_google_adc( self.provider_id.clone(), ApiKey::from(access_token.token), ) .url_params(credential.url_params.clone())) } } /// AWS Profile Strategy - Uses AWS SDK credential chain with a named profile /// Supports SSO, IAM, and other credential types configured in ~/.aws/config pub struct AwsProfileStrategy { provider_id: ProviderId, required_params: Vec, } const AWS_PROFILE_PARAM: &str = "AWS_PROFILE"; impl AwsProfileStrategy { pub fn new(provider_id: ProviderId, mut required_params: Vec) -> Self { let profile_param = URLParamSpec::new(URLParam::from(AWS_PROFILE_PARAM.to_string())); if !required_params.iter().any(|p| p.name == profile_param.name) { required_params.push(profile_param); } Self { provider_id, required_params } } } #[async_trait::async_trait] impl AuthStrategy for AwsProfileStrategy { async fn init(&self) -> anyhow::Result { Ok(AuthContextRequest::ApiKey(ApiKeyRequest { required_params: self.required_params.clone(), existing_params: None, api_key: Some("aws_profile_marker".to_string().into()), })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::ApiKey(ctx) => { let profile = ctx .response .url_params .get(&URLParam::from(AWS_PROFILE_PARAM.to_string())) .map(|v| v.to_string()) .ok_or_else(|| { AuthError::CompletionFailed("AWS_PROFILE is required".to_string()) })?; // Validate the profile works by attempting to load credentials let aws_config = aws_config::from_env().profile_name(&profile).load().await; let credentials_provider = aws_config.credentials_provider().ok_or_else(|| { AuthError::CompletionFailed(format!( "No credentials found for profile '{}'. Ensure the profile exists in ~/.aws/config and you've run 'aws sso login --profile {}'", profile, profile )) })?; // Try to resolve credentials to verify they work use aws_credential_types::provider::ProvideCredentials; credentials_provider .provide_credentials() .await .map_err(|e| { AuthError::CompletionFailed(format!( "Failed to resolve credentials for profile '{}': {}. Try running 'aws sso login --profile {}'", profile, e, profile )) })?; Ok( AuthCredential::new_aws_profile( self.provider_id.clone(), ApiKey::from(profile), ) .url_params(ctx.response.url_params), ) } _ => Err(AuthError::InvalidContext("Expected ApiKey context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { // AWS SDK handles SSO token refresh internally Ok(credential.clone()) } } /// OpenAI Codex Device Strategy - Custom device auth for ChatGPT Pro/Plus /// /// Implements the OpenAI-specific device authorization flow used by Codex: /// 1. Request device code from `/api/accounts/deviceauth/usercode` /// 2. User enters code at `https://auth.openai.com/codex/device` /// 3. Poll `/api/accounts/deviceauth/token` for authorization code + verifier /// 4. Exchange authorization code for OAuth tokens via standard token endpoint pub struct CodexDeviceStrategy { provider_id: ProviderId, config: OAuthConfig, } impl CodexDeviceStrategy { pub fn new(provider_id: ProviderId, config: OAuthConfig) -> Self { Self { provider_id, config } } } /// Response from the OpenAI device auth usercode endpoint #[derive(Debug, serde::Deserialize)] struct CodexDeviceAuthResponse { device_auth_id: String, user_code: String, interval: String, } /// Response from the OpenAI device auth token polling endpoint #[derive(Debug, serde::Deserialize)] struct CodexDeviceTokenResponse { authorization_code: String, code_verifier: String, } #[async_trait::async_trait] impl AuthStrategy for CodexDeviceStrategy { async fn init(&self) -> anyhow::Result { let http_client = build_http_client(self.config.custom_headers.as_ref()).map_err(|e| { AuthError::InitiationFailed(format!("Failed to build HTTP client: {e}")) })?; // Step 1: Request device authorization from OpenAI's custom endpoint let response = http_client .post(self.config.auth_url.as_str()) .header("Content-Type", "application/json") .json(&serde_json::json!({ "client_id": self.config.client_id.as_str() })) .send() .await .map_err(|e| { AuthError::InitiationFailed(format!("Device authorization request failed: {e}")) })?; if !response.status().is_success() { return Err(AuthError::InitiationFailed(format!( "Device authorization failed with status: {}", response.status() )) .into()); } let device_data: CodexDeviceAuthResponse = response.json().await.map_err(|e| { AuthError::InitiationFailed(format!("Failed to parse device auth response: {e}")) })?; let interval: u64 = device_data.interval.parse().unwrap_or(5).max(1); // Build the device code request using existing domain types // We encode the device_auth_id in the device_code field Ok(AuthContextRequest::DeviceCode(DeviceCodeRequest { user_code: device_data.user_code.clone().into(), device_code: device_data.device_auth_id.into(), verification_uri: Url::parse("https://auth.openai.com/codex/device")?, verification_uri_complete: None, expires_in: 300, // 5 minute timeout interval, oauth_config: self.config.clone(), })) } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match context_response { AuthContextResponse::DeviceCode(ctx) => { // Poll for authorization code using the custom OpenAI endpoint let token_response = codex_poll_for_tokens(&ctx.request, &self.config).await?; let access_token = token_response.access_token.clone(); let id_token = token_response.id_token.clone(); let mut credential = build_oauth_credential( self.provider_id.clone(), token_response, &self.config, chrono::Duration::hours(1), )?; // Store account_id in url_params so it's persisted and available // for chat request headers. enrich_codex_oauth_credential( &self.provider_id, &mut credential, id_token.as_deref(), &access_token, ); Ok(credential) } _ => Err(AuthError::InvalidContext("Expected DeviceCode context".to_string()).into()), } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { refresh_oauth_credential(credential, &self.config, chrono::Duration::hours(1), false).await } } /// Refresh OAuth credential - handles all OAuth flows async fn refresh_oauth_credential( credential: &AuthCredential, config: &OAuthConfig, expiry_duration: chrono::Duration, with_api_key_exchange: bool, ) -> anyhow::Result { // Extract tokens (works for OAuth and OAuthWithApiKey) let tokens = extract_oauth_tokens(credential)?; // Determine which OAuth access token to use let (oauth_access_token, oauth_refresh_token) = if let Some(refresh_token) = &tokens.refresh_token { // If we have a refresh token, refresh the OAuth access token first tracing::debug!("Refreshing OAuth access token using refresh token"); let token_response = refresh_access_token(config, refresh_token.as_str()).await?; ( token_response.access_token.clone(), token_response.refresh_token, ) } else { // No refresh token - use the existing long-lived OAuth access token // This is typical for GitHub Copilot where the OAuth token is long-lived tracing::debug!("No refresh token available, using existing OAuth access token"); ( tokens.access_token.to_string(), tokens.refresh_token.clone().map(|t| t.to_string()), ) }; // Exchange for API key if needed (GitHub Copilot pattern) let (api_key, expires_at) = if with_api_key_exchange { let url = config.token_refresh_url.as_ref().ok_or_else(|| { AuthError::RefreshFailed("Missing token_refresh_url for API key exchange".to_string()) })?; let (key, expiry) = exchange_oauth_for_api_key(&oauth_access_token, url, config).await?; (Some(key), expiry) } else { let expiry = calculate_token_expiry(None, expiry_duration); (None, expiry) }; // Build new tokens with refreshed OAuth access token let new_tokens = OAuthTokens::new(oauth_access_token, oauth_refresh_token, expires_at); // Build appropriate credential type while preserving URL params let refreshed = if let Some(key) = api_key { AuthCredential::new_oauth_with_api_key( credential.id.clone(), new_tokens, key, config.clone(), ) } else { AuthCredential::new_oauth(credential.id.clone(), new_tokens, config.clone()) }; Ok(refreshed.url_params(credential.url_params.clone())) } /// Poll for OAuth tokens during device flow async fn poll_for_tokens( device_code: &forge_domain::DeviceCode, config: &OAuthConfig, timeout: Duration, github_compatible: bool, ) -> anyhow::Result { let http_client = build_http_client(config.custom_headers.as_ref()) .map_err(|e| AuthError::PollFailed(format!("Failed to build HTTP client: {e}")))?; let start_time = tokio::time::Instant::now(); let interval = Duration::from_secs(5); loop { // Check timeout if start_time.elapsed() >= timeout { return Err(AuthError::Timeout(timeout).into()); } // Sleep before polling (GitHub pattern only) if github_compatible { tokio::time::sleep(interval).await; } // Build token request let params = vec![ ( "grant_type".to_string(), "urn:ietf:params:oauth:grant-type:device_code".to_string(), ), ("device_code".to_string(), device_code.to_string()), ("client_id".to_string(), config.client_id.to_string()), ]; let body = serde_urlencoded::to_string(¶ms) .map_err(|e| AuthError::PollFailed(format!("Failed to encode request: {e}")))?; // Make HTTP request with headers let mut headers = HeaderMap::new(); headers.insert( "Content-Type", HeaderValue::from_static("application/x-www-form-urlencoded"), ); headers.insert("Accept", HeaderValue::from_static("application/json")); inject_custom_headers(&mut headers, &config.custom_headers); let response = http_client .post(config.token_url.as_str()) .headers(headers) .body(body) .send() .await .map_err(|e| AuthError::PollFailed(format!("HTTP request failed: {e}")))?; let status = response.status(); let body_text = response .text() .await .map_err(|e| AuthError::PollFailed(format!("Failed to read response: {e}")))?; // GitHub-compatible: HTTP 200 can contain either success or error if github_compatible && status.is_success() { let token_response: serde_json::Value = serde_json::from_str(&body_text) .unwrap_or_else(|_| serde_json::json!({"error": "parse_error"})); // Check for error field first if let Some(error) = token_response.get("error").and_then(|v| v.as_str()) { if handle_oauth_error(error).is_ok() { // Retryable error - continue polling continue; } // Terminal error - propagate return Err(handle_oauth_error(error).unwrap_err().into()); } // No error field - parse as success return Ok(parse_token_response(&body_text)?); } // Standard OAuth: HTTP success means tokens if !github_compatible && status.is_success() { return Ok(parse_token_response(&body_text)?); } // Handle error responses (non-200 status for standard OAuth) let error_response: serde_json::Value = serde_json::from_str(&body_text) .unwrap_or_else(|_| serde_json::json!({"error": "unknown_error"})); if let Some(error) = error_response.get("error").and_then(|v| v.as_str()) { if handle_oauth_error(error).is_ok() { // Retryable error - sleep and continue tokio::time::sleep(if error == "slow_down" { interval * 2 } else { interval }) .await; continue; } // Terminal error - propagate return Err(handle_oauth_error(error).unwrap_err().into()); } // Unknown error return Err(AuthError::PollFailed(format!("HTTP {status}: {body_text}")).into()); } } /// Poll for Codex tokens using OpenAI's custom device auth endpoints. /// /// This differs from standard OAuth2 device code flow: /// 1. Polls `/api/accounts/deviceauth/token` with `device_auth_id` + /// `user_code` /// 2. Receives `authorization_code` + `code_verifier` (not tokens directly) /// 3. Exchanges the authorization code for OAuth tokens via standard token /// endpoint async fn codex_poll_for_tokens( request: &DeviceCodeRequest, config: &OAuthConfig, ) -> anyhow::Result { let http_client = build_http_client(config.custom_headers.as_ref()) .map_err(|e| AuthError::PollFailed(format!("Failed to build HTTP client: {e}")))?; let timeout = Duration::from_secs(request.expires_in); let interval = Duration::from_secs(request.interval.max(1)); // Add a safety margin to polling interval to avoid rate limiting let poll_interval = interval + Duration::from_secs(3); let start_time = tokio::time::Instant::now(); // The auth_url in config points to the usercode endpoint; derive the token // polling endpoint from the same base let poll_url = config.auth_url.as_str().replace("/usercode", "/token"); loop { if start_time.elapsed() >= timeout { return Err(AuthError::Timeout(timeout).into()); } // Poll request and response handling would be here tokio::time::sleep(poll_interval).await; let response = http_client .post(&poll_url) .header("Content-Type", "application/json") .json(&serde_json::json!({ "device_auth_id": request.device_code.as_str(), "user_code": request.user_code.as_str(), })) .send() .await .map_err(|e| AuthError::PollFailed(format!("HTTP request failed: {e}")))?; let status = response.status(); if status.is_success() { // Parse the custom response containing authorization_code + code_verifier let device_token: CodexDeviceTokenResponse = response.json().await.map_err(|e| { AuthError::PollFailed(format!("Failed to parse device token response: {e}")) })?; // Exchange the authorization code for OAuth tokens via standard // endpoint. Use a clean HTTP client without custom headers since the // standard OAuth token endpoint rejects unknown headers. let clean_client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| AuthError::PollFailed(format!("Failed to build HTTP client: {e}")))?; let token_response = clean_client .post(config.token_url.as_str()) .header("Content-Type", "application/x-www-form-urlencoded") .body( serde_urlencoded::to_string([ ("grant_type", "authorization_code"), ("code", &device_token.authorization_code), ( "redirect_uri", "https://auth.openai.com/deviceauth/callback", ), ("client_id", config.client_id.as_ref()), ("code_verifier", &device_token.code_verifier), ]) .map_err(|e| { AuthError::PollFailed(format!("Failed to encode token request: {e}")) })?, ) .send() .await .map_err(|e| { AuthError::PollFailed(format!("Token exchange request failed: {e}")) })?; if !token_response.status().is_success() { let token_exchange_status = token_response.status(); let error_text = token_response.text().await.unwrap_or_default(); return Err(AuthError::PollFailed(format!( "Token exchange failed ({}): {}", token_exchange_status, error_text )) .into()); } return Ok(parse_token_response( &token_response.text().await.map_err(|e| { AuthError::PollFailed(format!("Failed to read token response: {e}")) })?, )?); } // 403/404 means authorization pending (user hasn't entered code yet) if status.as_u16() == 403 || status.as_u16() == 404 { continue; } // Any other error is terminal let body_text = response.text().await.unwrap_or_default(); return Err(AuthError::PollFailed(format!("HTTP {status}: {body_text}")).into()); } } /// Exchange OAuth token for API key (GitHub Copilot pattern) async fn exchange_oauth_for_api_key( oauth_token: &str, api_key_exchange_url: &Url, config: &OAuthConfig, ) -> anyhow::Result<(ApiKey, chrono::DateTime)> { // Build request headers let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&format!("Bearer {oauth_token}")).map_err(|e| { AuthError::CompletionFailed(format!("Invalid authorization header: {e}")) })?, ); // Add custom headers from config inject_custom_headers(&mut headers, &config.custom_headers); let response = build_http_client(config.custom_headers.as_ref()) .map_err(|e| AuthError::CompletionFailed(format!("Failed to build HTTP client: {e}")))? .get(api_key_exchange_url.as_str()) .headers(headers) .send() .await .map_err(|e| { AuthError::CompletionFailed(format!("API key exchange request failed: {e}")) })?; let status = response.status(); if !status.is_success() { if status.as_u16() == 403 { return Err(AuthError::CompletionFailed( "Access denied. Ensure you have an active subscription.".to_string(), ) .into()); } return Err(AuthError::CompletionFailed(format!( "API key fetch failed ({}): {}", status, response.text().await.unwrap_or_default() )) .into()); } let OAuthTokenResponse { access_token, expires_at, .. } = response.json().await.map_err(|e| { AuthError::CompletionFailed(format!("Failed to parse API key response: {e}")) })?; Ok(( access_token.into(), chrono::DateTime::from_timestamp(expires_at.unwrap_or(0), 0) .unwrap_or_else(chrono::Utc::now), )) } /// Enum wrapper for all strategy implementations /// Eliminates heap allocation and dynamic dispatch pub enum AnyAuthStrategy { ApiKey(ApiKeyStrategy), OAuthCodeStandard(OAuthCodeStrategy), OAuthCodeAnthropic(OAuthCodeStrategy), OAuthCodeGithub(OAuthCodeStrategy), OAuthDevice(OAuthDeviceStrategy), OAuthWithApiKey(OAuthWithApiKeyStrategy), GoogleAdc(GoogleAdcStrategy), AwsProfile(AwsProfileStrategy), CodexDevice(CodexDeviceStrategy), } #[async_trait::async_trait] impl AuthStrategy for AnyAuthStrategy { async fn init(&self) -> anyhow::Result { match self { Self::ApiKey(s) => s.init().await, Self::OAuthCodeStandard(s) => s.init().await, Self::OAuthCodeAnthropic(s) => s.init().await, Self::OAuthCodeGithub(s) => s.init().await, Self::OAuthDevice(s) => s.init().await, Self::OAuthWithApiKey(s) => s.init().await, Self::GoogleAdc(s) => s.init().await, Self::AwsProfile(s) => s.init().await, Self::CodexDevice(s) => s.init().await, } } async fn complete( &self, context_response: AuthContextResponse, ) -> anyhow::Result { match self { Self::ApiKey(s) => s.complete(context_response).await, Self::OAuthCodeStandard(s) => s.complete(context_response).await, Self::OAuthCodeAnthropic(s) => s.complete(context_response).await, Self::OAuthCodeGithub(s) => s.complete(context_response).await, Self::OAuthDevice(s) => s.complete(context_response).await, Self::OAuthWithApiKey(s) => s.complete(context_response).await, Self::GoogleAdc(s) => s.complete(context_response).await, Self::AwsProfile(s) => s.complete(context_response).await, Self::CodexDevice(s) => s.complete(context_response).await, } } async fn refresh(&self, credential: &AuthCredential) -> anyhow::Result { match self { Self::ApiKey(s) => s.refresh(credential).await, Self::OAuthCodeStandard(s) => s.refresh(credential).await, Self::OAuthCodeAnthropic(s) => s.refresh(credential).await, Self::OAuthCodeGithub(s) => s.refresh(credential).await, Self::OAuthDevice(s) => s.refresh(credential).await, Self::OAuthWithApiKey(s) => s.refresh(credential).await, Self::GoogleAdc(s) => s.refresh(credential).await, Self::AwsProfile(s) => s.refresh(credential).await, Self::CodexDevice(s) => s.refresh(credential).await, } } } /// Factory for creating authentication strategies pub struct ForgeAuthStrategyFactory; impl Default for ForgeAuthStrategyFactory { fn default() -> Self { Self } } impl ForgeAuthStrategyFactory { pub fn new(_environment: forge_domain::Environment) -> Self { Self } } impl StrategyFactory for ForgeAuthStrategyFactory { type Strategy = AnyAuthStrategy; fn create_auth_strategy( &self, provider_id: ProviderId, auth_method: forge_domain::AuthMethod, required_params: Vec, ) -> anyhow::Result { match auth_method { forge_domain::AuthMethod::ApiKey => Ok(AnyAuthStrategy::ApiKey(ApiKeyStrategy::new( provider_id, required_params, ))), forge_domain::AuthMethod::OAuthCode(config) => { if provider_id == ProviderId::CLAUDE_CODE { return Ok(AnyAuthStrategy::OAuthCodeAnthropic(OAuthCodeStrategy::new( AnthropicHttpProvider, provider_id, config, ))); } if provider_id == ProviderId::GITHUB_COPILOT { return Ok(AnyAuthStrategy::OAuthCodeGithub(OAuthCodeStrategy::new( GithubHttpProvider, provider_id, config, ))); } Ok(AnyAuthStrategy::OAuthCodeStandard(OAuthCodeStrategy::new( StandardHttpProvider, provider_id, config, ))) } forge_domain::AuthMethod::OAuthDevice(config) => { // Check if this is OAuth-with-API-Key flow (GitHub Copilot pattern) if config.token_refresh_url.is_some() { Ok(AnyAuthStrategy::OAuthWithApiKey( OAuthWithApiKeyStrategy::new(provider_id, config)?, )) } else { Ok(AnyAuthStrategy::OAuthDevice(OAuthDeviceStrategy::new( provider_id, config, ))) } } forge_domain::AuthMethod::GoogleAdc => Ok(AnyAuthStrategy::GoogleAdc( GoogleAdcStrategy::new(provider_id, required_params), )), forge_domain::AuthMethod::AwsProfile => Ok(AnyAuthStrategy::AwsProfile( AwsProfileStrategy::new(provider_id, required_params), )), forge_domain::AuthMethod::CodexDevice(config) => Ok(AnyAuthStrategy::CodexDevice( CodexDeviceStrategy::new(provider_id, config), )), } } } #[cfg(test)] mod tests { use std::collections::HashMap; use forge_domain::URLParam; use pretty_assertions::assert_eq; use super::*; #[test] fn test_create_auth_strategy_api_key() { let factory = ForgeAuthStrategyFactory; let strategy = factory.create_auth_strategy( ProviderId::OPENAI, forge_domain::AuthMethod::ApiKey, vec![], ); assert!(strategy.is_ok()); } #[test] fn test_create_auth_strategy_oauth_code() { let config = OAuthConfig { client_id: "test".to_string().into(), auth_url: Url::parse("https://example.com/auth").unwrap(), token_url: Url::parse("https://example.com/token").unwrap(), scopes: vec![], redirect_uri: None, use_pkce: false, token_refresh_url: None, extra_auth_params: None, custom_headers: None, }; let factory = ForgeAuthStrategyFactory; let strategy = factory.create_auth_strategy( ProviderId::OPENAI, forge_domain::AuthMethod::OAuthCode(config), vec![], ); assert!(strategy.is_ok()); } #[test] fn test_create_auth_strategy_oauth_device() { let config = OAuthConfig { client_id: "test".to_string().into(), auth_url: Url::parse("https://example.com/auth").unwrap(), token_url: Url::parse("https://example.com/token").unwrap(), scopes: vec![], redirect_uri: None, use_pkce: false, token_refresh_url: None, extra_auth_params: None, custom_headers: None, }; let factory = ForgeAuthStrategyFactory; let strategy = factory.create_auth_strategy( ProviderId::OPENAI, forge_domain::AuthMethod::OAuthDevice(config), vec![], ); assert!(strategy.is_ok()); } #[test] fn test_create_auth_strategy_oauth_with_api_key() { let config = OAuthConfig { client_id: "test".to_string().into(), auth_url: Url::parse("https://example.com/auth").unwrap(), token_url: Url::parse("https://example.com/token").unwrap(), scopes: vec![], redirect_uri: None, use_pkce: false, token_refresh_url: Some(Url::parse("https://example.com/api_key").unwrap()), extra_auth_params: None, custom_headers: None, }; let factory = ForgeAuthStrategyFactory; let strategy = factory.create_auth_strategy( ProviderId::GITHUB_COPILOT, forge_domain::AuthMethod::OAuthDevice(config), vec![], ); assert!(strategy.is_ok()); } #[test] fn test_create_auth_strategy_codex_device() { let config = OAuthConfig { client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string().into(), auth_url: Url::parse("https://auth.openai.com/api/accounts/deviceauth/usercode") .unwrap(), token_url: Url::parse("https://auth.openai.com/oauth/token").unwrap(), scopes: vec![], redirect_uri: None, use_pkce: false, token_refresh_url: None, extra_auth_params: None, custom_headers: None, }; let factory = ForgeAuthStrategyFactory; let actual = factory.create_auth_strategy( ProviderId::CODEX, forge_domain::AuthMethod::CodexDevice(config), vec![], ); assert!(actual.is_ok()); assert!(matches!(actual.unwrap(), AnyAuthStrategy::CodexDevice(_))); } /// Helper to build a JWT token with the given claims payload. fn build_jwt(claims: &serde_json::Value) -> String { use base64::Engine; let header = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(r#"{"alg":"RS256","typ":"JWT"}"#); let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(serde_json::to_vec(claims).unwrap()); format!("{header}.{payload}.fake_signature") } #[test] fn test_extract_chatgpt_account_id_from_direct_claim() { let fixture = build_jwt(&serde_json::json!({ "chatgpt_account_id": "acct_123" })); let actual = extract_chatgpt_account_id(&fixture); let expected = Some("acct_123".to_string()); assert_eq!(actual, expected); } #[test] fn test_extract_chatgpt_account_id_from_nested_claim() { let fixture = build_jwt(&serde_json::json!({ "https://api.openai.com/auth": { "chatgpt_account_id": "acct_nested_456" } })); let actual = extract_chatgpt_account_id(&fixture); let expected = Some("acct_nested_456".to_string()); assert_eq!(actual, expected); } #[test] fn test_extract_chatgpt_account_id_from_organizations() { let fixture = build_jwt(&serde_json::json!({ "organizations": [ {"id": "org_789", "name": "My Org"} ] })); let actual = extract_chatgpt_account_id(&fixture); let expected = Some("org_789".to_string()); assert_eq!(actual, expected); } #[test] fn test_extract_chatgpt_account_id_prefers_direct_claim() { let fixture = build_jwt(&serde_json::json!({ "chatgpt_account_id": "direct", "https://api.openai.com/auth": { "chatgpt_account_id": "nested" }, "organizations": [{"id": "org"}] })); let actual = extract_chatgpt_account_id(&fixture); let expected = Some("direct".to_string()); assert_eq!(actual, expected); } #[test] fn test_extract_chatgpt_account_id_returns_none_for_empty_claims() { let fixture = build_jwt(&serde_json::json!({})); let actual = extract_chatgpt_account_id(&fixture); assert_eq!(actual, None); } #[test] fn test_extract_chatgpt_account_id_returns_none_for_invalid_jwt() { let actual = extract_chatgpt_account_id("not-a-jwt"); assert_eq!(actual, None); } #[test] fn test_extract_chatgpt_account_id_returns_none_for_invalid_base64() { let actual = extract_chatgpt_account_id("header.!!!invalid-base64!!!.signature"); assert_eq!(actual, None); } #[test] fn test_extract_chatgpt_account_id_returns_none_for_empty_organizations() { let fixture = build_jwt(&serde_json::json!({ "organizations": [] })); let actual = extract_chatgpt_account_id(&fixture); assert_eq!(actual, None); } #[test] fn test_enrich_codex_oauth_credential_uses_id_token_claims() { let fixture_id_token = build_jwt(&serde_json::json!({ "chatgpt_account_id": "acct_from_id_token" })); let fixture_access_token = "not-a-jwt"; let mut actual = AuthCredential::new_oauth( ProviderId::CODEX, OAuthTokens::new( fixture_access_token, None::, chrono::Utc::now() + chrono::Duration::hours(1), ), OAuthConfig { client_id: "test".to_string().into(), auth_url: Url::parse("https://example.com/auth").unwrap(), token_url: Url::parse("https://example.com/token").unwrap(), scopes: vec![], redirect_uri: Some("http://localhost:1455/auth/callback".to_string()), use_pkce: true, token_refresh_url: None, extra_auth_params: None, custom_headers: None, }, ); enrich_codex_oauth_credential( &ProviderId::CODEX, &mut actual, Some(&fixture_id_token), fixture_access_token, ); let actual = actual .url_params .get(&URLParam::from("chatgpt_account_id".to_string())); let expected = Some(&forge_domain::URLParamValue::from( "acct_from_id_token".to_string(), )); assert_eq!(actual, expected); } #[tokio::test] async fn test_refresh_oauth_credential_preserves_url_params() { let fixture_config = OAuthConfig { client_id: "test".to_string().into(), auth_url: Url::parse("https://example.com/auth").unwrap(), token_url: Url::parse("https://example.com/token").unwrap(), scopes: vec![], redirect_uri: None, use_pkce: false, token_refresh_url: None, extra_auth_params: None, custom_headers: None, }; let fixture_tokens = OAuthTokens::new( "access_token", None::, chrono::Utc::now() + chrono::Duration::minutes(30), ); let fixture_url_params = HashMap::from([( URLParam::from("chatgpt_account_id".to_string()), "acct_123".to_string().into(), )]); let fixture_credential = AuthCredential::new_oauth(ProviderId::CODEX, fixture_tokens, fixture_config.clone()) .url_params(fixture_url_params.clone()); let actual = refresh_oauth_credential( &fixture_credential, &fixture_config, chrono::Duration::hours(1), false, ) .await .unwrap(); let expected = fixture_url_params; assert_eq!(actual.url_params, expected); } }