use std::collections::HashMap; use std::sync::Arc; use fabro_model::catalog::CatalogProvider; use fabro_model::{ApiKeyHeaderPolicy, Catalog, CredentialRef, ProviderId}; use fabro_static::EnvVars; use fabro_types::settings::{InterpString, ResolveCtx, ResolveError as InterpResolveError}; use fabro_vault::{SecretType, Vault}; use tokio::sync::RwLock as AsyncRwLock; use tokio::task::spawn_blocking; use crate::credential::{ApiKeyHeader, OAuthCredential}; use crate::credential_source::CredentialSource; use crate::env_source::EnvCredentialSource; use crate::refresh::refresh_oauth_credential; use crate::vault_ext::{ VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_token_lookup, }; pub type EnvLookup = Arc Option + Send + Sync>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CredentialUsage { ApiRequest, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResolvedSecret { ApiKey(String), OAuth { credential: Box, vault_name: String, }, /// Opaque AWS SigV4 source: no static secret; the adapter signs requests /// using the AWS default credential chain. AwsSigv4, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiCredential { pub provider: ProviderId, pub auth_header: Option, pub extra_headers: HashMap, pub base_url: Option, pub codex_mode: bool, pub org_id: Option, pub project_id: Option, } impl ApiCredential { /// Build an `ApiCredential` from an API key using the supplied catalog for /// auth header policy and provider base URL. pub fn from_api_key( provider: impl Into, key: String, catalog: &Catalog, ) -> Result { let provider_id = provider.into(); let provider = catalog .provider(&provider_id) .ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?; let auth_header = auth_header_for_catalog_provider(provider, key)?; Ok(Self { provider: provider_id, auth_header: Some(auth_header), extra_headers: HashMap::new(), base_url: provider.base_url.clone(), codex_mode: false, org_id: None, project_id: None, }) } } const OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id"; const ORIGINATOR_HEADER: &str = "originator"; const FABRO_ORIGINATOR: &str = "fabro"; pub(crate) fn apply_openai_api_env_context( credential: &mut ApiCredential, env_lookup: &(dyn Fn(&str) -> Option + Send + Sync), ) { credential.org_id = env_lookup(EnvVars::OPENAI_ORG_ID); credential.project_id = env_lookup(EnvVars::OPENAI_PROJECT_ID); } pub(crate) fn apply_openai_codex_api_context( credential: &mut ApiCredential, account_id: Option<&str>, env_lookup: &(dyn Fn(&str) -> Option + Send + Sync), ) { apply_openai_api_env_context(credential, env_lookup); if let Some(account_id) = account_id { credential.extra_headers.insert( CHATGPT_ACCOUNT_ID_HEADER.to_string(), account_id.to_string(), ); } credential .extra_headers .insert(ORIGINATOR_HEADER.to_string(), FABRO_ORIGINATOR.to_string()); credential.base_url = Some(OPENAI_CODEX_BASE_URL.to_string()); credential.codex_mode = true; } #[must_use] pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHeader { match policy { ApiKeyHeaderPolicy::Bearer => ApiKeyHeader::Bearer(key), ApiKeyHeaderPolicy::Custom { name } => ApiKeyHeader::Custom { name, value: key }, } } fn auth_header_for_catalog_provider( provider: &CatalogProvider, key: String, ) -> Result { let Some(auth) = &provider.auth else { return Err(ResolveError::NotConfigured(provider.id.clone())); }; Ok(build_api_key_header(auth.header.clone(), key)) } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedCredential { Api(ApiCredential), } #[derive(Debug, thiserror::Error)] pub enum ResolveError { #[error("{0} is not configured")] NotConfigured(ProviderId), #[error("{provider} header interpolation failed: {source}")] Interpolation { provider: ProviderId, #[source] source: InterpResolveError, }, #[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")] VaultSchemaMismatch { provider: ProviderId, name: String, actual: SecretType, }, #[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")] VaultDecodeFailed { provider: ProviderId, name: String, #[source] source: serde_json::Error, }, #[error("{provider} requires re-authentication: {source}")] RefreshFailed { provider: ProviderId, #[source] source: anyhow::Error, }, #[error("{0} requires re-authentication: missing refresh token")] RefreshTokenMissing(ProviderId), } #[must_use] pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String { let provider_name = provider.display_name(); match err { ResolveError::NotConfigured(_) => { format!("{provider_name} is not configured") } ResolveError::Interpolation { source, .. } => { format!("{provider_name} header interpolation failed: {source}") } ResolveError::VaultSchemaMismatch { name, actual, .. } => { format!( "{provider_name} vault credential '{name}' has schema {actual:?}, expected Token or Oauth" ) } ResolveError::VaultDecodeFailed { name, source, .. } => { format!("{provider_name} vault credential '{name}' is not valid OAuth JSON: {source}") } ResolveError::RefreshFailed { source, .. } => { format!("{provider_name} requires re-authentication: {source}") } ResolveError::RefreshTokenMissing(_) => { format!("{provider_name} requires re-authentication: refresh token missing") } } } #[derive(Clone)] pub struct CredentialResolver { vault: Arc>, env_lookup: EnvLookup, } impl CredentialResolver { #[must_use] #[expect( clippy::disallowed_methods, reason = "CredentialResolver owns the process-env fallback used after vault lookup." )] pub fn new(vault: Arc>) -> Self { Self::with_env_lookup(vault, Arc::new(|name| std::env::var(name).ok())) } #[must_use] pub fn with_env_lookup(vault: Arc>, env_lookup: EnvLookup) -> Self { Self { vault, env_lookup } } pub async fn resolve( &self, provider: impl Into, _usage: CredentialUsage, catalog: &Catalog, ) -> Result { let provider_id = provider.into(); let Some(catalog_provider) = catalog.provider(&provider_id) else { return Err(ResolveError::NotConfigured(provider_id)); }; if catalog_provider.auth.is_none() { let vault = self.vault.read().await; return self .api_credential_from_provider_auth(&vault, catalog_provider, catalog) .map(ResolvedCredential::Api); } let initial_secret = { let vault = self.vault.read().await; self.find_credential(&vault, catalog_provider)? }; let secret = if let ResolvedSecret::OAuth { credential, vault_name, } = &initial_secret { if !credential.needs_refresh() { initial_secret } else if credential.tokens.refresh_token.is_none() { return Err(ResolveError::RefreshTokenMissing(provider_id.clone())); } else { let refreshed = refresh_oauth_credential(credential) .await .map_err(|source| ResolveError::RefreshFailed { provider: provider_id.clone(), source, })?; let refreshed_for_store = refreshed.clone(); let vault_name_for_store = vault_name.clone(); let vault = Arc::clone(&self.vault); spawn_blocking(move || { let mut vault = vault.blocking_write(); vault_set_oauth(&mut vault, &vault_name_for_store, &refreshed_for_store) .map(|_| ()) .map_err(anyhow::Error::from) }) .await .map_err(|join_err| ResolveError::RefreshFailed { provider: provider_id.clone(), source: anyhow::Error::from(join_err), })? .map_err(|source| ResolveError::RefreshFailed { provider: provider_id.clone(), source, })?; ResolvedSecret::OAuth { credential: Box::new(refreshed), vault_name: vault_name.clone(), } } } else { initial_secret }; let vault = self.vault.read().await; self.to_api_credential(&vault, &provider_id, &secret, catalog) .map(ResolvedCredential::Api) } #[must_use] pub fn configured_providers(&self, vault: &Vault, catalog: &Catalog) -> Vec { catalog .providers() .iter() .filter(|provider| self.has_credential_material(vault, provider, catalog)) .map(|provider| provider.id.clone()) .collect() } fn find_credential( &self, vault: &Vault, provider: &CatalogProvider, ) -> Result { let Some(auth) = &provider.auth else { return Err(ResolveError::NotConfigured(provider.id.clone())); }; for credential_ref in &auth.credentials { if let Some(credential) = self.credential_from_ref(vault, &provider.id, credential_ref)? { return Ok(credential); } } Err(ResolveError::NotConfigured(provider.id.clone())) } fn has_credential_material( &self, vault: &Vault, provider: &CatalogProvider, catalog: &Catalog, ) -> bool { let Some(auth) = &provider.auth else { return self .resolved_extra_headers_for_catalog(vault, &provider.id, catalog) .is_ok(); }; auth.credentials.iter().any(|credential_ref| { self.credential_from_ref(vault, &provider.id, credential_ref) .is_ok_and(|credential| credential.is_some()) }) } fn credential_from_ref( &self, vault: &Vault, provider: &ProviderId, credential_ref: &CredentialRef, ) -> Result, ResolveError> { match credential_ref { CredentialRef::Vault(name) => match vault_get_token(vault, name) { Ok(Some(token)) => Ok(Some(ResolvedSecret::ApiKey(token))), Ok(None) => Ok(None), Err(VaultLookupError::SchemaMismatch { actual: SecretType::Oauth, .. }) => vault_get_oauth(vault, name) .map(|credential| { credential.map(|credential| ResolvedSecret::OAuth { credential: Box::new(credential), vault_name: name.clone(), }) }) .map_err(|err| vault_lookup_error(provider, name, err)), Err(err) => Err(vault_lookup_error(provider, name, err)), }, CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)), // AWS SigV4 is an opaque source: it always "resolves" (the adapter // signs at request time from the AWS chain), no vault/env lookup. CredentialRef::AwsSigv4 => Ok(Some(ResolvedSecret::AwsSigv4)), } } fn lookup_env(&self, name: &str) -> Option { (self.env_lookup)(name) } fn provider_base_url_for_catalog(provider: &ProviderId, catalog: &Catalog) -> Option { catalog .provider(provider) .and_then(|provider| provider.base_url.clone()) } fn resolved_extra_headers_for_catalog( &self, vault: &Vault, provider: &ProviderId, catalog: &Catalog, ) -> Result, ResolveError> { let Some(catalog_provider) = catalog.provider(provider) else { return Ok(HashMap::new()); }; let mut ctx = ResolveCtx::new() .with_env(|env_name| self.lookup_env(env_name)) .with_secrets(|secret_name| vault_token_lookup(vault, secret_name)); resolve_extra_headers(provider, &catalog_provider.extra_headers, &mut ctx) } fn to_api_credential( &self, vault: &Vault, provider_id: &ProviderId, secret: &ResolvedSecret, catalog: &Catalog, ) -> Result { let base_url = Self::provider_base_url_for_catalog(provider_id, catalog); match secret { // Opaque AWS SigV4 source: carry the marker so the adapter signs // with the AWS chain; no static secret resolved here. ResolvedSecret::AwsSigv4 => Ok(ApiCredential { provider: provider_id.clone(), auth_header: Some(ApiKeyHeader::AwsSigv4), extra_headers: self.resolved_extra_headers_for_catalog( vault, provider_id, catalog, )?, base_url, codex_mode: false, org_id: None, project_id: None, }), ResolvedSecret::ApiKey(key) => { let provider = catalog .provider(provider_id) .ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?; let auth_header = auth_header_for_catalog_provider(provider, key.clone())?; let mut cred = ApiCredential { provider: provider_id.clone(), auth_header: Some(auth_header), extra_headers: self.resolved_extra_headers_for_catalog( vault, provider_id, catalog, )?, base_url, codex_mode: false, org_id: None, project_id: None, }; if provider_id == &ProviderId::openai() { apply_openai_api_env_context(&mut cred, &*self.env_lookup); } Ok(cred) } ResolvedSecret::OAuth { credential, .. } => { let mut api_credential = ApiCredential { provider: provider_id.clone(), auth_header: Some(ApiKeyHeader::Bearer(credential.tokens.access_token.clone())), extra_headers: self.resolved_extra_headers_for_catalog( vault, provider_id, catalog, )?, base_url, codex_mode: false, org_id: None, project_id: None, }; if provider_id == &ProviderId::openai() { apply_openai_codex_api_context( &mut api_credential, credential.account_id.as_deref(), &*self.env_lookup, ); } Ok(api_credential) } } } fn api_credential_from_provider_auth( &self, vault: &Vault, provider: &CatalogProvider, catalog: &Catalog, ) -> Result { if provider.auth.is_some() { return Err(ResolveError::NotConfigured(provider.id.clone())); } let extra_headers = self.resolved_extra_headers_for_catalog(vault, &provider.id, catalog)?; Ok(ApiCredential { provider: provider.id.clone(), auth_header: None, extra_headers, base_url: Self::provider_base_url_for_catalog(&provider.id, catalog), codex_mode: false, org_id: None, project_id: None, }) } } /// Resolve a provider's `extra_headers` interpolation sources with `ctx`. /// /// Resolved header values may contain secrets; keep this path free of value /// logging. Content-based redaction covers credential-shaped values on output /// surfaces, but no mechanism redacts these exact values, so a low-entropy /// header value that does not look like a credential is not caught. pub(crate) fn resolve_extra_headers( provider: &ProviderId, headers: &HashMap, ctx: &mut ResolveCtx<'_>, ) -> Result, ResolveError> { headers .iter() .map(|(name, source)| { let value = InterpString::parse(source) .resolve_with(ctx) .map_err(|source| ResolveError::Interpolation { provider: provider.clone(), source, })?; Ok((name.clone(), value)) }) .collect() } fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError { match err { VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch { provider: provider.clone(), name: name.to_string(), actual, }, VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed { provider: provider.clone(), name: name.to_string(), source, }, } } pub async fn configured_providers_from_process_env( vault: Option<&Arc>>, catalog: &Catalog, ) -> Vec { match vault { Some(vault_arc) => { let resolver = CredentialResolver::new(Arc::clone(vault_arc)); let guard = vault_arc.read().await; resolver.configured_providers(&guard, catalog) } None => { EnvCredentialSource::new() .configured_providers(catalog) .await } } } #[cfg(test)] mod tests { use std::error::Error as _; use chrono::{Duration, Utc}; use fabro_model::catalog::LlmCatalogSettings; use httpmock::Method::POST; use httpmock::MockServer; use super::*; use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens}; use crate::vault_ext::{vault_get_oauth, vault_set_oauth, vault_set_token}; fn oauth_credential(token_url: String, expires_at: chrono::DateTime) -> OAuthCredential { OAuthCredential { tokens: OAuthTokens { access_token: "expired-access".to_string(), refresh_token: Some("refresh-token".to_string()), expires_at, }, config: OAuthConfig { auth_url: "https://auth.openai.com".to_string(), token_url, client_id: "test-client".to_string(), scopes: vec!["openid".to_string()], redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()), use_pkce: true, }, account_id: Some("acct_123".to_string()), } } fn test_resolver(vault: Vault, env_lookup: EnvLookup) -> CredentialResolver { CredentialResolver::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env_lookup) } fn catalog_with(overrides: &str) -> Catalog { let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap(); Catalog::from_builtin_with_overrides(&settings).unwrap() } fn default_catalog() -> Catalog { catalog_with("") } /// A no-auth portkey provider whose only variation is its `extra_headers` /// TOML lines. fn portkey_catalog(extra_headers: &str) -> Catalog { catalog_with(&format!( r#" [providers.portkey] display_name = "Portkey Bedrock" adapter = "anthropic" agent_profile = "anthropic" base_url = "https://api.portkey.ai/v1" [providers.portkey.extra_headers] {extra_headers} [models."portkey-claude"] provider = "portkey" display_name = "Portkey Claude" family = "claude" default = true [models."portkey-claude".limits] context_window = 200000 [models."portkey-claude".features] tools = true vision = true reasoning = true reasoning_effort = "levels" "# )) } #[tokio::test] async fn resolve_openai_api_request_prefers_env_when_listed_first() { let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); let resolver = test_resolver( vault, Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())), ); let catalog = default_catalog(); let resolved = resolver .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("env-key".to_string())) ); } #[tokio::test] async fn resolve_openai_api_request_falls_back_to_codex_oauth_credential() { let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_oauth( &mut vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME, &oauth_credential( "https://auth.openai.com/oauth/token".to_string(), Utc::now() + Duration::hours(1), ), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = default_catalog(); let resolved = resolver .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("expired-access".to_string())) ); assert!(api.codex_mode); assert_eq!( api.base_url.as_deref(), Some("https://chatgpt.com/backend-api/codex") ); } #[tokio::test] async fn sigv4_provider_resolves_to_aws_sigv4_credential() { let dir = tempfile::tempdir().unwrap(); let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); // No env credentials configured: SigV4 must still resolve. let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = catalog_with( r#" [providers.bedrock] adapter = "bedrock" enabled = true base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com" [providers.bedrock.auth] credentials = ["aws_sigv4"] "#, ); let resolved = resolver .resolve( ProviderId::from("bedrock"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!(api.provider, ProviderId::from("bedrock")); assert_eq!(api.auth_header, Some(ApiKeyHeader::AwsSigv4)); } #[tokio::test] async fn resolve_returns_not_configured_for_missing_provider() { let dir = tempfile::tempdir().unwrap(); let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = default_catalog(); let err = resolver .resolve( ProviderId::anthropic(), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap_err(); assert!(matches!( err, ResolveError::NotConfigured(provider) if provider == ProviderId::anthropic() )); } #[tokio::test] async fn anthropic_api_credentials_use_x_api_key_header() { let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = default_catalog(); let resolved = resolver .resolve( ProviderId::anthropic(), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "anthropic-key".to_string(), }) ); } #[tokio::test] async fn custom_openai_compatible_resolves_with_catalog_base_url_from_vault() { let catalog = catalog_with( r#" [providers.acme] display_name = "Acme" adapter = "openai_compatible" agent_profile = "openai" base_url = "https://default.example.com/v1" [providers.acme.auth] credentials = ["vault:acme"] [models."compat-model"] provider = "acme" display_name = "Compat Model" family = "openai" default = true [models."compat-model".limits] context_window = 128000 [models."compat-model".features] tools = true vision = false reasoning = false "#, ); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "acme", "compat-key").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let resolved = resolver .resolve( ProviderId::new("acme"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("compat-key".to_string())) ); assert_eq!( api.base_url.as_deref(), Some("https://default.example.com/v1") ); } #[tokio::test] async fn with_env_lookup_overrides_vault_settings() { let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); vault .set( "OPENAI_ORG_ID", "vault-org", fabro_vault::SecretType::Token, None, ) .unwrap(); let resolver = test_resolver( vault, Arc::new(|name| match name { "OPENAI_API_KEY" => Some("env-key".to_string()), "OPENAI_ORG_ID" => Some("env-org".to_string()), _ => None, }), ); let catalog = default_catalog(); let resolved = resolver .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!(api.org_id.as_deref(), Some("env-org")); } #[tokio::test] async fn configured_providers_returns_vault_backed_provider() { let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let vault = resolver.vault.read().await; let catalog = default_catalog(); assert_eq!(resolver.configured_providers(&vault, &catalog), vec![ ProviderId::openai() ]); } #[tokio::test] async fn resolve_uses_custom_vault_backed_provider() { let catalog = catalog_with( r#" [providers.acme] display_name = "Acme" adapter = "openai_compatible" agent_profile = "openai" base_url = "https://api.acme.test/v1" [providers.acme.auth] credentials = ["vault:acme"] [models."acme-large"] provider = "acme" display_name = "Acme Large" family = "acme" default = true [models."acme-large".limits] context_window = 128000 [models."acme-large".features] tools = true vision = false reasoning = false "#, ); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "acme", "acme-key").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let resolved = resolver .resolve( ProviderId::new("acme"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!(api.provider, ProviderId::new("acme")); assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("acme-key".to_string())) ); assert_eq!(api.base_url.as_deref(), Some("https://api.acme.test/v1")); } #[tokio::test] async fn configured_providers_returns_env_backed_provider() { let dir = tempfile::tempdir().unwrap(); let vault = Vault::load(dir.path().join("secrets.json")).unwrap(); let resolver = test_resolver( vault, Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())), ); let vault = resolver.vault.read().await; let catalog = default_catalog(); assert_eq!(resolver.configured_providers(&vault, &catalog), vec![ ProviderId::openai() ]); } #[tokio::test] async fn vault_source_resolves_secret_header_token() { let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.gateway_team_secret }}""#); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "gateway_team_secret", "s3cr3t").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let resolved = resolver .resolve( ProviderId::new("portkey"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.extra_headers.get("x-team-secret"), Some(&"s3cr3t".to_string()) ); } #[tokio::test] async fn resolve_multi_segment_header_token() { let catalog = portkey_catalog(r#"authorization = "Bearer {{ secrets.TOKEN }}""#); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "TOKEN", "gateway-token").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let resolved = resolver .resolve( ProviderId::new("portkey"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.extra_headers.get("authorization"), Some(&"Bearer gateway-token".to_string()) ); } #[tokio::test] async fn missing_secret_header_fails_without_echoing_value() { let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.MISSING }}""#); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_token(&mut vault, "OTHER_SECRET", "should-not-leak").unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let err = resolver .resolve( ProviderId::new("portkey"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap_err(); assert!(matches!( err, ResolveError::Interpolation { ref provider, .. } if provider == &ProviderId::new("portkey") )); let source = err .source() .expect("interpolation errors should preserve the source error"); assert!(source.to_string().contains("MISSING")); let message = err.to_string(); assert!(message.contains("MISSING")); assert!(!message.contains("should-not-leak")); } #[tokio::test] async fn header_with_file_or_oauth_vault_entry_fails_closed() { let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.gateway_team_secret }}""#); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_oauth( &mut vault, "gateway_team_secret", &oauth_credential( "https://auth.openai.com/oauth/token".to_string(), Utc::now() + Duration::hours(1), ), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let err = resolver .resolve( ProviderId::new("portkey"), CredentialUsage::ApiRequest, &catalog, ) .await .unwrap_err(); assert!(matches!( err, ResolveError::Interpolation { ref provider, .. } if provider == &ProviderId::new("portkey") )); let message = err.to_string(); assert!(message.contains("gateway_team_secret")); assert!(!message.contains("expired-access")); assert!(!message.contains("refresh-token")); } #[tokio::test] async fn resolve_refreshes_expired_oauth_credentials_and_persists_them() { let server = MockServer::start_async().await; let refresh_mock = server .mock_async(|when, then| { when.method(POST) .path("/oauth/token") .header("content-type", "application/x-www-form-urlencoded") .form_urlencoded_tuple("grant_type", "refresh_token") .form_urlencoded_tuple("client_id", "test-client") .form_urlencoded_tuple("refresh_token", "refresh-token"); then.status(200) .header("content-type", "application/json") .body( serde_json::json!({ "access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600 }) .to_string(), ); }) .await; let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_oauth( &mut vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME, &oauth_credential( server.url("/oauth/token"), Utc::now() - Duration::minutes(1), ), ) .unwrap(); let vault = Arc::new(AsyncRwLock::new(vault)); let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None)); let catalog = default_catalog(); let resolved = resolver .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap(); let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("new-access".to_string())) ); assert!(api.codex_mode); let stored = { let vault = vault.read().await; vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME) .unwrap() .unwrap() }; assert_eq!(stored.tokens.access_token, "new-access"); assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh")); assert_eq!(stored.account_id.as_deref(), Some("acct_123")); refresh_mock.assert_async().await; } #[tokio::test] async fn resolve_returns_refresh_token_missing_when_expired_oauth_has_no_refresh_token() { let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); let mut credential = oauth_credential( "https://auth.openai.com/oauth/token".to_string(), Utc::now() - Duration::minutes(1), ); credential.tokens.refresh_token = None; vault_set_oauth( &mut vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME, &credential, ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = default_catalog(); let err = resolver .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap_err(); assert!(matches!( err, ResolveError::RefreshTokenMissing(provider) if provider == ProviderId::openai() )); } #[test] fn auth_issue_message_formats_refresh_token_missing() { let message = auth_issue_message( &ProviderId::openai(), &ResolveError::RefreshTokenMissing(ProviderId::openai()), ); assert_eq!( message, "openai requires re-authentication: refresh token missing" ); } #[test] fn api_credential_debug_redacts_secret_material() { let credential = ApiCredential { provider: ProviderId::openai(), auth_header: Some(ApiKeyHeader::Bearer("sk-test".to_string())), extra_headers: HashMap::new(), base_url: None, codex_mode: false, org_id: None, project_id: None, }; let debug = format!("{credential:?}"); assert!(!debug.contains("sk-test")); assert!(debug.contains("REDACTED")); } }