From ea63077ebabbd63059ae28eb7077a6ba8307d758 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Thu, 11 Jun 2026 16:23:06 -0700 Subject: [PATCH 1/3] feat(auth): add IdP bearer token validator and per-provider accepted audiences Groundwork for accepting OIDC provider-issued access tokens as API bearer credentials (resource-server support), so downstream apps like Shisho can forward their users' IdP tokens to Codex. - Add `accepted_audiences` to OidcProviderConfig: audiences accepted on API bearer tokens from a provider, defaulting to the provider's own client_id. Configurable via YAML or the ..._ACCEPTED_AUDIENCES env override (comma-separated). - New codex_services::idp_bearer::IdpBearerValidator: validates RS256/ ES256 bearer JWTs against the matching provider's JWKS, selected by an unverified `iss` peek and re-verified during validation. Enforces issuer (trailing-slash tolerant), audience against the accepted list, and exp/nbf with 30s clock-skew leeway. `iss`/`aud` are required claims so tokens missing them fail closed. - HS256 and alg=none are rejected outright on this path; symmetric algorithms stay reserved for Codex's local session JWTs, closing the key-confusion attack. - Per-provider JWKS cache: lazy fetch from the discovery document, 1h refresh, stale-serve on refresh failure, and a rate-limited refetch on unknown `kid` so key rotation works without a restart. HTTP client has redirects disabled and a 10s timeout. - Error enum distinguishes every rejection cause for precise auth logs (never echoing token material) and separates IdP outages from token rejections. Validation is not wired into the auth extractor yet; this is the self-contained service layer. Unit and integration tests added, the latter against a local axum IdP stub with static RSA test fixtures. --- Cargo.lock | 2 + crates/codex-config/src/env_override.rs | 25 + crates/codex-config/src/types.rs | 47 ++ crates/codex-services/Cargo.toml | 4 + crates/codex-services/src/idp_bearer.rs | 628 ++++++++++++++++++ crates/codex-services/src/lib.rs | 2 + crates/codex-services/src/oidc.rs | 6 + .../tests/fixtures/idp_bearer/jwks_key_a.json | 12 + .../fixtures/idp_bearer/jwks_keys_a_b.json | 20 + .../tests/fixtures/idp_bearer/key_a.pem | 28 + .../tests/fixtures/idp_bearer/key_b.pem | 28 + crates/codex-services/tests/idp_bearer.rs | 365 ++++++++++ tests/api/oidc.rs | 5 + 13 files changed, 1172 insertions(+) create mode 100644 crates/codex-services/src/idp_bearer.rs create mode 100644 crates/codex-services/tests/fixtures/idp_bearer/jwks_key_a.json create mode 100644 crates/codex-services/tests/fixtures/idp_bearer/jwks_keys_a_b.json create mode 100644 crates/codex-services/tests/fixtures/idp_bearer/key_a.pem create mode 100644 crates/codex-services/tests/fixtures/idp_bearer/key_b.pem create mode 100644 crates/codex-services/tests/idp_bearer.rs diff --git a/Cargo.lock b/Cargo.lock index 963b95f3..e3ce9121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,7 @@ name = "codex-services" version = "1.38.1" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "chrono", "codex-config", @@ -1115,6 +1116,7 @@ dependencies = [ "futures", "handlebars", "image", + "jsonwebtoken", "jxl-oxide", "lettre", "lru", 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..fea270f6 --- /dev/null +++ b/crates/codex-services/src/idp_bearer.rs @@ -0,0 +1,628 @@ +//! 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) + } +} + +/// 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 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/tests/api/oidc.rs b/tests/api/oidc.rs index aa0dbb6f..a5ad38c1 100644 --- a/tests/api/oidc.rs +++ b/tests/api/oidc.rs @@ -132,6 +132,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 +358,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 +375,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 +630,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 +969,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(); From 47b713ad7968a5ca95bbdf506b8987cea83f7f83 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Thu, 11 Jun 2026 17:00:36 -0700 Subject: [PATCH 2/3] feat(auth): accept IdP bearer tokens on the API and resolve linked users Wire the IdP bearer validator into the auth extractor, making Codex a proper OAuth2 resource server: provider-issued RS256/ES256 access tokens now authenticate API requests for users who have linked that identity via web SSO. This unblocks downstream services (e.g. Shisho's passthrough strategy) that forward their callers' IdP tokens to Codex. - Route bearer tokens by JWT header algorithm in extract_from_jwt: HS256 session tokens keep the existing local-secret path verbatim; RS256/ES256 divert to the IdP validator only when one is configured. Without a validator (OIDC disabled or no providers), behavior is unchanged, including error messages. - Resolve validated (provider, subject) identities through oidc_connections; no auto-provisioning. An unlinked identity gets a 401 telling the user to sign into Codex via SSO once. Inactive users are rejected identically to the local path. - Add AppState.idp_bearer (IdpBearerAuth): the validator bundled with a TTL'd (provider, subject) -> user_id cache so hot endpoints don't hit the connection table per request; user data freshness stays governed by the existing user auth cache, which both paths now share through a common resolve_user_context helper. - Add AuthMethod::OidcBearer for audit visibility. - Token rejections return a uniform 401 with the precise cause logged (never echoing token material); IdP-side failures (discovery/JWKS unreachable) return 503 instead of 401 so outages don't read as bad credentials. Covers both AuthContext and FlexibleAuthContext extractors. Unit tests for the subject cache and dispatch helper, plus API integration tests against a local IdP stub (linked/unlinked/tampered/inactive/disabled and HS256/Basic-auth regression with the validator configured). --- Cargo.lock | 1 + Cargo.toml | 3 + crates/codex-api/src/extractors/auth.rs | 224 ++++++++++++- crates/codex-api/src/extractors/mod.rs | 2 +- crates/codex-services/src/idp_bearer.rs | 27 ++ src/commands/serve.rs | 20 ++ tests/api/idp_bearer.rs | 414 ++++++++++++++++++++++++ tests/api/mod.rs | 1 + tests/api/oidc.rs | 1 + tests/api/pdf_cache.rs | 1 + tests/api/rate_limit.rs | 1 + tests/api/refresh_token.rs | 1 + tests/api/task_metrics.rs | 1 + tests/common/http.rs | 3 + 14 files changed, 696 insertions(+), 4 deletions(-) create mode 100644 tests/api/idp_bearer.rs diff --git a/Cargo.lock b/Cargo.lock index e3ce9121..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", 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/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-services/src/idp_bearer.rs b/crates/codex-services/src/idp_bearer.rs index fea270f6..7fde9df5 100644 --- a/crates/codex-services/src/idp_bearer.rs +++ b/crates/codex-services/src/idp_bearer.rs @@ -407,6 +407,16 @@ impl IdpBearerValidator { } } +/// 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. @@ -612,6 +622,23 @@ mod tests { )); } + #[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!( 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 a5ad38c1..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, 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, From 71e4802c803e4f335cbc64a492e7a23140293bd7 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Thu, 11 Jun 2026 17:17:21 -0700 Subject: [PATCH 3/3] docs(auth): document IdP bearer tokens and accepted_audiences config Operator-facing documentation for the resource-server support added in the previous commits, so the feature is configurable without reading source. - New "API Bearer Tokens (Resource Server)" section in the OIDC docs: how validation works (algorithm allowlist, issuer-based provider selection, JWKS verification with automatic key-rotation pickup, audience and expiry enforcement), the sign-in-via-SSO-once linking requirement and why there is no auto-provisioning, and audience rules with a worked accepted_audiences example. - Troubleshooting table mapping each server-logged rejection reason to its likely cause and fix, including the 503 IdP-unreachable case. - accepted_audiences added to the provider settings reference and the environment variable override examples. - Commented accepted_audiences examples in the shipped sqlite, docker, and kubernetes config templates. --- config/config.docker.yaml | 6 +++ config/config.kubernetes.yaml | 6 +++ config/config.sqlite.yaml | 6 +++ docs/docs/users/oidc.md | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) 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/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