diff --git a/Cargo.lock b/Cargo.lock index 963b95f3..f2d2549e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,6 +832,7 @@ dependencies = [ "http-body-util", "hyper", "image", + "jsonwebtoken", "lopdf", "migration", "opentelemetry 0.32.0", @@ -1102,6 +1103,7 @@ name = "codex-services" version = "1.38.1" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "chrono", "codex-config", @@ -1115,6 +1117,7 @@ dependencies = [ "futures", "handlebars", "image", + "jsonwebtoken", "jxl-oxide", "lettre", "lru", diff --git a/Cargo.toml b/Cargo.toml index e131f1d6..8a762f17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,6 +163,9 @@ zip = "8.1" migration = { path = "migration" } serial_test = { workspace = true } tracing-test = "0.2" +# Signs RS256 fixture tokens for the IdP bearer API tests; same version and +# backend as codex-utils/codex-services. +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } # Enables codex_db::test_helpers (gated behind the `test-utils` feature) so # the root crate's integration suite under `tests/` can mint SQLite test # databases. diff --git a/config/config.docker.yaml b/config/config.docker.yaml index c7d07473..e3fdcdc9 100644 --- a/config/config.docker.yaml +++ b/config/config.docker.yaml @@ -72,6 +72,12 @@ auth: # # groups_claim: groups # # username_claim: preferred_username # # email_claim: email + # # Audiences accepted on API bearer tokens from this provider. + # # Defaults to [client_id]. Add the client IDs of trusted apps that + # # forward their users' IdP tokens to the Codex API. + # # accepted_audiences: + # # - codex-client-id + # # - other-trusted-client-id # # Group-to-role mapping (optional) # # role_mapping: # # admin: diff --git a/config/config.kubernetes.yaml b/config/config.kubernetes.yaml index 8323c3a7..b9c0564b 100644 --- a/config/config.kubernetes.yaml +++ b/config/config.kubernetes.yaml @@ -135,6 +135,12 @@ files: # scopes: # - profile # - email +# # Audiences accepted on API bearer tokens from this provider. +# # Defaults to [client_id]. Add the client IDs of trusted apps that +# # forward their users' IdP tokens to the Codex API. +# # accepted_audiences: +# # - codex-client-id +# # - other-trusted-client-id # # role_mapping: # # admin: # # - codex-admins diff --git a/config/config.sqlite.yaml b/config/config.sqlite.yaml index 5d7eff3c..bae7b731 100644 --- a/config/config.sqlite.yaml +++ b/config/config.sqlite.yaml @@ -70,6 +70,12 @@ auth: # # groups_claim: groups # # username_claim: preferred_username # # email_claim: email + # # Audiences accepted on API bearer tokens from this provider. + # # Defaults to [client_id]. Add the client IDs of trusted apps that + # # forward their users' IdP tokens to the Codex API. + # # accepted_audiences: + # # - codex-client-id + # # - other-trusted-client-id # # Group-to-role mapping (optional) # # role_mapping: # # admin: diff --git a/crates/codex-api/src/extractors/auth.rs b/crates/codex-api/src/extractors/auth.rs index fdfb239c..035671f4 100644 --- a/crates/codex-api/src/extractors/auth.rs +++ b/crates/codex-api/src/extractors/auth.rs @@ -92,6 +92,75 @@ impl UserAuthCache { } } +/// Cache TTL for resolved IdP `(provider, subject) -> user_id` links. +/// Longer than the user cache: the link itself only changes when a user +/// (un)links a provider, which is rare; user data freshness is still +/// governed by `USER_CACHE_TTL_SECS` downstream. +const OIDC_SUBJECT_CACHE_TTL_SECS: i64 = 300; + +struct CachedSubject { + user_id: Uuid, + cached_at: DateTime, +} + +/// TTL'd map from a validated IdP identity to the linked local user, so +/// hot paths (thumbnail bursts) don't hit `oidc_connections` per request. +#[derive(Default)] +pub struct OidcSubjectCache { + cache: DashMap<(String, String), CachedSubject>, +} + +impl OidcSubjectCache { + fn get(&self, provider_name: &str, subject: &str) -> Option { + let key = (provider_name.to_string(), subject.to_string()); + if let Some(entry) = self.cache.get(&key) { + let age = Utc::now().signed_duration_since(entry.cached_at); + if age.num_seconds() < OIDC_SUBJECT_CACHE_TTL_SECS { + return Some(entry.user_id); + } + drop(entry); + self.cache.remove(&key); + } + None + } + + fn insert(&self, provider_name: &str, subject: &str, user_id: Uuid) { + self.cache.insert( + (provider_name.to_string(), subject.to_string()), + CachedSubject { + user_id, + cached_at: Utc::now(), + }, + ); + } + + /// Drop every cached link for a user (e.g. after disconnecting a + /// provider). Mirrors `UserAuthCache::invalidate`; like it, nothing + /// calls this yet — expiry is TTL-driven. + #[allow(dead_code)] + pub fn invalidate_user(&self, user_id: &Uuid) { + self.cache.retain(|_, entry| entry.user_id != *user_id); + } +} + +/// IdP bearer authentication state: the token validator plus the +/// subject-resolution cache. Present on [`AppState`] only when OIDC is +/// enabled with at least one provider; absent means the bearer path is +/// byte-for-byte the local-JWT behavior. +pub struct IdpBearerAuth { + validator: codex_services::IdpBearerValidator, + subject_cache: OidcSubjectCache, +} + +impl IdpBearerAuth { + pub fn new(config: &codex_config::OidcConfig) -> Self { + Self { + validator: codex_services::IdpBearerValidator::new(config), + subject_cache: OidcSubjectCache::default(), + } + } +} + /// Authentication context extracted from JWT or API key #[derive(Debug, Clone)] pub struct AuthContext { @@ -115,6 +184,11 @@ pub enum AuthMethod { Jwt, ApiKey, BasicAuth, + /// Bearer token issued by a configured OIDC identity provider and + /// resolved to a local user via their `oidc_connections` link. + /// Semantically closest to `Jwt`: a full-user credential, no token + /// permission constraints. + OidcBearer, } impl AuthContext { @@ -230,6 +304,11 @@ pub struct AppState { /// OIDC authentication service for external identity provider authentication /// None when OIDC is disabled in config pub oidc_service: Option>, + /// IdP bearer token validation (resource-server path): accepts provider- + /// issued RS256/ES256 access tokens on the API. None when OIDC is + /// disabled or no providers are configured; the bearer path then keeps + /// today's local-JWT-only behavior. + pub idp_bearer: Option>, /// OAuth state manager for user plugin OAuth flows pub oauth_state_manager: Arc, /// Plugin file storage service for managing plugin data directories @@ -300,8 +379,21 @@ impl FromRequestParts> for AuthContext { } } -/// Extract auth context from JWT token +/// Extract auth context from a bearer token: either a Codex session JWT +/// (HS256, local secret) or an IdP-issued access token (RS256/ES256, +/// validated against the provider's JWKS). +/// +/// Routing is by the JWT header's algorithm, not try-and-fallback: HS256 +/// keeps the existing local path verbatim, and only RS256/ES256 tokens +/// with a configured validator divert. Tokens whose header doesn't decode +/// also follow the existing path, preserving today's error messages. async fn extract_from_jwt(token: &str, state: &AppState) -> Result { + if let Some(idp_bearer) = &state.idp_bearer + && codex_services::idp_bearer::is_idp_algorithm(token) + { + return extract_from_idp_bearer(token, state, idp_bearer).await; + } + // Verify and decode JWT let claims = state .jwt_service @@ -312,6 +404,77 @@ async fn extract_from_jwt(token: &str, state: &AppState) -> Result Result { + let validated = idp_bearer.validator.validate(token).await.map_err(|err| { + if err.is_backend() { + // The token may be fine; the IdP couldn't be consulted. + tracing::warn!(reason = %err, "IdP bearer validation failed on the IdP side"); + ApiError::ServiceUnavailable("Identity provider is unreachable".to_string()) + } else { + // The precise cause goes to the log only; the response stays + // uniform so callers can't probe validation internals. + tracing::debug!(reason = %err, "Rejected IdP bearer token"); + ApiError::Unauthorized("Invalid bearer token".to_string()) + } + })?; + + let user_id = match idp_bearer + .subject_cache + .get(&validated.provider_name, &validated.subject) + { + Some(user_id) => user_id, + None => { + let connection = codex_db::repositories::OidcConnectionRepository::find_by_provider_subject( + &state.db, + &validated.provider_name, + &validated.subject, + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load OIDC connection: {}", e)))? + .ok_or_else(|| { + tracing::debug!( + provider = %validated.provider_name, + "Valid IdP bearer token for an unlinked identity" + ); + ApiError::Unauthorized( + "No Codex account is linked to this identity; sign in to Codex via SSO once to link it" + .to_string(), + ) + })?; + idp_bearer.subject_cache.insert( + &validated.provider_name, + &validated.subject, + connection.user_id, + ); + connection.user_id + } + }; + + resolve_user_context(user_id, AuthMethod::OidcBearer, state).await +} + +/// Build an [`AuthContext`] for a user id whose credential has already +/// been verified (session JWT or IdP bearer), going through the user auth +/// cache. Inactive users are rejected identically on every path. +async fn resolve_user_context( + user_id: Uuid, + auth_method: AuthMethod, + state: &AppState, +) -> Result { // OPTIMIZATION: Check cache first to avoid DB query on every request // This significantly reduces DB load when many requests come in from the same user // (e.g., loading a page with 30+ thumbnail requests) @@ -328,7 +491,7 @@ async fn extract_from_jwt(token: &str, state: &AppState) -> Result Result Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oidc_subject_cache_returns_fresh_entries() { + let cache = OidcSubjectCache::default(); + let user_id = Uuid::new_v4(); + + assert_eq!(cache.get("authentik", "sub-1"), None); + + cache.insert("authentik", "sub-1", user_id); + assert_eq!(cache.get("authentik", "sub-1"), Some(user_id)); + + // Distinct provider or subject is a distinct identity. + assert_eq!(cache.get("keycloak", "sub-1"), None); + assert_eq!(cache.get("authentik", "sub-2"), None); + } + + #[test] + fn oidc_subject_cache_expires_entries_after_ttl() { + let cache = OidcSubjectCache::default(); + let user_id = Uuid::new_v4(); + + cache.cache.insert( + ("authentik".to_string(), "sub-1".to_string()), + CachedSubject { + user_id, + cached_at: Utc::now() - chrono::Duration::seconds(OIDC_SUBJECT_CACHE_TTL_SECS + 1), + }, + ); + + assert_eq!(cache.get("authentik", "sub-1"), None); + // The expired entry is evicted, not just skipped. + assert!(cache.cache.is_empty()); + } + + #[test] + fn oidc_subject_cache_invalidates_all_links_for_a_user() { + let cache = OidcSubjectCache::default(); + let user_a = Uuid::new_v4(); + let user_b = Uuid::new_v4(); + + cache.insert("authentik", "sub-a", user_a); + cache.insert("keycloak", "sub-a2", user_a); + cache.insert("authentik", "sub-b", user_b); + + cache.invalidate_user(&user_a); + + assert_eq!(cache.get("authentik", "sub-a"), None); + assert_eq!(cache.get("keycloak", "sub-a2"), None); + assert_eq!(cache.get("authentik", "sub-b"), Some(user_b)); + } +} diff --git a/crates/codex-api/src/extractors/mod.rs b/crates/codex-api/src/extractors/mod.rs index eb7117c7..141688d8 100644 --- a/crates/codex-api/src/extractors/mod.rs +++ b/crates/codex-api/src/extractors/mod.rs @@ -3,7 +3,7 @@ pub mod client_info; // AuthMethod is part of the public API for auth context inspection #[allow(unused_imports)] -pub use auth::{AppState, AuthContext, AuthMethod, AuthState, FlexibleAuthContext}; +pub use auth::{AppState, AuthContext, AuthMethod, AuthState, FlexibleAuthContext, IdpBearerAuth}; pub use client_info::ClientInfo; // Historical alias. The canonical location is `codex_services::content_filter`. pub use codex_services::content_filter::ContentFilter; diff --git a/crates/codex-config/src/env_override.rs b/crates/codex-config/src/env_override.rs index 26e98239..bb9b520f 100644 --- a/crates/codex-config/src/env_override.rs +++ b/crates/codex-config/src/env_override.rs @@ -127,6 +127,13 @@ impl EnvOverride for OidcProviderConfig { if let Ok(email_claim) = env::var(format!("{}_EMAIL_CLAIM", prefix)) { self.email_claim = email_claim; } + if let Ok(accepted_audiences) = env::var(format!("{}_ACCEPTED_AUDIENCES", prefix)) { + self.accepted_audiences = accepted_audiences + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } // Role mapping: ROLE_MAPPING_ADMIN="group1, group2", ROLE_MAPPING_MAINTAINER="group3" let role_mapping_prefix = format!("{}_ROLE_MAPPING_", prefix); @@ -207,6 +214,7 @@ impl EnvOverride for OidcConfig { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; new_provider.apply_env_overrides(&provider_prefix); new_provider @@ -1232,6 +1240,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_CLIENT_SECRET"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_SCOPES"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_GROUPS_CLAIM"); + remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ACCEPTED_AUDIENCES"); use crate::OidcProviderConfig; @@ -1246,6 +1255,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; set_var( @@ -1272,6 +1282,10 @@ mod tests { "CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_GROUPS_CLAIM", "custom_groups", ); + set_var( + "CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ACCEPTED_AUDIENCES", + "codex-client, shared-shisho-client", + ); provider.apply_env_overrides("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK"); assert_eq!(provider.display_name, "Authentik SSO"); @@ -1287,6 +1301,13 @@ mod tests { ] ); assert_eq!(provider.groups_claim, "custom_groups"); + assert_eq!( + provider.accepted_audiences, + vec![ + "codex-client".to_string(), + "shared-shisho-client".to_string() + ] + ); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_DISPLAY_NAME"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ISSUER_URL"); @@ -1294,6 +1315,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_CLIENT_SECRET"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_SCOPES"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_GROUPS_CLAIM"); + remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ACCEPTED_AUDIENCES"); } #[test] @@ -1319,6 +1341,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); @@ -1463,6 +1486,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; set_var( @@ -1525,6 +1549,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; // Add admin via env, reader stays from YAML diff --git a/crates/codex-config/src/types.rs b/crates/codex-config/src/types.rs index a3595ed6..4ab77263 100644 --- a/crates/codex-config/src/types.rs +++ b/crates/codex-config/src/types.rs @@ -147,6 +147,14 @@ pub struct OidcProviderConfig { /// Claim for email (default: "email") #[serde(default = "default_email_claim")] pub email_claim: String, + + /// Audiences accepted on API bearer tokens from this provider. + /// Empty means "accept only `client_id`". Add the client IDs of trusted + /// apps that forward their users' tokens (e.g. a shared Shisho/claude.ai + /// client). Resolution of the empty default happens at validator + /// construction. + #[serde(default)] + pub accepted_audiences: Vec, } fn default_groups_claim() -> String { @@ -2140,6 +2148,7 @@ database: groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; let yaml = serde_yaml::to_string(&provider).unwrap(); @@ -2222,6 +2231,43 @@ client_id: "test-client" assert!(provider.role_mapping.is_empty()); } + #[test] + fn test_oidc_provider_config_accepted_audiences_default_empty() { + let yaml_content = r#" +display_name: "Test Provider" +issuer_url: "https://test.example.com" +client_id: "test-client" +"#; + + let provider: OidcProviderConfig = serde_yaml::from_str(yaml_content).unwrap(); + // Empty means "use [client_id]"; resolution happens at validator + // construction, not here. + assert!(provider.accepted_audiences.is_empty()); + } + + #[test] + fn test_oidc_provider_config_accepted_audiences_from_yaml() { + let yaml_content = r#" +display_name: "Authentik" +issuer_url: "https://authentik.example.com/application/o/codex/" +client_id: "codex-client" +accepted_audiences: + - "codex-client" + - "shared-shisho-client" +"#; + + let provider: OidcProviderConfig = serde_yaml::from_str(yaml_content).unwrap(); + assert_eq!( + provider.accepted_audiences, + vec!["codex-client", "shared-shisho-client"] + ); + + // Round-trips through serialization + let yaml = serde_yaml::to_string(&provider).unwrap(); + let deserialized: OidcProviderConfig = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(deserialized.accepted_audiences, provider.accepted_audiences); + } + #[test] fn test_oidc_config_serialization() { let mut providers = HashMap::new(); @@ -2238,6 +2284,7 @@ client_id: "test-client" groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); diff --git a/crates/codex-services/Cargo.toml b/crates/codex-services/Cargo.toml index 83ad0758..dcf9ff53 100644 --- a/crates/codex-services/Cargo.toml +++ b/crates/codex-services/Cargo.toml @@ -52,6 +52,8 @@ csv = "1.3" # Auth / identity openidconnect = "4" +# IdP bearer validation (resource-server path); same version/backend as codex-utils +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } sha2 = "0.10" base64 = "0.22" rand = "0.10" @@ -106,6 +108,8 @@ sysinfo = { version = "0.39", default-features = false, features = ["system"], o [dev-dependencies] tempfile = { workspace = true } serial_test = { workspace = true } +# Local IdP stub (discovery + JWKS endpoints) for idp_bearer integration tests +axum = "0.8" codex-db = { workspace = true, features = ["test-utils"] } # Capturing tracing layer for plugin::manager span-emission tests and the # metrics::tests in-memory exporter checks. diff --git a/crates/codex-services/src/idp_bearer.rs b/crates/codex-services/src/idp_bearer.rs new file mode 100644 index 00000000..7fde9df5 --- /dev/null +++ b/crates/codex-services/src/idp_bearer.rs @@ -0,0 +1,655 @@ +//! IdP-issued bearer token validation: the OAuth2 resource-server half of +//! Codex's OIDC support. Turns a raw `Authorization: Bearer` JWT from a +//! configured identity provider into a verified `(provider_name, subject)` +//! pair, or a precise rejection reason. +//! +//! ## Security model +//! +//! - **Asymmetric algorithms only** ([`ALLOWED_ALGS`]). Codex's own session +//! JWTs are HS256 and verified elsewhere with the local secret; HS* is +//! never valid on this path, closing the classic key-confusion attack +//! where an attacker signs a token with public key material. +//! - **Provider selection by unverified `iss` peek.** The issuer claim is +//! read without verification only to pick which provider's JWKS and +//! audience rules apply; nothing is trusted until the signature verifies +//! and the issuer is re-checked as part of validation. +//! - **Audience enforcement.** The token's `aud` must match the provider's +//! accepted list (`accepted_audiences`, defaulting to the provider's own +//! `client_id`). Tokens without an `aud` claim are rejected. +//! - **No auto-provisioning.** This module only proves token authenticity. +//! Mapping the validated identity to a Codex user happens upstream, and +//! only against existing `oidc_connections` rows: a valid IdP token for +//! an unlinked identity must not create an account. +//! +//! JWKS material is fetched lazily from the provider's discovery document +//! and cached; see [`JwksCache`] for the refresh and fail-closed rules. +//! Errors never include token material. + +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use jsonwebtoken::jwk::{Jwk, JwkSet}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde::Deserialize; +use tokio::sync::Mutex; +use tokio::time::Instant; + +use codex_config::OidcConfig; + +/// Algorithms accepted on IdP-issued bearer tokens. Symmetric algorithms +/// are deliberately absent: there is no shared secret in this trust model. +const ALLOWED_ALGS: &[Algorithm] = &[Algorithm::RS256, Algorithm::ES256]; + +/// Age at which a cached JWKS is refreshed on next use. Matches the 1h +/// discovery cache TTL used by the web-login flow. +const JWKS_REFRESH_AFTER: Duration = Duration::from_secs(3600); + +/// Minimum spacing between refetches triggered by unknown-`kid` misses, so +/// a stream of garbage kids cannot turn into IdP load. +const MISS_REFETCH_MIN_INTERVAL: Duration = Duration::from_secs(60); + +/// Leeway applied to `exp`/`nbf`, absorbing small clock skew between the +/// IdP and Codex. +const CLOCK_SKEW_LEEWAY_SECS: u64 = 30; + +/// Validation failure reasons. Variants are deliberately fine-grained so +/// the auth extractor can log a precise cause; none carry token material. +#[derive(Debug, thiserror::Error)] +pub enum IdpBearerError { + // -- IdP-side failures (the caller's token may be fine). -- + #[error("could not fetch the IdP discovery document")] + Discovery(#[source] reqwest::Error), + #[error("could not fetch the IdP JWKS")] + Jwks(#[source] reqwest::Error), + #[error( + "discovery document issuer `{found}` does not match the configured issuer `{expected}`" + )] + IssuerMismatch { expected: String, found: String }, + + // -- Token failures (distinct traced reasons). -- + #[error("malformed token header")] + BadHeader(#[source] jsonwebtoken::errors::Error), + #[error("malformed token payload")] + BadPayload, + #[error("unsupported signing algorithm")] + UnsupportedAlgorithm, + #[error("token issuer does not match any configured provider")] + UnknownIssuer, + #[error("no JWKS key matches the token's key id")] + UnknownKey, + #[error("the matched JWKS key is unusable")] + BadKey(#[source] jsonwebtoken::errors::Error), + #[error("wrong issuer")] + WrongIssuer, + #[error("wrong audience")] + WrongAudience, + #[error("token expired")] + Expired, + #[error("token not yet valid")] + Immature, + #[error("signature verification failed")] + BadSignature, + #[error("token missing required claim `{0}`")] + MissingClaim(String), + #[error("token has no usable subject claim")] + MissingSubject, + #[error("token rejected")] + Invalid(#[source] jsonwebtoken::errors::Error), +} + +impl IdpBearerError { + /// IdP-side failures the caller cannot fix by changing their token; + /// everything else is a rejection of the presented credential. + pub fn is_backend(&self) -> bool { + matches!( + self, + IdpBearerError::Discovery(_) + | IdpBearerError::Jwks(_) + | IdpBearerError::IssuerMismatch { .. } + ) + } +} + +/// A successfully validated IdP bearer token, reduced to the identity pair +/// the caller resolves against `oidc_connections`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedIdpToken { + /// Configured provider name (the `oidc_connections.provider` value). + pub provider_name: String, + /// The token's `sub` claim: the user's unique identifier at the IdP. + pub subject: String, +} + +/// The slice of the OIDC discovery document this module consumes. +#[derive(Debug, Clone, Deserialize)] +struct Discovery { + issuer: String, + jwks_uri: String, +} + +#[derive(Default)] +struct KeyState { + jwks: Option, + fetched_at: Option, + last_miss_refetch: Option, +} + +/// Lazy, per-provider JWKS cache. +/// +/// Keys are fetched on first use (an IdP outage degrades bearer auth +/// instead of failing startup) and refreshed two ways: a periodic refresh +/// once the set is older than [`JWKS_REFRESH_AFTER`], and a single +/// miss-triggered refetch when a token names an unknown `kid` (key +/// rotation). Miss refetches are rate-limited by +/// [`MISS_REFETCH_MIN_INTERVAL`]; after the one refetch the lookup fails +/// closed. A refresh failure serves the previous (stale) set rather than +/// taking down auth. +struct JwksCache { + /// Configured issuer, without the trailing slash. + issuer: String, + discovery: Mutex>, + keys: Mutex, +} + +impl JwksCache { + fn new(issuer_url: &str) -> Self { + Self { + issuer: issuer_url.trim_end_matches('/').to_owned(), + discovery: Mutex::new(None), + keys: Mutex::new(KeyState::default()), + } + } + + /// The discovery document, fetched once per process and then served + /// from memory (endpoints do not move within an IdP deployment). + async fn discovery(&self, http: &reqwest::Client) -> Result { + let mut cached = self.discovery.lock().await; + if let Some(doc) = cached.as_ref() { + return Ok(doc.clone()); + } + + let url = format!("{}/.well-known/openid-configuration", self.issuer); + let doc: Discovery = http + .get(&url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(IdpBearerError::Discovery)? + .json() + .await + .map_err(IdpBearerError::Discovery)?; + + // The discovery issuer authenticates the document (OIDC Discovery + // §4.3); a mismatch means the configured URL points at something + // other than the issuer it claims to serve. + if doc.issuer.trim_end_matches('/') != self.issuer { + return Err(IdpBearerError::IssuerMismatch { + expected: self.issuer.clone(), + found: doc.issuer.clone(), + }); + } + + *cached = Some(doc.clone()); + Ok(doc) + } + + /// The decoding key for `kid`, refetching the JWKS once on an unknown + /// `kid` (rate-limited), then failing closed. + async fn decoding_key( + &self, + http: &reqwest::Client, + kid: Option<&str>, + ) -> Result { + let mut state = self.keys.lock().await; + + let stale = state + .fetched_at + .is_none_or(|at| at.elapsed() >= JWKS_REFRESH_AFTER); + if stale { + match self.refetch(http, &mut state).await { + Ok(()) => {} + // Serve from the stale set if we have one; only a cold + // cache propagates the fetch failure. + Err(err) if state.jwks.is_none() => return Err(err), + Err(err) => { + tracing::warn!( + issuer = %self.issuer, + error = %err, + "JWKS refresh failed; serving stale key set" + ); + } + } + } + + if let Some(jwk) = Self::find(state.jwks.as_ref(), kid) { + return DecodingKey::from_jwk(jwk).map_err(IdpBearerError::BadKey); + } + + // Unknown kid: one refetch covers key rotation; the rate limit + // keeps garbage kids from hammering the IdP. + let may_refetch = state + .last_miss_refetch + .is_none_or(|at| at.elapsed() >= MISS_REFETCH_MIN_INTERVAL); + if may_refetch { + state.last_miss_refetch = Some(Instant::now()); + self.refetch(http, &mut state).await?; + if let Some(jwk) = Self::find(state.jwks.as_ref(), kid) { + return DecodingKey::from_jwk(jwk).map_err(IdpBearerError::BadKey); + } + } + + Err(IdpBearerError::UnknownKey) + } + + /// A `kid` match, or the sole key of a single-key set when the token + /// header carries no `kid` at all. + fn find<'a>(jwks: Option<&'a JwkSet>, kid: Option<&str>) -> Option<&'a Jwk> { + let jwks = jwks?; + match kid { + Some(kid) => jwks.find(kid), + None if jwks.keys.len() == 1 => jwks.keys.first(), + None => None, + } + } + + async fn refetch( + &self, + http: &reqwest::Client, + state: &mut KeyState, + ) -> Result<(), IdpBearerError> { + let discovery = self.discovery(http).await?; + let jwks: JwkSet = http + .get(&discovery.jwks_uri) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(IdpBearerError::Jwks)? + .json() + .await + .map_err(IdpBearerError::Jwks)?; + + tracing::debug!(issuer = %self.issuer, keys = jwks.keys.len(), "JWKS refreshed"); + state.jwks = Some(jwks); + state.fetched_at = Some(Instant::now()); + Ok(()) + } +} + +/// One configured provider, with its audience rules resolved and its JWKS +/// cache. +struct ProviderState { + name: String, + /// Audiences accepted on tokens from this provider. Resolved at + /// construction: an empty `accepted_audiences` config means + /// `[client_id]`. + accepted_audiences: Vec, + jwks: JwksCache, +} + +/// Validates IdP-issued bearer tokens against the configured OIDC +/// providers. See the module docs for the security model. +pub struct IdpBearerValidator { + providers: Vec, + http: reqwest::Client, +} + +impl IdpBearerValidator { + /// Build a validator for every provider in `config`. + /// + /// Whether bearer validation is enabled at all (`oidc.enabled`, at + /// least one provider) is the caller's decision; this constructor only + /// shapes provider state. + pub fn new(config: &OidcConfig) -> Self { + // Redirects disabled to match the SSRF posture of the login flow; + // the timeout keeps a hung IdP from hanging API auth requests. + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build IdP bearer HTTP client"); + + let providers = config + .providers + .iter() + .map(|(name, provider)| ProviderState { + name: name.clone(), + accepted_audiences: if provider.accepted_audiences.is_empty() { + vec![provider.client_id.clone()] + } else { + provider.accepted_audiences.clone() + }, + jwks: JwksCache::new(&provider.issuer_url), + }) + .collect(); + + Self { providers, http } + } + + /// Number of configured providers (zero means nothing can validate). + pub fn provider_count(&self) -> usize { + self.providers.len() + } + + /// Validate an IdP-issued bearer token: algorithm allowlist, provider + /// selection by `iss`, then signature, issuer, audience, and + /// `exp`/`nbf` (30s leeway) against the provider's JWKS. + pub async fn validate(&self, token: &str) -> Result { + let header = decode_header(token).map_err(IdpBearerError::BadHeader)?; + if !ALLOWED_ALGS.contains(&header.alg) { + return Err(IdpBearerError::UnsupportedAlgorithm); + } + + let issuer = peek_issuer(token)?; + let provider = self + .provider_for_issuer(&issuer) + .ok_or(IdpBearerError::UnknownIssuer)?; + + let key = provider + .jwks + .decoding_key(&self.http, header.kid.as_deref()) + .await?; + + // Validate against exactly the header's (allowlisted) algorithm; + // jsonwebtoken rejects the token if the key family differs. + let mut validation = Validation::new(header.alg); + validation.leeway = CLOCK_SKEW_LEEWAY_SECS; + validation.validate_nbf = true; + // `iss`/`aud` are only checked when present unless required: + // a token without them must fail closed, not skip the check. + validation.set_required_spec_claims(&["exp", "iss", "aud"]); + // Issuers in the wild differ on the trailing slash; accept both + // spellings of the configured issuer, nothing else. + let trimmed = provider.jwks.issuer.as_str(); + validation.set_issuer(&[trimmed.to_owned(), format!("{trimmed}/")]); + validation.set_audience(&provider.accepted_audiences); + + #[derive(Deserialize)] + struct BearerClaims { + sub: Option, + } + + match decode::(token, &key, &validation) { + Ok(data) => { + let subject = data + .claims + .sub + .filter(|sub| !sub.is_empty()) + .ok_or(IdpBearerError::MissingSubject)?; + tracing::debug!( + provider = %provider.name, + "IdP bearer token validated" + ); + Ok(ValidatedIdpToken { + provider_name: provider.name.clone(), + subject, + }) + } + Err(err) => Err(match err.kind() { + jsonwebtoken::errors::ErrorKind::InvalidIssuer => IdpBearerError::WrongIssuer, + jsonwebtoken::errors::ErrorKind::InvalidAudience => IdpBearerError::WrongAudience, + jsonwebtoken::errors::ErrorKind::ExpiredSignature => IdpBearerError::Expired, + jsonwebtoken::errors::ErrorKind::ImmatureSignature => IdpBearerError::Immature, + jsonwebtoken::errors::ErrorKind::InvalidSignature => IdpBearerError::BadSignature, + jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claim) => { + IdpBearerError::MissingClaim(claim.clone()) + } + _ => IdpBearerError::Invalid(err), + }), + } + } + + /// The provider whose configured issuer matches `iss`, trailing-slash + /// tolerant. + fn provider_for_issuer(&self, iss: &str) -> Option<&ProviderState> { + let iss = iss.trim_end_matches('/'); + self.providers.iter().find(|p| p.jwks.issuer == iss) + } +} + +/// Whether the token's header names an algorithm this module handles. +/// +/// The auth extractor uses this to route bearer tokens: HS256 session JWTs +/// (and anything with an undecodable header) stay on the local-secret path +/// and keep today's error behavior; only RS256/ES256 tokens are diverted to +/// IdP validation, and only when a validator is configured. +pub fn is_idp_algorithm(token: &str) -> bool { + decode_header(token).is_ok_and(|header| ALLOWED_ALGS.contains(&header.alg)) +} + +/// Read the token's `iss` claim without verifying anything. Used solely to +/// select which provider's keys and rules apply; the claim is re-validated +/// against that provider during signature verification. +fn peek_issuer(token: &str) -> Result { + #[derive(Deserialize)] + struct IssClaim { + iss: Option, + } + + let payload = token.split('.').nth(1).ok_or(IdpBearerError::BadPayload)?; + let bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| IdpBearerError::BadPayload)?; + let claims: IssClaim = + serde_json::from_slice(&bytes).map_err(|_| IdpBearerError::BadPayload)?; + claims.iss.ok_or(IdpBearerError::UnknownIssuer) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_config::{OidcDefaultRole, OidcProviderConfig}; + use std::collections::HashMap; + + fn provider(issuer_url: &str, client_id: &str, accepted: Vec) -> OidcProviderConfig { + OidcProviderConfig { + display_name: "Test".to_string(), + issuer_url: issuer_url.to_string(), + client_id: client_id.to_string(), + client_secret: None, + client_secret_env: None, + scopes: vec![], + role_mapping: HashMap::new(), + groups_claim: "groups".to_string(), + username_claim: "preferred_username".to_string(), + email_claim: "email".to_string(), + accepted_audiences: accepted, + } + } + + fn config(providers: Vec<(&str, OidcProviderConfig)>) -> OidcConfig { + OidcConfig { + enabled: true, + auto_create_users: false, + default_role: OidcDefaultRole::Reader, + redirect_uri_base: None, + providers: providers + .into_iter() + .map(|(name, p)| (name.to_string(), p)) + .collect(), + } + } + + /// Hand-assemble a `header.payload.signature` token. The signature is + /// garbage: these tests only exercise the steps before verification. + fn raw_token(header: serde_json::Value, payload: serde_json::Value) -> String { + let encode = |v: &serde_json::Value| URL_SAFE_NO_PAD.encode(v.to_string()); + format!("{}.{}.c2ln", encode(&header), encode(&payload)) + } + + #[test] + fn accepted_audiences_default_to_client_id() { + let cfg = config(vec![( + "authentik", + provider("https://idp.example.com/app/", "codex-client", vec![]), + )]); + let validator = IdpBearerValidator::new(&cfg); + assert_eq!( + validator.providers[0].accepted_audiences, + vec!["codex-client".to_string()] + ); + } + + #[test] + fn explicit_accepted_audiences_are_kept_verbatim() { + let cfg = config(vec![( + "authentik", + provider( + "https://idp.example.com/app/", + "codex-client", + vec!["codex-client".to_string(), "shared-client".to_string()], + ), + )]); + let validator = IdpBearerValidator::new(&cfg); + assert_eq!( + validator.providers[0].accepted_audiences, + vec!["codex-client".to_string(), "shared-client".to_string()] + ); + } + + #[test] + fn provider_lookup_tolerates_trailing_slash_in_both_directions() { + let cfg = config(vec![ + ( + "with-slash", + provider("https://a.example.com/app/", "a", vec![]), + ), + ("no-slash", provider("https://b.example.com", "b", vec![])), + ]); + let validator = IdpBearerValidator::new(&cfg); + + for iss in ["https://a.example.com/app", "https://a.example.com/app/"] { + let found = validator.provider_for_issuer(iss).expect(iss); + assert_eq!(found.name, "with-slash"); + } + for iss in ["https://b.example.com", "https://b.example.com/"] { + let found = validator.provider_for_issuer(iss).expect(iss); + assert_eq!(found.name, "no-slash"); + } + assert!( + validator + .provider_for_issuer("https://evil.example.com") + .is_none() + ); + } + + #[test] + fn peek_issuer_reads_the_unverified_claim() { + let token = raw_token( + serde_json::json!({"alg": "RS256", "typ": "JWT"}), + serde_json::json!({"iss": "https://idp.example.com", "sub": "u1"}), + ); + assert_eq!(peek_issuer(&token).unwrap(), "https://idp.example.com"); + } + + #[test] + fn peek_issuer_rejects_garbage_and_missing_iss() { + assert!(matches!( + peek_issuer("not-a-jwt"), + Err(IdpBearerError::BadPayload) + )); + assert!(matches!( + peek_issuer("a.!!!.c"), + Err(IdpBearerError::BadPayload) + )); + + let token = raw_token( + serde_json::json!({"alg": "RS256", "typ": "JWT"}), + serde_json::json!({"sub": "u1"}), + ); + assert!(matches!( + peek_issuer(&token), + Err(IdpBearerError::UnknownIssuer) + )); + } + + #[tokio::test] + async fn hs256_tokens_are_rejected_before_any_network_io() { + let cfg = config(vec![( + "authentik", + provider("https://idp.example.com", "codex", vec![]), + )]); + let validator = IdpBearerValidator::new(&cfg); + + // A real HS256 token signed with some secret: the IdP path must + // refuse it on algorithm alone, never trying key material. + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::HS256), + &serde_json::json!({"iss": "https://idp.example.com", "sub": "u1", "exp": 4102444800u64}), + &jsonwebtoken::EncodingKey::from_secret(b"local-secret"), + ) + .unwrap(); + + assert!(matches!( + validator.validate(&token).await, + Err(IdpBearerError::UnsupportedAlgorithm) + )); + } + + #[tokio::test] + async fn alg_none_tokens_are_rejected_as_malformed() { + let cfg = config(vec![( + "authentik", + provider("https://idp.example.com", "codex", vec![]), + )]); + let validator = IdpBearerValidator::new(&cfg); + + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(r#"{"iss":"https://idp.example.com","sub":"u1"}"#); + let token = format!("{header}.{payload}."); + + assert!(matches!( + validator.validate(&token).await, + Err(IdpBearerError::BadHeader(_)) + )); + } + + #[tokio::test] + async fn unknown_issuer_is_rejected_before_any_network_io() { + let cfg = config(vec![( + "authentik", + provider("https://idp.example.com", "codex", vec![]), + )]); + let validator = IdpBearerValidator::new(&cfg); + + let token = raw_token( + serde_json::json!({"alg": "RS256", "typ": "JWT"}), + serde_json::json!({"iss": "https://evil.example.com", "sub": "u1"}), + ); + assert!(matches!( + validator.validate(&token).await, + Err(IdpBearerError::UnknownIssuer) + )); + } + + #[test] + fn is_idp_algorithm_routes_only_allowlisted_asymmetric_tokens() { + let token_with_alg = |alg: &str| { + let header = URL_SAFE_NO_PAD.encode(format!(r#"{{"alg":"{alg}","typ":"JWT"}}"#)); + format!("{header}.e30.c2ln") + }; + + assert!(is_idp_algorithm(&token_with_alg("RS256"))); + assert!(is_idp_algorithm(&token_with_alg("ES256"))); + + // Local session tokens and oddities stay on the existing path. + assert!(!is_idp_algorithm(&token_with_alg("HS256"))); + assert!(!is_idp_algorithm(&token_with_alg("none"))); + assert!(!is_idp_algorithm("garbage")); + assert!(!is_idp_algorithm("")); + } + + #[test] + fn backend_errors_are_distinguished_from_token_rejections() { + assert!( + IdpBearerError::IssuerMismatch { + expected: "a".into(), + found: "b".into() + } + .is_backend() + ); + assert!(!IdpBearerError::WrongAudience.is_backend()); + assert!(!IdpBearerError::Expired.is_backend()); + assert!(!IdpBearerError::UnknownKey.is_backend()); + } +} diff --git a/crates/codex-services/src/lib.rs b/crates/codex-services/src/lib.rs index 7c34bdf1..de524bc1 100644 --- a/crates/codex-services/src/lib.rs +++ b/crates/codex-services/src/lib.rs @@ -15,6 +15,7 @@ pub mod email; pub mod export_storage; pub mod file_cleanup; pub mod filter; +pub mod idp_bearer; pub mod inflight_thumbnails; pub mod library_jobs; pub mod metadata; @@ -43,6 +44,7 @@ pub use cleanup_subscriber::CleanupEventSubscriber; pub use export_storage::ExportStorage; pub use file_cleanup::{CleanupStats, FileCleanupService, OrphanedFileType}; pub use filter::FilterService; +pub use idp_bearer::{IdpBearerError, IdpBearerValidator, ValidatedIdpToken}; pub use inflight_thumbnails::InflightThumbnailTracker; pub use oidc::OidcService; pub use pdf_cache::{CacheStats, CleanupResult, PdfPageCache}; diff --git a/crates/codex-services/src/oidc.rs b/crates/codex-services/src/oidc.rs index d61bb8d0..97463426 100644 --- a/crates/codex-services/src/oidc.rs +++ b/crates/codex-services/src/oidc.rs @@ -747,6 +747,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); @@ -817,6 +818,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; let config = create_test_config(); @@ -1215,6 +1217,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); providers.insert( @@ -1230,6 +1233,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); @@ -1306,6 +1310,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; let secret = service.resolve_client_secret(&provider); @@ -1329,6 +1334,7 @@ mod tests { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; let secret = service.resolve_client_secret(&provider); diff --git a/crates/codex-services/tests/fixtures/idp_bearer/jwks_key_a.json b/crates/codex-services/tests/fixtures/idp_bearer/jwks_key_a.json new file mode 100644 index 00000000..6ad5dac9 --- /dev/null +++ b/crates/codex-services/tests/fixtures/idp_bearer/jwks_key_a.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-a", + "n": "q4QrLEg4YwAwH0OG_lYWWP6p9TYfGh_UZxwgze7uzy6heqwzPFm5UQHESJLtA8tJFQu-9MUPQLWnWBB2HADcLkHnRQ2hq3QpkNG1f9s87jAXEW5qg6omWACFQnpxpO69eWuub23uIt86q-qWCirvlixQzcb9S0IFMDdMj4LLO_sPXEDkykDIIO2_psqOBl_wnXEVaxcEiar9KKyhRIIBivaZoY-3OsUK6m6Evg6imyrWUXluddLCndVOv5tM7-9vO3aLrU2ciUXY-w2jpi6dtqtLOwMWpRmJdARrhXoeK9Ez4FfUIm0xjr2MnEzOzHZWgOSuDqVbdYmlCuAwms0MeQ", + "e": "AQAB" + } + ] +} \ No newline at end of file diff --git a/crates/codex-services/tests/fixtures/idp_bearer/jwks_keys_a_b.json b/crates/codex-services/tests/fixtures/idp_bearer/jwks_keys_a_b.json new file mode 100644 index 00000000..e0199255 --- /dev/null +++ b/crates/codex-services/tests/fixtures/idp_bearer/jwks_keys_a_b.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-a", + "n": "q4QrLEg4YwAwH0OG_lYWWP6p9TYfGh_UZxwgze7uzy6heqwzPFm5UQHESJLtA8tJFQu-9MUPQLWnWBB2HADcLkHnRQ2hq3QpkNG1f9s87jAXEW5qg6omWACFQnpxpO69eWuub23uIt86q-qWCirvlixQzcb9S0IFMDdMj4LLO_sPXEDkykDIIO2_psqOBl_wnXEVaxcEiar9KKyhRIIBivaZoY-3OsUK6m6Evg6imyrWUXluddLCndVOv5tM7-9vO3aLrU2ciUXY-w2jpi6dtqtLOwMWpRmJdARrhXoeK9Ez4FfUIm0xjr2MnEzOzHZWgOSuDqVbdYmlCuAwms0MeQ", + "e": "AQAB" + }, + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-b", + "n": "jLtLXvrhhzS1USoF3052Pf_nEKjy2_AphQNgWxXcs6o9Z9ZGzGI_4LEPW5j0eS84Yyfn3GTipQpCvacEe51udoYIl1VebC4S81M21485BMfUWybnXLKzZ0m9yReyqLt38Xgzw1B4icsTzhuZdRyILSruLJvsAKe64lA8Eug_bRQMNt6QuNxwyAEFyIBvavm68mz7atszWGoHPa-uMaCUIq6PWPBfrhFQgW0T1n82rIWrmIp6dL0-j6gJLX1_4-mOZEzhg950v24FlmkLXk2MZQX5JPzFgYtVuErC_wUXFnlW8Mh4J8XV4TWIfZ2ZJnJoUt3DVnba2LXQ1vH0A8TY1w", + "e": "AQAB" + } + ] +} \ No newline at end of file diff --git a/crates/codex-services/tests/fixtures/idp_bearer/key_a.pem b/crates/codex-services/tests/fixtures/idp_bearer/key_a.pem new file mode 100644 index 00000000..a6317719 --- /dev/null +++ b/crates/codex-services/tests/fixtures/idp_bearer/key_a.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrhCssSDhjADAf +Q4b+VhZY/qn1Nh8aH9RnHCDN7u7PLqF6rDM8WblRAcRIku0Dy0kVC770xQ9AtadY +EHYcANwuQedFDaGrdCmQ0bV/2zzuMBcRbmqDqiZYAIVCenGk7r15a65vbe4i3zqr +6pYKKu+WLFDNxv1LQgUwN0yPgss7+w9cQOTKQMgg7b+myo4GX/CdcRVrFwSJqv0o +rKFEggGK9pmhj7c6xQrqboS+DqKbKtZReW510sKd1U6/m0zv7287doutTZyJRdj7 +DaOmLp22q0s7AxalGYl0BGuFeh4r0TPgV9QibTGOvYycTM7MdlaA5K4OpVt1iaUK +4DCazQx5AgMBAAECggEACi6J65KL+mCP+sMk9+M/Z2AS0dI5LXFXSBm03tTAy37O +qjwfvITtCeCLeO1z8YY3W+77EofCPIqsGP+9KzNn3r0d0A0bnFSEhSrV1fW7W75y +GCBQZWmL5ix9vLENHwfnKpaYX9gMS3iqAHuoFYlv+J+/2j9mWPnd2ftmaZyPmQtY +7FXtNaQBdA2OqLF8z+oEV0R2ebnGWznwWPuEudgS2BGizpFDiNSNQnaGLzaK6ijx +FBx68iuQmsMoWfEVf5agLYN/E8/U572GczMSp1d6Rt2WgoC4cCmAc3N0iqACq2a3 +fKGZ/wnmB/ZJdOwOE/jjgsA3Gjq5QlntarSqx+IH4wKBgQDi9BtQRjJO9trs3//Y +u5IwlMo6ZjAq1h7q5sMrw+QbY9fYzcOWp5BOP66ozQEzrCa81hAr6ZvCK91JG32x +EzG9yh6EuCzT0wQrznMemniii2Kj433INFC4CPgiNQR5wPVCkUuStzXny387dyZU +Lt3M2zyMBMshODziLl9YmNlvFwKBgQDBd7f8Lm9Tnu+bDynjsEfT4xeXER/UNPV6 +62zI4bhF4mRYpoV7mt294iKu2iOAUyRkbSDf7z6/iydK9MrVsLY1CUSC11N4O3Fn +HSvzRMCAZObZJcKBaVHoWl+fzgm1btCQ/FRuYoEUaC+25KGgSPpA9dqL4i+KJnRb +Hed+pWoa7wKBgQC+69O+aA8SVqA8/QNh9Ak7TTACiMykfJRtz8sIGjbew9Nk01Ri +fHvtF61621rTeVLtMLdR+afKZNQ3GCVYvWju3AVoaPdtCutLXtWBPTmWo+NLM6kw +rrHo38K+JBOrySOJ6GjG99ElOg/Cmq1nzDVSjGwW7kFjpMezDoevJIZl6QKBgFxi +TcInblPQ/nvd5tPqNrJwq7AgpkFQNLTIvP7EApmkfOR0SCN5FU/6LzOtyOwbR7KJ +BfzJIv6WdWHuuPkH1EpJLaUJK7urLwDdRkJ5Xy8wf6fLxzDC2TEKDsWhvrMbwo6p +X6wCb78N7VL5pFCE6LOicwT/MlJXAJMOGaA5XwyTAoGAIvheHsJ8/tsrBjZr+ayC +bIDjmAt7/2HcXm88zRAbN4rsA2WF0ydE+HOETtk7gEzHDaPMftmqM57LNR9WV0P8 +6pcVK+fGvrtZQevswuBGqvHwW9OlsqI0zNXHhAhR68bFoh09V1zF4/dcMzUyNMgz +r4Z+G/Oqf2tNs8J3DBKY5X4= +-----END PRIVATE KEY----- diff --git a/crates/codex-services/tests/fixtures/idp_bearer/key_b.pem b/crates/codex-services/tests/fixtures/idp_bearer/key_b.pem new file mode 100644 index 00000000..24344a23 --- /dev/null +++ b/crates/codex-services/tests/fixtures/idp_bearer/key_b.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMu0te+uGHNLVR +KgXfTnY9/+cQqPLb8CmFA2BbFdyzqj1n1kbMYj/gsQ9bmPR5LzhjJ+fcZOKlCkK9 +pwR7nW52hgiXVV5sLhLzUzbXjzkEx9RbJudcsrNnSb3JF7Kou3fxeDPDUHiJyxPO +G5l1HIgtKu4sm+wAp7riUDwS6D9tFAw23pC43HDIAQXIgG9q+brybPtq2zNYagc9 +r64xoJQiro9Y8F+uEVCBbRPWfzashauYinp0vT6PqAktfX/j6Y5kTOGD3nS/bgWW +aQteTYxlBfkk/MWBi1W4SsL/BRcWeVbwyHgnxdXhNYh9nZkmcmhS3cNWdtrYtdDW +8fQDxNjXAgMBAAECgf8eS8HuBdtqziblGUNGz2RsLVWQ6z/Ow+TW9o3HuLQ8MjEF +8KlNHaCivl7I53nynKmG5/PltyeFQ5PFQC9FF/R25NWfTMF4wVX9tpb7XvY8u+SJ +gg9pAboPLO08lhZRyu1cnIPdiB/5GDCAoBsTxbtSNs9IIIHKTPwnSnPdLFslyqBm +MsCfrknhS8FBjyFkkqNXQonOhRtk9pCx878697hbZuLPYsQfsgOqUA92zezX6of6 +G+gA7Hf5vLfHQSERWCFHpZioBYLbRJ7dLObvj0lSogKc7+mqn9RsHyFFWEPk30us +s4cR+wvmSPLvqvFfQvJ2V0voXbVY7QJZkgXKh4ECgYEAxH4orKC9DVB9QHEhIJjs +0BXMk/evIAxJv+8am1v0g08zmnunltCZVDuQ1lCl880mUsBTYxhK0ASLk0AEBIFh +irN8T9NOvrhXyGkjOTd2RaamcprVa23iwD+r71aiqTCKylZoexQyQ+r9ZdS8Jo4b +WKmzAQvxfrkjWJ/4NtY2LxsCgYEAt1oKWQzR3DdhVmPZYiPx72RTi8kYWNtIuWkV +FZmUCaPyFfGzAuKNBW+yKOHU31jiQJEcy3uwPPJpwXftAiV+UlcDNXFN5pgczAwn +8JpfiNZ9dm4C2E4RHgdMQt3rz16VaQfPDJbkk4zwEJmg+ibf5p8N8abtVjqPx/dh +TIfljPUCgYEAk2BYm5n4Ejtzpljz0uJAhJZFGhplJLFyw13QZARlcfN+rfjfKzQM +POxsZwKYZjNR8jFEmgfHXRx7n5cdLE/qXEDhFXJVFqFnXe7Vt32M3RLwtvbA6lHC +CBX5nIsrd6DsCHUk6mOsi9p98tnLwVNG2Yp2s2tE15p/E2LwphinDU8CgYALP94E +qrGxlkBFoaizycrNSlWJ7ROuV/31Sko94gdgNAvlZsf59FZ8r+a5dWmvLm/rUDv5 +DCS7CJCOi0IicJR4jtgmjkYeUNTrfA9zRrV32tklzAgmp1uLgR0fuSf/uCjooc8F +UbjCiNIt4o8q6fmw169uVTSYps0tkrMIlAn0hQKBgQCEYCKWmlKm+yAB9DMezZvS +crG4CWQnrNmzLDZa3MlbsJilzNde56/SsOR/4kGY4BeqhfcecxrCEExBgKyllKyT +FNKU/54XZyec7VWx7HJ5URDImVoxGyGwxOxfbJ08ZBo2Y7lJQUxEE6Jeuk0vQFkA +g/bg3qDb/rLop6K5WW72BQ== +-----END PRIVATE KEY----- diff --git a/crates/codex-services/tests/idp_bearer.rs b/crates/codex-services/tests/idp_bearer.rs new file mode 100644 index 00000000..194210e3 --- /dev/null +++ b/crates/codex-services/tests/idp_bearer.rs @@ -0,0 +1,365 @@ +//! IdP bearer validation integration tests: full validate() runs against a +//! local axum "IdP" serving a discovery document and a JWKS built from +//! static RSA test keys under `tests/fixtures/idp_bearer/` (test-only key +//! material, never used outside the suite). + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use tokio::sync::Mutex; + +use codex_config::{OidcConfig, OidcDefaultRole, OidcProviderConfig}; +use codex_services::idp_bearer::{IdpBearerError, IdpBearerValidator}; + +fn fixture_path(name: &str) -> String { + format!( + "{}/tests/fixtures/idp_bearer/{name}", + env!("CARGO_MANIFEST_DIR") + ) +} + +fn key_pem(name: &str) -> Vec { + std::fs::read(fixture_path(&format!("{name}.pem"))).expect("test key fixture") +} + +fn jwks(name: &str) -> serde_json::Value { + let raw = std::fs::read_to_string(fixture_path(&format!("{name}.json"))).expect("jwks fixture"); + serde_json::from_str(&raw).expect("jwks fixture parses") +} + +/// In-process IdP stub: discovery + JWKS endpoints with a swappable key +/// set and a fetch counter for cache-behavior assertions. +struct IdpStub { + url: String, + jwks_body: Arc>, + jwks_hits: Arc, +} + +impl IdpStub { + async fn start(initial_jwks: serde_json::Value) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let url = format!("http://{}", listener.local_addr().unwrap()); + + let jwks_body = Arc::new(Mutex::new(initial_jwks)); + let jwks_hits = Arc::new(AtomicUsize::new(0)); + + #[derive(Clone)] + struct StubState { + issuer: String, + jwks_body: Arc>, + jwks_hits: Arc, + } + + let state = StubState { + issuer: url.clone(), + jwks_body: Arc::clone(&jwks_body), + jwks_hits: Arc::clone(&jwks_hits), + }; + + let app = Router::new() + .route( + "/.well-known/openid-configuration", + get(|State(s): State| async move { + Json(serde_json::json!({ + "issuer": s.issuer, + "jwks_uri": format!("{}/jwks", s.issuer), + })) + }), + ) + .route( + "/jwks", + get(|State(s): State| async move { + s.jwks_hits.fetch_add(1, Ordering::SeqCst); + Json(s.jwks_body.lock().await.clone()) + }), + ) + .with_state(state); + + tokio::spawn(async move { + axum::serve(listener, app).await.expect("stub IdP serves"); + }); + + Self { + url, + jwks_body, + jwks_hits, + } + } + + async fn set_jwks(&self, body: serde_json::Value) { + *self.jwks_body.lock().await = body; + } + + fn jwks_fetches(&self) -> usize { + self.jwks_hits.load(Ordering::SeqCst) + } +} + +fn provider_config(issuer_url: &str, accepted_audiences: Vec) -> OidcProviderConfig { + OidcProviderConfig { + display_name: "Test IdP".to_string(), + issuer_url: issuer_url.to_string(), + client_id: "codex-client".to_string(), + client_secret: None, + client_secret_env: None, + scopes: vec![], + role_mapping: HashMap::new(), + groups_claim: "groups".to_string(), + username_claim: "preferred_username".to_string(), + email_claim: "email".to_string(), + accepted_audiences, + } +} + +fn validator(provider_name: &str, provider: OidcProviderConfig) -> IdpBearerValidator { + let config = OidcConfig { + enabled: true, + auto_create_users: false, + default_role: OidcDefaultRole::Reader, + redirect_uri_base: None, + providers: HashMap::from([(provider_name.to_string(), provider)]), + }; + IdpBearerValidator::new(&config) +} + +fn sign(kid: &str, key: &str, claims: &serde_json::Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_owned()); + encode( + &header, + claims, + &EncodingKey::from_rsa_pem(&key_pem(key)).expect("test key parses"), + ) + .expect("token signs") +} + +fn claims(iss: &str, aud: &str, exp_offset_secs: i64) -> serde_json::Value { + serde_json::json!({ + "iss": iss, + "aud": aud, + "exp": chrono::Utc::now().timestamp() + exp_offset_secs, + "sub": "idp-user-42", + }) +} + +#[tokio::test] +async fn valid_token_resolves_to_provider_and_subject() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator("authentik", provider_config(&idp.url, vec![])); + + let token = sign("key-a", "key_a", &claims(&idp.url, "codex-client", 3600)); + let validated = v.validate(&token).await.expect("valid token validates"); + + assert_eq!(validated.provider_name, "authentik"); + assert_eq!(validated.subject, "idp-user-42"); +} + +#[tokio::test] +async fn second_validation_is_served_from_the_jwks_cache() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator("authentik", provider_config(&idp.url, vec![])); + + let token = sign("key-a", "key_a", &claims(&idp.url, "codex-client", 3600)); + v.validate(&token).await.expect("first validation"); + v.validate(&token).await.expect("second validation"); + + assert_eq!(idp.jwks_fetches(), 1, "second call must hit the cache"); +} + +#[tokio::test] +async fn each_tampered_dimension_fails_with_its_specific_error() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator("authentik", provider_config(&idp.url, vec![])); + + // Wrong issuer: no configured provider matches. + let token = sign( + "key-a", + "key_a", + &claims("https://evil.example.com", "codex-client", 3600), + ); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::UnknownIssuer) + )); + + // Wrong audience. + let token = sign("key-a", "key_a", &claims(&idp.url, "not-codex", 3600)); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::WrongAudience) + )); + + // Missing audience entirely: must fail closed, not skip the check. + let token = sign( + "key-a", + "key_a", + &serde_json::json!({ + "iss": idp.url, + "exp": chrono::Utc::now().timestamp() + 3600, + "sub": "idp-user-42", + }), + ); + match v.validate(&token).await { + Err(IdpBearerError::MissingClaim(claim)) => assert_eq!(claim, "aud"), + other => panic!("expected MissingClaim(aud), got {other:?}"), + } + + // Expired (well past the 30s leeway). + let token = sign("key-a", "key_a", &claims(&idp.url, "codex-client", -3600)); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::Expired) + )); + + // Not yet valid (nbf in the future, past the leeway). + let mut not_yet = claims(&idp.url, "codex-client", 3600); + not_yet["nbf"] = serde_json::json!(chrono::Utc::now().timestamp() + 3600); + let token = sign("key-a", "key_a", ¬_yet); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::Immature) + )); + + // Signed by key B but claiming key A's kid: signature mismatch. + let token = sign("key-a", "key_b", &claims(&idp.url, "codex-client", 3600)); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::BadSignature) + )); + + // No subject claim. + let token = sign( + "key-a", + "key_a", + &serde_json::json!({ + "iss": idp.url, + "aud": "codex-client", + "exp": chrono::Utc::now().timestamp() + 3600, + }), + ); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::MissingSubject) + )); +} + +#[tokio::test] +async fn expiry_within_leeway_is_tolerated() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator("authentik", provider_config(&idp.url, vec![])); + + // Expired 10s ago: inside the 30s clock-skew leeway. + let token = sign("key-a", "key_a", &claims(&idp.url, "codex-client", -10)); + v.validate(&token) + .await + .expect("clock skew within leeway tolerated"); +} + +#[tokio::test] +async fn accepted_audiences_list_admits_trusted_foreign_clients() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator( + "authentik", + provider_config( + &idp.url, + vec!["codex-client".to_string(), "shisho-shared".to_string()], + ), + ); + + // A token minted for the shared client is accepted... + let token = sign("key-a", "key_a", &claims(&idp.url, "shisho-shared", 3600)); + v.validate(&token).await.expect("shared audience accepted"); + + // ...while an unlisted audience still fails. + let token = sign("key-a", "key_a", &claims(&idp.url, "other-app", 3600)); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::WrongAudience) + )); +} + +#[tokio::test] +async fn issuer_trailing_slash_mismatch_between_config_and_token_is_tolerated() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + // Configured WITH a trailing slash, token issuer without (Authentik + // configs are routinely pasted with the slash). + let v = validator( + "authentik", + provider_config(&format!("{}/", idp.url), vec![]), + ); + + let token = sign("key-a", "key_a", &claims(&idp.url, "codex-client", 3600)); + v.validate(&token).await.expect("slash variant accepted"); + + // And the other spelling in the token also validates. + let token = sign( + "key-a", + "key_a", + &claims(&format!("{}/", idp.url), "codex-client", 3600), + ); + v.validate(&token).await.expect("slashed issuer accepted"); +} + +#[tokio::test] +async fn unknown_kid_triggers_one_refetch_then_fails_closed() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator("authentik", provider_config(&idp.url, vec![])); + + let token = sign("key-c", "key_b", &claims(&idp.url, "codex-client", 3600)); + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::UnknownKey) + )); + // Initial load + the one miss-triggered refetch. + assert_eq!(idp.jwks_fetches(), 2); + + // Immediately retrying the same unknown kid stays failed without + // another fetch (miss refetches are rate-limited). + assert!(matches!( + v.validate(&token).await, + Err(IdpBearerError::UnknownKey) + )); + assert_eq!(idp.jwks_fetches(), 2, "rate limit must hold"); +} + +#[tokio::test] +async fn rotated_key_is_picked_up_via_the_kid_miss_refetch() { + let idp = IdpStub::start(jwks("jwks_key_a")).await; + let v = validator("authentik", provider_config(&idp.url, vec![])); + + // Warm the cache on key A. + let token_a = sign("key-a", "key_a", &claims(&idp.url, "codex-client", 3600)); + v.validate(&token_a).await.expect("key A validates"); + + // Rotate: the IdP now also serves key B; a key-B token must validate + // transparently via the miss refetch, no restart. + idp.set_jwks(jwks("jwks_keys_a_b")).await; + let token_b = sign("key-b", "key_b", &claims(&idp.url, "codex-client", 3600)); + v.validate(&token_b) + .await + .expect("rotation must be transparent"); +} + +#[tokio::test] +async fn unreachable_idp_is_a_backend_error_not_a_token_rejection() { + // Nothing listens on this port. + let v = validator("authentik", provider_config("http://127.0.0.1:1", vec![])); + + let token = sign( + "key-a", + "key_a", + &claims("http://127.0.0.1:1", "codex-client", 3600), + ); + let err = v.validate(&token).await.expect_err("IdP is down"); + assert!( + err.is_backend(), + "IdP outage must be a backend error, got {err:?}" + ); +} diff --git a/docs/docs/users/oidc.md b/docs/docs/users/oidc.md index 8c949d6b..f298c054 100644 --- a/docs/docs/users/oidc.md +++ b/docs/docs/users/oidc.md @@ -15,6 +15,7 @@ OIDC authentication allows you to: - **Automatic User Creation**: New users are created on first OIDC login - **Group-to-Role Mapping**: Map IdP groups to Codex roles (Admin, Maintainer, Reader) - **Hybrid Mode**: OIDC and local authentication work side by side +- **API Bearer Tokens**: Provider-issued access tokens authenticate API requests directly (see [API Bearer Tokens](#api-bearer-tokens-resource-server)) ## Configuration @@ -72,6 +73,7 @@ auth: | `groups_claim` | No | `groups` | JWT claim containing user's groups | | `username_claim` | No | `preferred_username` | JWT claim for the username | | `email_claim` | No | `email` | JWT claim for the email address | +| `accepted_audiences` | No | `[client_id]` | Audiences accepted on API bearer tokens from this provider (see [API Bearer Tokens](#api-bearer-tokens-resource-server)) | ### Environment Variable Overrides @@ -94,6 +96,7 @@ CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_SCOPES="email, profile, groups" CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_GROUPS_CLAIM="groups" CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_USERNAME_CLAIM="preferred_username" CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_EMAIL_CLAIM="email" +CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ACCEPTED_AUDIENCES="codex-client, other-trusted-client" # Role mapping (comma-separated group names per role) CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ROLE_MAPPING_ADMIN="codex-admins, administrators" @@ -278,6 +281,72 @@ auth: A user can link their account to multiple providers. The same Codex account is used as long as the email address matches. +## API Bearer Tokens (Resource Server) + +Besides web sign-in, Codex accepts access tokens issued by your configured providers as API credentials: + +```bash +curl -H "Authorization: Bearer $IDP_ACCESS_TOKEN" https://codex.example.com/api/v1/auth/me +``` + +This makes Codex an OAuth2 resource server: applications that already hold a user's IdP token (a reverse proxy doing forward-auth, an MCP service, another app in your SSO ecosystem) can call the Codex API as that user without managing Codex API keys. + +### How Validation Works + +1. The token's signing algorithm is checked. Only asymmetric algorithms (RS256, ES256) are accepted on this path; Codex's own session tokens are HS256 and verified separately, so the two can never be confused. +2. The token's `iss` claim selects the matching configured provider (trailing slashes are tolerated). +3. The signature is verified against the provider's published JWKS, fetched via OIDC discovery and cached. Key rotation at the IdP is picked up automatically, no restart needed. +4. The `aud` claim must match one of the provider's `accepted_audiences`, and `exp`/`nbf` are enforced with a small clock-skew allowance. Tokens without `aud` or `exp` are rejected. +5. The token's `sub` is resolved to the Codex user who linked that identity. + +The feature needs no extra configuration: it is active whenever `auth.oidc.enabled` is `true` with at least one provider. With OIDC disabled, bearer authentication behaves exactly as before. + +### Linking Requirement + +There is **no auto-provisioning from API tokens**: the user must have signed into Codex web via SSO at least once so that the identity link exists. A valid token for an unlinked identity gets a `401` asking the user to sign in via SSO once. This is deliberate: a valid IdP token proves who the caller is at the IdP, but it should not silently create Codex accounts for everyone in your organization. + +Two related limitations: + +- Role/group sync happens only at web login. A bearer token's `groups` claim is ignored; role changes at the IdP take effect on the user's next web sign-in. +- Tokens are accepted from configured providers only. There is no way to accept tokens from an issuer you have not configured. + +### Audience Rules + +OAuth2 access tokens carry an `aud` (audience) claim naming the client they were issued to. By default Codex only accepts tokens whose audience is the provider's own `client_id`, which covers tokens obtained through Codex's sign-in flow. + +If another application obtains tokens under its own client ID and forwards them to Codex, add that client ID to `accepted_audiences`: + +```yaml +auth: + oidc: + enabled: true + providers: + authentik: + issuer_url: "https://authentik.example.com/application/o/codex/" + client_id: "codex-client-id" + accepted_audiences: + - "codex-client-id" # keep accepting Codex's own tokens + - "shared-apps-client-id" # tokens minted for a trusted app +``` + +Only list clients you trust to act on behalf of their users: accepting an audience means any valid token minted for that client authenticates against Codex. + +### Troubleshooting + +Rejected tokens return a generic `401 Invalid bearer token`; the precise reason is logged server-side (debug level) and never echoed to the caller. Look for `Rejected IdP bearer token` in the logs: + +| Logged reason | Likely cause | Fix | +|---------------|--------------|-----| +| `unsupported signing algorithm` | Token is HS256 or another symmetric/unsupported algorithm | Configure the IdP to sign access tokens with RS256 or ES256 | +| `token issuer does not match any configured provider` | The token's `iss` is not a configured `issuer_url` | Add the provider, or fix `issuer_url` (compare with the token's `iss` claim) | +| `wrong audience` or `token missing required claim` for `aud` | Token minted for a client not in `accepted_audiences`, or the IdP omits `aud` | Add the requesting app's client ID to `accepted_audiences`; ensure the IdP stamps an audience | +| `token expired` | The access token's `exp` has passed | Obtain a fresh token; check for clock skew beyond ~30s | +| `no JWKS key matches the token's key id` | Token signed with a key the IdP no longer publishes | Re-issue the token; verify the issuer's `jwks_uri` is reachable from Codex | +| `signature verification failed` | Token tampered with, or signed by a different issuer's key | Verify the token actually comes from the configured provider | +| `No Codex account is linked to this identity` (response message) | The user never signed into Codex web via SSO | Sign into the Codex web UI through the provider once | + +If the IdP itself is unreachable (discovery or JWKS fetch fails), Codex returns `503 Identity provider is unreachable` rather than `401`, and logs `IdP bearer validation failed on the IdP side` at warn level. + ## Security ### PKCE diff --git a/src/commands/serve.rs b/src/commands/serve.rs index a717ce76..26ba2a52 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -360,6 +360,25 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { None }; + // IdP bearer validation (resource-server path): accept provider-issued + // access tokens on the API, gated on the same OIDC config as web SSO. + let idp_bearer = if config.auth.oidc.enabled && !config.auth.oidc.providers.is_empty() { + info!("Initializing IdP bearer token validation..."); + for (name, provider_config) in &config.auth.oidc.providers { + let audiences = if provider_config.accepted_audiences.is_empty() { + format!("{} (client_id default)", provider_config.client_id) + } else { + provider_config.accepted_audiences.join(", ") + }; + info!(" - {}: accepted audiences: {}", name, audiences); + } + Some(Arc::new(codex_api::extractors::IdpBearerAuth::new( + &config.auth.oidc, + ))) + } else { + None + }; + // Initialize plugin metrics service info!("Initializing plugin metrics service..."); let plugin_metrics_service = Arc::new(codex_services::PluginMetricsService::new()); @@ -535,6 +554,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { plugin_manager: plugin_manager.clone(), plugin_metrics_service, oidc_service, + idp_bearer, oauth_state_manager: oauth_state_manager.clone(), export_storage: Some(export_storage.clone()), plugin_file_storage: Some(plugin_file_storage), diff --git a/tests/api/idp_bearer.rs b/tests/api/idp_bearer.rs new file mode 100644 index 00000000..5edb8cb7 --- /dev/null +++ b/tests/api/idp_bearer.rs @@ -0,0 +1,414 @@ +//! IdP bearer token API integration tests. +//! +//! End-to-end coverage of the resource-server path: provider-issued +//! RS256 tokens on `Authorization: Bearer`, resolved to local users via +//! `oidc_connections`. The IdP is a local axum stub serving a discovery +//! document and a JWKS built from the static RSA test keys shared with +//! the codex-services suite. + +#[path = "../common/mod.rs"] +mod common; + +use axum::routing::get; +use axum::{Json, Router}; +use codex::api::extractors::{AppState, IdpBearerAuth}; +use codex::config::{OidcConfig, OidcDefaultRole, OidcProviderConfig}; +use codex::db::entities::{oidc_connections, users}; +use codex::db::repositories::{OidcConnectionRepository, UserRepository}; +use common::*; +use hyper::StatusCode; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use sea_orm::DatabaseConnection; +use std::collections::HashMap; +use std::sync::Arc; + +fn fixture_path(name: &str) -> String { + format!( + "{}/crates/codex-services/tests/fixtures/idp_bearer/{name}", + env!("CARGO_MANIFEST_DIR") + ) +} + +/// Start a local IdP stub serving discovery + a static JWKS; returns its URL. +async fn start_idp_stub() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let url = format!("http://{}", listener.local_addr().unwrap()); + + let issuer = url.clone(); + let jwks: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fixture_path("jwks_key_a.json")).expect("jwks fixture"), + ) + .expect("jwks fixture parses"); + + let app = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || { + let issuer = issuer.clone(); + async move { + Json(serde_json::json!({ + "issuer": issuer, + "jwks_uri": format!("{issuer}/jwks"), + })) + } + }), + ) + .route("/jwks", get(move || async move { Json(jwks.clone()) })); + + tokio::spawn(async move { + axum::serve(listener, app).await.expect("stub IdP serves"); + }); + + url +} + +/// Sign an RS256 token with one of the fixture keys. +fn sign_idp_token(kid: &str, key: &str, claims: &serde_json::Value) -> String { + let pem = std::fs::read(fixture_path(&format!("{key}.pem"))).expect("test key fixture"); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_owned()); + encode( + &header, + claims, + &EncodingKey::from_rsa_pem(&pem).expect("test key parses"), + ) + .expect("token signs") +} + +fn idp_claims(iss: &str, sub: &str, exp_offset_secs: i64) -> serde_json::Value { + serde_json::json!({ + "iss": iss, + "aud": "codex-client", + "exp": chrono::Utc::now().timestamp() + exp_offset_secs, + "sub": sub, + }) +} + +fn oidc_config_for(idp_url: &str) -> OidcConfig { + OidcConfig { + enabled: true, + auto_create_users: false, + default_role: OidcDefaultRole::Reader, + redirect_uri_base: None, + providers: HashMap::from([( + "authentik".to_string(), + OidcProviderConfig { + display_name: "Authentik".to_string(), + issuer_url: idp_url.to_string(), + client_id: "codex-client".to_string(), + client_secret: None, + client_secret_env: None, + scopes: vec![], + role_mapping: HashMap::new(), + groups_claim: "groups".to_string(), + username_claim: "preferred_username".to_string(), + email_claim: "email".to_string(), + accepted_audiences: vec![], + }, + )]), + } +} + +/// AppState with the IdP bearer path configured against the stub. +async fn create_state_with_idp_bearer(db: DatabaseConnection, idp_url: &str) -> Arc { + let base = create_test_app_state(db).await; + Arc::new(AppState { + idp_bearer: Some(Arc::new(IdpBearerAuth::new(&oidc_config_for(idp_url)))), + ..(*base).clone() + }) +} + +/// Seed a user and link it to the authentik identity `subject`. +async fn seed_linked_user(db: &DatabaseConnection, subject: &str, admin: bool) -> users::Model { + let user = create_test_user( + &format!("idp-{subject}"), + &format!("{subject}@example.com"), + "oidc:placeholder", + admin, + ); + UserRepository::create(db, &user).await.unwrap(); + + let connection = oidc_connections::Model { + id: uuid::Uuid::new_v4(), + user_id: user.id, + provider_name: "authentik".to_string(), + subject: subject.to_string(), + email: Some(user.email.clone()), + display_name: None, + groups: None, + access_token_hash: None, + refresh_token_encrypted: None, + token_expires_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + last_used_at: None, + }; + OidcConnectionRepository::create(db, &connection) + .await + .unwrap(); + + user +} + +#[tokio::test] +async fn valid_idp_token_with_linked_connection_resolves_on_me() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + let user = seed_linked_user(&db, "sub-linked", false).await; + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let app = create_test_router_with_app_state(state); + + let token = sign_idp_token("key-a", "key_a", &idp_claims(&idp_url, "sub-linked", 3600)); + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert_eq!(body["username"], user.username.as_str()); + assert_eq!(body["id"], user.id.to_string().as_str()); +} + +#[tokio::test] +async fn valid_idp_token_works_on_authcontext_endpoints() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + // /api/v1/api-keys takes the strict `AuthContext` extractor + // (versus `/me`'s FlexibleAuthContext), covering the second dispatch site. + seed_linked_user(&db, "sub-admin", true).await; + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let app = create_test_router_with_app_state(state); + + let token = sign_idp_token("key-a", "key_a", &idp_claims(&idp_url, "sub-admin", 3600)); + let request = get_request_with_auth("/api/v1/api-keys", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::OK, "body: {body}"); +} + +#[tokio::test] +async fn unlinked_identity_gets_the_link_account_401() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + // No user, no connection: the token is valid but unknown to Codex. + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let app = create_test_router_with_app_state(state); + + let token = sign_idp_token( + "key-a", + "key_a", + &idp_claims(&idp_url, "sub-stranger", 3600), + ); + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + let message = body["message"].as_str().unwrap_or_default(); + assert!( + message.contains("sign in to Codex via SSO"), + "the 401 must tell the user how to link, got: {message}" + ); +} + +#[tokio::test] +async fn tampered_idp_tokens_get_a_uniform_401() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + seed_linked_user(&db, "sub-linked", false).await; + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let app = create_test_router_with_app_state(state); + + let mut wrong_aud = idp_claims(&idp_url, "sub-linked", 3600); + wrong_aud["aud"] = serde_json::json!("not-codex"); + + let cases = [ + ( + "wrong issuer", + sign_idp_token( + "key-a", + "key_a", + &idp_claims("https://evil.example.com", "sub-linked", 3600), + ), + ), + ( + "wrong audience", + sign_idp_token("key-a", "key_a", &wrong_aud), + ), + ( + "expired", + sign_idp_token("key-a", "key_a", &idp_claims(&idp_url, "sub-linked", -3600)), + ), + ( + // Signed by key B but claiming key A's kid. + "bad signature", + sign_idp_token("key-a", "key_b", &idp_claims(&idp_url, "sub-linked", 3600)), + ), + ]; + + for (case, token) in cases { + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app.clone(), request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::UNAUTHORIZED, "case {case}: {body}"); + let message = body["message"].as_str().unwrap_or_default(); + assert_eq!( + message, "Invalid bearer token", + "case {case}: the response must not leak the validation internals" + ); + } +} + +#[tokio::test] +async fn rs256_token_without_validator_keeps_todays_behavior() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + seed_linked_user(&db, "sub-linked", false).await; + + // OIDC disabled: AppState carries no validator, so the RS256 token + // falls through to the local JWT path and fails exactly like today. + let state = create_test_app_state(db).await; + let app = create_test_router_with_app_state(state); + + let token = sign_idp_token("key-a", "key_a", &idp_claims(&idp_url, "sub-linked", 3600)); + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + let message = body["message"].as_str().unwrap_or_default(); + assert!( + message.contains("Invalid JWT token"), + "expected the pre-existing local-JWT error, got: {message}" + ); +} + +#[tokio::test] +async fn hs256_session_jwt_still_works_with_validator_configured() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + + let user = create_test_user("local-user", "local@example.com", "hash", false); + UserRepository::create(&db, &user).await.unwrap(); + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let token = generate_test_token(&state, &user); + let app = create_test_router_with_app_state(state); + + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert_eq!(body["username"], "local-user"); +} + +#[tokio::test] +async fn basic_auth_still_works_with_validator_configured() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + + let password_hash = codex::utils::password::hash_password("hunter2").unwrap(); + let user = create_test_user("basic-user", "basic@example.com", &password_hash, false); + UserRepository::create(&db, &user).await.unwrap(); + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let app = create_test_router_with_app_state(state); + + let request = get_request_with_basic_auth("/api/v1/auth/me", "basic-user", "hunter2"); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::OK, "body: {body}"); + assert_eq!(body["username"], "basic-user"); +} + +#[tokio::test] +async fn inactive_linked_user_is_rejected_like_the_local_path() { + let idp_url = start_idp_stub().await; + let (db, _temp_dir) = setup_test_db().await; + + let mut user = create_test_user( + "idp-inactive", + "inactive@example.com", + "oidc:placeholder", + false, + ); + user.is_active = false; + UserRepository::create(&db, &user).await.unwrap(); + + let connection = oidc_connections::Model { + id: uuid::Uuid::new_v4(), + user_id: user.id, + provider_name: "authentik".to_string(), + subject: "sub-inactive".to_string(), + email: Some(user.email.clone()), + display_name: None, + groups: None, + access_token_hash: None, + refresh_token_encrypted: None, + token_expires_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + last_used_at: None, + }; + OidcConnectionRepository::create(&db, &connection) + .await + .unwrap(); + + let state = create_state_with_idp_bearer(db, &idp_url).await; + let app = create_test_router_with_app_state(state); + + let token = sign_idp_token( + "key-a", + "key_a", + &idp_claims(&idp_url, "sub-inactive", 3600), + ); + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + let body = body.unwrap_or_default(); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + let message = body["message"].as_str().unwrap_or_default(); + assert!( + message.contains("inactive"), + "inactive users must be rejected identically to the local path, got: {message}" + ); +} + +#[tokio::test] +async fn unreachable_idp_yields_503_not_401() { + let (db, _temp_dir) = setup_test_db().await; + seed_linked_user(&db, "sub-linked", false).await; + + // Validator configured against a dead port: the token cannot be + // checked, which is the IdP's failure, not the caller's. + let state = create_state_with_idp_bearer(db, "http://127.0.0.1:1").await; + let app = create_test_router_with_app_state(state); + + let token = sign_idp_token( + "key-a", + "key_a", + &idp_claims("http://127.0.0.1:1", "sub-linked", 3600), + ); + let request = get_request_with_auth("/api/v1/auth/me", &token); + let (status, _body): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); +} diff --git a/tests/api/mod.rs b/tests/api/mod.rs index 7ed91284..ece3a03b 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -20,6 +20,7 @@ mod external_ratings; mod filesystem; mod filter_presets; mod genres; +mod idp_bearer; mod info; mod komga; mod koreader; diff --git a/tests/api/oidc.rs b/tests/api/oidc.rs index aa0dbb6f..4100a71a 100644 --- a/tests/api/oidc.rs +++ b/tests/api/oidc.rs @@ -102,6 +102,7 @@ async fn create_test_state_with_oidc( plugin_manager, plugin_metrics_service, oidc_service, + idp_bearer: None, oauth_state_manager: Arc::new(codex::services::user_plugin::OAuthStateManager::new()), export_storage: None, plugin_file_storage: None, @@ -132,6 +133,7 @@ fn create_test_oidc_config() -> OidcConfig { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); @@ -357,6 +359,7 @@ async fn test_list_multiple_providers() { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); @@ -373,6 +376,7 @@ async fn test_list_multiple_providers() { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }, ); @@ -627,6 +631,7 @@ fn test_oidc_role_mapping_empty_role_mapping() { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; oidc_config .providers @@ -965,6 +970,7 @@ fn test_oidc_provider_config_serde() { groups_claim: "groups".to_string(), username_claim: "preferred_username".to_string(), email_claim: "email".to_string(), + accepted_audiences: vec![], }; let json = serde_json::to_string(&config).unwrap(); diff --git a/tests/api/pdf_cache.rs b/tests/api/pdf_cache.rs index d1ba70d2..167c9b06 100644 --- a/tests/api/pdf_cache.rs +++ b/tests/api/pdf_cache.rs @@ -101,6 +101,7 @@ async fn create_test_app_state_with_pdf_cache( plugin_manager, plugin_metrics_service, oidc_service: None, + idp_bearer: None, oauth_state_manager: Arc::new(codex::services::user_plugin::OAuthStateManager::new()), export_storage: None, plugin_file_storage: None, diff --git a/tests/api/rate_limit.rs b/tests/api/rate_limit.rs index bf35166f..6387aad0 100644 --- a/tests/api/rate_limit.rs +++ b/tests/api/rate_limit.rs @@ -134,6 +134,7 @@ async fn create_rate_limited_app_state( plugin_manager, plugin_metrics_service, oidc_service: None, + idp_bearer: None, oauth_state_manager: Arc::new(codex::services::user_plugin::OAuthStateManager::new()), export_storage: None, plugin_file_storage: None, diff --git a/tests/api/refresh_token.rs b/tests/api/refresh_token.rs index f6918a9e..71a1db57 100644 --- a/tests/api/refresh_token.rs +++ b/tests/api/refresh_token.rs @@ -98,6 +98,7 @@ async fn build_state(db: DatabaseConnection, refresh_enabled: bool) -> Arc Arc Arc { plugin_manager, plugin_metrics_service, oidc_service: None, // Tests disable OIDC by default + idp_bearer: None, oauth_state_manager: Arc::new(codex::services::user_plugin::OAuthStateManager::new()), export_storage: None, plugin_file_storage: None, @@ -146,6 +147,7 @@ pub async fn create_test_app_state(db: DatabaseConnection) -> Arc { plugin_manager, plugin_metrics_service, oidc_service: None, // Tests disable OIDC by default + idp_bearer: None, oauth_state_manager: Arc::new(codex::services::user_plugin::OAuthStateManager::new()), export_storage: None, plugin_file_storage: None, @@ -238,6 +240,7 @@ pub async fn create_test_router(state: Arc) -> Router { plugin_manager, plugin_metrics_service, oidc_service: None, // Tests disable OIDC by default + idp_bearer: None, oauth_state_manager: Arc::new(codex::services::user_plugin::OAuthStateManager::new()), export_storage: None, plugin_file_storage: None,