From 4ce266e5fc99cab4ff0b1baaae7ff169f3933cdd Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Tue, 28 Jul 2026 06:44:51 -0400 Subject: [PATCH 01/13] feat: add optional OIDC bearer authentication Signed-off-by: Francisco Javier Arceo --- Cargo.lock | 222 ++++- Cargo.toml | 1 + README.md | 36 + crates/agentic-server/Cargo.toml | 4 + crates/agentic-server/src/app.rs | 33 +- crates/agentic-server/src/auth.rs | 837 ++++++++++++++++ crates/agentic-server/src/handler/mod.rs | 1 + .../src/handler/websocket/error.rs | 5 + .../src/handler/websocket/mod.rs | 1 + .../src/handler/websocket/responses.rs | 57 +- crates/agentic-server/src/lib.rs | 1 + crates/agentic-server/src/main.rs | 48 +- crates/agentic-server/src/server.rs | 91 +- crates/agentic-server/tests/oidc_auth_test.rs | 897 ++++++++++++++++++ docs/api/index.md | 40 + docs/deploying/container.md | 22 +- docs/design/oidc-bearer-authentication.md | 67 ++ 17 files changed, 2315 insertions(+), 48 deletions(-) create mode 100644 crates/agentic-server/src/auth.rs create mode 100644 crates/agentic-server/tests/oidc_auth_test.rs create mode 100644 docs/design/oidc-bearer-authentication.md diff --git a/Cargo.lock b/Cargo.lock index 7a7d66e6..fbfcbd32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,10 @@ dependencies = [ "either", "futures", "http", + "jsonwebtoken", + "rand 0.8.6", "reqwest 0.12.28", + "rsa", "serde", "serde_json", "thiserror", @@ -31,6 +34,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -280,6 +284,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -597,6 +607,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -607,6 +629,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -659,6 +708,44 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -668,6 +755,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -712,6 +820,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -872,6 +996,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -914,6 +1039,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -1371,6 +1507,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1657,6 +1814,30 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1796,6 +1977,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2106,6 +2296,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2188,7 +2388,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2246,7 +2446,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2303,6 +2503,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2813,10 +3027,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 82f3388e..b09fc8f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ criterion = { version = "0.5", features = ["async_tokio"] } futures = "0.3" indexmap = "2" http = "1" +jsonwebtoken = { version = "=10.3.0", default-features = false, features = ["rust_crypto"] } reqwest = { version = "0.12", default-features = false } rmcp-reqwest = { package = "reqwest", version = "0.13.2", default-features = false, features = ["json", "stream", "rustls"] } rmcp = { version = "1.8", default-features = false } diff --git a/README.md b/README.md index e5bd27b8..e3baf2b0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,28 @@ Then launch Codex: codex --disable image_generation -c model_provider=agentic-api -m Qwen/Qwen3-30B-A3B-FP8 ``` +If the gateway enables OIDC, configure Codex's supported command-backed bearer authentication instead of +`requires_openai_auth = false`: + +```toml +[model_providers.agentic-api] +name = "agentic-api" +base_url = "http://localhost:9000/v1" +wire_api = "responses" +supports_websockets = true + +[model_providers.agentic-api.auth] +command = "/absolute/path/to/print-oidc-token" +args = ["--audience", "agentic-api"] +refresh_interval_ms = 300000 +``` + +The command must print only a current OIDC token to stdout. Codex refreshes it before expiry and sends it as the +provider bearer token. See the +[Codex custom-provider authentication reference](https://developers.openai.com/codex/config-advanced#custom-model-providers). +Keep the inference credential in the gateway's `OPENAI_API_KEY`; do not print that service credential from the token +command. + ## 🧑‍💻 Claude Code on your own GPUs Agentic API serves the Anthropic Messages protocol at `/v1/messages`, so Claude Code (CLI or Agent SDK) runs against open models. Point it at the gateway: @@ -136,6 +158,20 @@ export ANTHROPIC_MODEL="Qwen/Qwen3-30B-A3B-FP8" # match the served model claude -p "summarize the files in this directory" ``` +With OIDC enabled, use Claude Code's bearer-token variable and leave its API-key variable unset so the identity token +is not also sent as an upstream `x-api-key`: + +```bash +export ANTHROPIC_BASE_URL="http://localhost:9000" +export ANTHROPIC_AUTH_TOKEN="$(/absolute/path/to/print-oidc-token --audience agentic-api)" +unset ANTHROPIC_API_KEY + +claude -p "summarize the files in this directory" +``` + +Refresh `ANTHROPIC_AUTH_TOKEN` before it expires. For supported dynamic credential helpers, see Anthropic's +[LLM gateway authentication guide](https://docs.anthropic.com/en/docs/claude-code/llm-gateway). + Claude Code's own tools (Bash, Edit, Read, …) stay **client-owned** — Claude Code runs them, as usual. ### Running Claude Code's web search on the gateway diff --git a/crates/agentic-server/Cargo.toml b/crates/agentic-server/Cargo.toml index 2d0f3564..62cfd1dc 100644 --- a/crates/agentic-server/Cargo.toml +++ b/crates/agentic-server/Cargo.toml @@ -14,6 +14,7 @@ clap.workspace = true either.workspace = true futures.workspace = true http.workspace = true +jsonwebtoken.workspace = true reqwest = { workspace = true, default-features = false, features = ["rustls-tls"] } serde.workspace = true serde_json.workspace = true @@ -23,12 +24,15 @@ tokio-util.workspace = true tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +url.workspace = true [dev-dependencies] bytes.workspace = true criterion.workspace = true futures.workspace = true reqwest = { workspace = true, features = ["json"] } +rand = "0.8" +rsa = "0.9" serde_json.workspace = true tokio = { workspace = true, features = ["test-util"] } tokio-tungstenite.workspace = true diff --git a/crates/agentic-server/src/app.rs b/crates/agentic-server/src/app.rs index 1c62a10f..83a0273c 100644 --- a/crates/agentic-server/src/app.rs +++ b/crates/agentic-server/src/app.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use axum::Router; +use axum::middleware; use axum::routing::{get, post}; use http::HeaderValue; use tokio::sync::Notify; @@ -11,8 +12,9 @@ use tower_http::cors::{AllowOrigin, Any, CorsLayer}; use agentic_core::executor::ExecutionContext; use agentic_core::proxy::ProxyState; +use crate::auth::{ANTHROPIC_COUNT_TOKENS_PATH, ANTHROPIC_MESSAGES_PATH, OidcAuthenticator, require_oidc}; use crate::handler::{ - compact_response, conversations, count_tokens, health, messages, models, ready, responses, responses_ws, + compact_response, conversations, count_tokens, health, messages, models, ready, responses, responses_ws_with_auth, }; #[derive(Clone, Default)] @@ -121,15 +123,30 @@ pub struct AppState { } pub fn build_router(state: AppState, server_config: &ServerConfig) -> Router { - Router::new() - .route("/health", get(health)) - .route("/ready", get(ready)) + build_router_with_auth(state, server_config, None) +} + +pub fn build_router_with_auth( + state: AppState, + server_config: &ServerConfig, + authenticator: Option, +) -> Router { + let public_routes = Router::new().route("/health", get(health)).route("/ready", get(ready)); + let protected_routes = Router::new() .route("/v1/conversations", post(conversations)) .route("/v1/models", get(models)) - .route("/v1/messages", post(messages)) - .route("/v1/messages/count_tokens", post(count_tokens)) - .route("/v1/responses", post(responses).get(responses_ws)) - .route("/v1/responses/compact", post(compact_response)) + .route(ANTHROPIC_MESSAGES_PATH, post(messages)) + .route(ANTHROPIC_COUNT_TOKENS_PATH, post(count_tokens)) + .route("/v1/responses", post(responses).get(responses_ws_with_auth)); + let protected_routes = match authenticator { + Some(authenticator) => { + protected_routes.route_layer(middleware::from_fn_with_state(authenticator, require_oidc)) + } + None => protected_routes, + }; + + public_routes + .merge(protected_routes) .layer(server_config.cors_layer()) .with_state(state) } diff --git a/crates/agentic-server/src/auth.rs b/crates/agentic-server/src/auth.rs new file mode 100644 index 00000000..48366d34 --- /dev/null +++ b/crates/agentic-server/src/auth.rs @@ -0,0 +1,837 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, Request, StatusCode, header}; +use axum::middleware::Next; +use axum::response::Response; +use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde::Deserialize; +use serde_json::json; +use tokio::sync::RwLock; +use tracing::{debug, warn}; +use url::{Host, Url}; + +const OIDC_HTTP_TIMEOUT: Duration = Duration::from_secs(10); +const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(30); +const JWKS_REFRESH_COALESCE_WINDOW: Duration = Duration::from_secs(1); +const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(300); +const MAX_JWKS_TTL: Duration = Duration::from_secs(3600); +const MAX_PROVIDER_RESPONSE_BYTES: usize = 1024 * 1024; +const MAX_JWKS_KEYS: usize = 100; +const JWT_CLOCK_SKEW_SECONDS: u64 = 60; + +pub(crate) const ANTHROPIC_MESSAGES_PATH: &str = "/v1/messages"; +pub(crate) const ANTHROPIC_COUNT_TOKENS_PATH: &str = "/v1/messages/count_tokens"; + +#[derive(Clone)] +pub struct OidcConfig { + issuer: Url, + issuer_value: String, + audience: String, + allows_loopback_http: bool, +} + +impl OidcConfig { + /// Create an OIDC bearer-token configuration. + /// + /// # Errors + /// + /// Returns an error when the issuer is not an absolute HTTPS URL (except + /// for loopback test/development issuers) or the audience is empty. + pub fn new(issuer: &str, audience: &str) -> Result { + let issuer_value = issuer.trim().to_owned(); + let issuer = Url::parse(&issuer_value).map_err(OidcAuthError::InvalidIssuer)?; + let allows_loopback_http = is_loopback_http(&issuer); + if issuer.scheme() != "https" && !allows_loopback_http { + return Err(OidcAuthError::InsecureIssuer); + } + if issuer.query().is_some() || issuer.fragment().is_some() { + return Err(OidcAuthError::InvalidIssuerComponents); + } + + let audience = audience.trim().to_owned(); + if audience.is_empty() { + return Err(OidcAuthError::EmptyAudience); + } + + Ok(Self { + issuer, + issuer_value, + audience, + allows_loopback_http, + }) + } + + fn discovery_url(&self) -> Result { + Url::parse(&format!( + "{}/.well-known/openid-configuration", + self.issuer.as_str().trim_end_matches('/') + )) + .map_err(OidcAuthError::InvalidIssuer) + } +} + +fn is_loopback_http(url: &Url) -> bool { + url.scheme() == "http" + && match url.host() { + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + Some(Host::Domain(_)) | None => false, + } +} + +struct CachedKey { + decoding_key: DecodingKey, + algorithm: Option, +} + +struct CachedJwks { + keys: HashMap>, + expires_at: Instant, +} + +struct RefreshState { + last_completed: Instant, + retry_after: Option, + coalesce_until: Option, +} + +#[derive(Clone)] +pub struct OidcAuthenticator { + audience: String, + jwks_uri: Url, + keys: Arc>, + refresh_state: Arc>, + refresh_gate: Arc, + validations: Arc>, + client: reqwest::Client, +} + +impl OidcAuthenticator { + /// Discover an OIDC provider and cache its initial verification keys. + /// + /// # Errors + /// + /// Returns an error when provider discovery or the JSON Web Key Set + /// (JWKS) request fails, or when the discovered metadata is inconsistent. + pub async fn discover(config: OidcConfig) -> Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(OIDC_HTTP_TIMEOUT) + .build() + .map_err(OidcAuthError::HttpClient)?; + let (metadata, _) = + fetch_json::(&client, config.discovery_url()?, ProviderRequest::Metadata).await?; + let discovered_issuer = Url::parse(&metadata.issuer).map_err(OidcAuthError::InvalidDiscoveredIssuer)?; + if discovered_issuer != config.issuer { + return Err(OidcAuthError::IssuerMismatch { + expected: config.issuer_value, + discovered: metadata.issuer, + }); + } + + let jwks_uri = Url::parse(&metadata.jwks_uri).map_err(OidcAuthError::InvalidJwksUri)?; + if jwks_uri.scheme() != "https" && !(config.allows_loopback_http && is_loopback_http(&jwks_uri)) { + return Err(OidcAuthError::InsecureJwksUri); + } + + let keys = fetch_jwks(&client, jwks_uri.clone()).await?; + let refresh_completed = Instant::now(); + let validations = build_validations(&metadata.issuer, &config.audience); + Ok(Self { + audience: config.audience, + jwks_uri, + keys: Arc::new(RwLock::new(keys)), + refresh_state: Arc::new(std::sync::Mutex::new(RefreshState { + last_completed: refresh_completed, + retry_after: None, + coalesce_until: None, + })), + refresh_gate: Arc::new(tokio::sync::Semaphore::new(1)), + validations: Arc::new(validations), + client, + }) + } + + async fn authenticate(&self, token: &str) -> Result { + let token_header = decode_header(token).map_err(OidcAuthError::InvalidToken)?; + if matches!(token_header.alg, Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512) { + return Err(OidcAuthError::UnsupportedTokenAlgorithm); + } + let kid = token_header.kid.ok_or(OidcAuthError::MissingKeyId)?; + let key = self.verification_key(&kid).await?; + if key.algorithm.is_some_and(|algorithm| algorithm != token_header.alg) { + return Err(OidcAuthError::AlgorithmMismatch); + } + let validation = self + .validations + .iter() + .find_map(|(algorithm, validation)| (*algorithm == token_header.alg).then_some(validation)) + .ok_or(OidcAuthError::UnsupportedTokenAlgorithm)?; + + let claims = decode::(token, &key.decoding_key, validation) + .map_err(OidcAuthError::InvalidToken)? + .claims; + if claims.sub.is_empty() { + return Err(OidcAuthError::EmptySubject); + } + if !claims.audience_allows(&self.audience) { + return Err(OidcAuthError::InvalidAuthorizedParty); + } + Ok(AuthenticatedPrincipal { + issuer: claims.iss, + subject: claims.sub, + expires_at: claims.exp, + }) + } + + async fn verification_key(&self, kid: &str) -> Result, OidcAuthError> { + { + let keys = self.keys.read().await; + if Instant::now() < keys.expires_at { + if let Some(key) = keys.keys.get(kid) { + return Ok(Arc::clone(key)); + } + } + } + + let _refresh_permit = self + .refresh_gate + .acquire() + .await + .map_err(|_| OidcAuthError::RefreshStateUnavailable)?; + { + let keys = self.keys.read().await; + let refresh_state = self + .refresh_state + .lock() + .map_err(|_| OidcAuthError::RefreshStateUnavailable)?; + let now = Instant::now(); + if now < keys.expires_at { + if let Some(key) = keys.keys.get(kid) { + return Ok(Arc::clone(key)); + } + if now.duration_since(refresh_state.last_completed) < JWKS_REFRESH_COOLDOWN { + return Err(OidcAuthError::UnknownKeyId); + } + } + if refresh_state.coalesce_until.is_some_and(|deadline| now < deadline) { + return keys.keys.get(kid).cloned().ok_or(OidcAuthError::UnknownKeyId); + } + if refresh_state.retry_after.is_some_and(|deadline| now < deadline) { + return Err(OidcAuthError::JwksRefreshBackoff); + } + } + + self.refresh_state + .lock() + .map_err(|_| OidcAuthError::RefreshStateUnavailable)? + .retry_after = Some(Instant::now() + JWKS_REFRESH_COOLDOWN); + let refreshed = fetch_jwks(&self.client, self.jwks_uri.clone()).await?; + let key = refreshed.keys.get(kid).cloned(); + *self.keys.write().await = refreshed; + let refresh_completed = Instant::now(); + let mut refresh_state = self + .refresh_state + .lock() + .map_err(|_| OidcAuthError::RefreshStateUnavailable)?; + refresh_state.last_completed = refresh_completed; + refresh_state.retry_after = None; + refresh_state.coalesce_until = Some(refresh_completed + JWKS_REFRESH_COALESCE_WINDOW); + key.ok_or(OidcAuthError::UnknownKeyId) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthenticatedPrincipal { + issuer: String, + subject: String, + expires_at: u64, +} + +impl AuthenticatedPrincipal { + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer + } + + #[must_use] + pub fn subject(&self) -> &str { + &self.subject + } + + #[must_use] + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + pub(crate) fn is_expired(&self) -> bool { + self.is_expired_at(jsonwebtoken::get_current_timestamp()) + } + + fn is_expired_at(&self, timestamp: u64) -> bool { + timestamp > self.expires_at.saturating_add(JWT_CLOCK_SKEW_SECONDS) + } + + #[cfg(test)] + pub(crate) fn expired_for_test() -> Self { + Self { + issuer: "https://issuer.example".to_owned(), + subject: "subject".to_owned(), + expires_at: 0, + } + } +} + +pub async fn require_oidc( + State(authenticator): State, + mut request: Request, + next: Next, +) -> Response { + let error_format = AuthErrorFormat::for_path(request.uri().path()); + let Some(token) = bearer_token(request.headers()) else { + return authentication_error(error_format, "missing_bearer_token", "missing bearer token"); + }; + let duplicate_identity_api_key = request + .headers() + .get_all("x-api-key") + .iter() + .any(|value| value.to_str().ok().is_some_and(|value| value.trim() == token)); + + match authenticator.authenticate(token).await { + Ok(principal) => { + request.headers_mut().remove(header::AUTHORIZATION); + if duplicate_identity_api_key { + request.headers_mut().remove("x-api-key"); + } + request.extensions_mut().insert(principal); + next.run(request).await + } + Err(error) => { + if error.is_dependency_failure() { + warn!(error = %error, "OIDC token verification dependency failed"); + authentication_service_unavailable(error_format) + } else { + debug!(error = %error, "OIDC bearer token rejected"); + authentication_error(error_format, "invalid_token", "invalid bearer token") + } + } + } +} + +fn bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION)? + .to_str() + .ok()? + .split_once(' ') + .and_then(|(scheme, token)| { + let token = token.trim(); + (scheme.eq_ignore_ascii_case("bearer") && !token.is_empty()).then_some(token) + }) +} + +#[derive(Clone, Copy)] +enum AuthErrorFormat { + OpenAi, + Anthropic, +} + +impl AuthErrorFormat { + fn for_path(path: &str) -> Self { + if matches!(path, ANTHROPIC_MESSAGES_PATH | ANTHROPIC_COUNT_TOKENS_PATH) { + Self::Anthropic + } else { + Self::OpenAi + } + } +} + +fn authentication_error(format: AuthErrorFormat, code: &'static str, message: &'static str) -> Response { + protocol_error( + format, + StatusCode::UNAUTHORIZED, + "authentication_error", + "authentication_error", + code, + message, + true, + ) +} + +fn authentication_service_unavailable(format: AuthErrorFormat) -> Response { + protocol_error( + format, + StatusCode::SERVICE_UNAVAILABLE, + "server_error", + "api_error", + "authentication_service_unavailable", + "authentication service temporarily unavailable", + false, + ) +} + +fn protocol_error( + format: AuthErrorFormat, + status: StatusCode, + openai_error_type: &'static str, + anthropic_error_type: &'static str, + code: &'static str, + message: &'static str, + challenge: bool, +) -> Response { + let body = match format { + AuthErrorFormat::OpenAi => json!({ + "error": { + "message": message, + "type": openai_error_type, + "param": null, + "code": code + } + }), + AuthErrorFormat::Anthropic => json!({ + "type": "error", + "error": { + "type": anthropic_error_type, + "message": message + } + }), + }; + let mut builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json"); + if challenge { + builder = builder.header(header::WWW_AUTHENTICATE, "Bearer"); + } + builder + .body(Body::from(body.to_string())) + .expect("valid authentication protocol error response") +} + +#[derive(Clone, Copy)] +enum ProviderRequest { + Metadata, + Jwks, +} + +impl ProviderRequest { + fn error(self, error: reqwest::Error) -> OidcAuthError { + match self { + Self::Metadata => OidcAuthError::ProviderMetadataRequest(error), + Self::Jwks => OidcAuthError::JwksRequest(error), + } + } +} + +async fn fetch_jwks(client: &reqwest::Client, uri: Url) -> Result { + let (keys, headers) = fetch_json::(client, uri, ProviderRequest::Jwks).await?; + compile_jwks(keys, jwks_ttl(&headers)) +} + +async fn fetch_json( + client: &reqwest::Client, + uri: Url, + request_kind: ProviderRequest, +) -> Result<(T, HeaderMap), OidcAuthError> +where + T: serde::de::DeserializeOwned, +{ + let mut response = client + .get(uri) + .send() + .await + .map_err(|error| request_kind.error(error))? + .error_for_status() + .map_err(|error| request_kind.error(error))?; + if response + .content_length() + .is_some_and(|length| length > MAX_PROVIDER_RESPONSE_BYTES as u64) + { + return Err(OidcAuthError::ProviderResponseTooLarge); + } + + let headers = response.headers().clone(); + let mut body = Vec::with_capacity( + response + .content_length() + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or_default() + .min(MAX_PROVIDER_RESPONSE_BYTES), + ); + while let Some(chunk) = response.chunk().await.map_err(|error| request_kind.error(error))? { + if body.len().saturating_add(chunk.len()) > MAX_PROVIDER_RESPONSE_BYTES { + return Err(OidcAuthError::ProviderResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + let value = serde_json::from_slice(&body).map_err(OidcAuthError::InvalidProviderJson)?; + Ok((value, headers)) +} + +fn compile_jwks(keys: JwkSet, ttl: Duration) -> Result { + if keys.keys.is_empty() { + return Err(OidcAuthError::EmptyJwks); + } + if keys.keys.len() > MAX_JWKS_KEYS { + return Err(OidcAuthError::TooManyJwksKeys); + } + + let mut compiled = HashMap::with_capacity(keys.keys.len()); + for key in keys.keys { + let Some(kid) = key.common.key_id.clone() else { + continue; + }; + let algorithm = match verification_algorithm(&key) { + VerificationAlgorithm::Skip => continue, + VerificationAlgorithm::AnyAsymmetric => None, + VerificationAlgorithm::Exact(algorithm) => Some(algorithm), + }; + let Ok(decoding_key) = DecodingKey::from_jwk(&key) else { + continue; + }; + if compiled + .insert( + kid.clone(), + Arc::new(CachedKey { + decoding_key, + algorithm, + }), + ) + .is_some() + { + return Err(OidcAuthError::DuplicateKeyId(kid)); + } + } + if compiled.is_empty() { + return Err(OidcAuthError::EmptyJwks); + } + + Ok(CachedJwks { + keys: compiled, + expires_at: Instant::now() + ttl, + }) +} + +enum VerificationAlgorithm { + Skip, + AnyAsymmetric, + Exact(Algorithm), +} + +fn verification_algorithm(key: &Jwk) -> VerificationAlgorithm { + if key + .common + .public_key_use + .as_ref() + .is_some_and(|key_use| key_use != &PublicKeyUse::Signature) + || key + .common + .key_operations + .as_ref() + .is_some_and(|operations| !operations.contains(&KeyOperations::Verify)) + { + return VerificationAlgorithm::Skip; + } + + match key.common.key_algorithm { + Some(key_algorithm) => VerificationAlgorithm::Exact(match key_algorithm { + KeyAlgorithm::ES256 => Algorithm::ES256, + KeyAlgorithm::ES384 => Algorithm::ES384, + KeyAlgorithm::RS256 => Algorithm::RS256, + KeyAlgorithm::RS384 => Algorithm::RS384, + KeyAlgorithm::RS512 => Algorithm::RS512, + KeyAlgorithm::PS256 => Algorithm::PS256, + KeyAlgorithm::PS384 => Algorithm::PS384, + KeyAlgorithm::PS512 => Algorithm::PS512, + KeyAlgorithm::EdDSA => Algorithm::EdDSA, + KeyAlgorithm::HS256 + | KeyAlgorithm::HS384 + | KeyAlgorithm::HS512 + | KeyAlgorithm::RSA1_5 + | KeyAlgorithm::RSA_OAEP + | KeyAlgorithm::RSA_OAEP_256 + | KeyAlgorithm::UNKNOWN_ALGORITHM => return VerificationAlgorithm::Skip, + }), + None => VerificationAlgorithm::AnyAsymmetric, + } +} + +fn build_validations(issuer: &str, audience: &str) -> Vec<(Algorithm, Validation)> { + [ + Algorithm::ES256, + Algorithm::ES384, + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::EdDSA, + ] + .into_iter() + .map(|algorithm| { + let mut validation = Validation::new(algorithm); + validation.leeway = JWT_CLOCK_SKEW_SECONDS; + validation.set_audience(&[audience]); + validation.set_issuer(&[issuer]); + validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]); + validation.validate_nbf = true; + (algorithm, validation) + }) + .collect() +} + +fn jwks_ttl(headers: &HeaderMap) -> Duration { + let mut max_age: Option = None; + for value in headers.get_all(header::CACHE_CONTROL) { + let Ok(value) = value.to_str() else { + continue; + }; + for directive in value.split(',').map(str::trim) { + if directive.eq_ignore_ascii_case("no-cache") || directive.eq_ignore_ascii_case("no-store") { + return Duration::ZERO; + } + if let Some((name, seconds)) = directive.split_once('=') { + if name.trim().eq_ignore_ascii_case("max-age") { + let parsed = seconds.trim().trim_matches('"').parse::().ok(); + max_age = match (max_age, parsed) { + (Some(existing), Some(parsed)) => Some(existing.min(parsed)), + (None, parsed) => parsed, + (existing, None) => existing, + }; + } + } + } + } + let age = headers + .get_all(header::AGE) + .iter() + .filter_map(|value| value.to_str().ok()?.trim().parse::().ok()) + .max() + .map_or(Duration::ZERO, Duration::from_secs); + max_age.map_or_else( + || DEFAULT_JWKS_TTL.saturating_sub(age), + |seconds| Duration::from_secs(seconds).saturating_sub(age).min(MAX_JWKS_TTL), + ) +} + +#[derive(Deserialize)] +struct ProviderMetadata { + issuer: String, + jwks_uri: String, +} + +#[derive(Deserialize)] +struct IdentityClaims { + iss: String, + sub: String, + aud: AudienceClaim, + exp: u64, + #[serde(default)] + azp: Option, +} + +impl IdentityClaims { + fn audience_allows(&self, expected: &str) -> bool { + match &self.aud { + AudienceClaim::One(audience) => audience == expected, + AudienceClaim::Many(audiences) if audiences.len() == 1 => audiences[0] == expected, + AudienceClaim::Many(audiences) => { + audiences.iter().any(|audience| audience == expected) && self.azp.as_deref() == Some(expected) + } + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum AudienceClaim { + One(String), + Many(Vec), +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum OidcAuthError { + #[error("OIDC audience must not be empty")] + EmptyAudience, + #[error("OIDC issuer must not include a query or fragment")] + InvalidIssuerComponents, + #[error("OIDC issuer must use HTTPS; HTTP is only permitted for loopback IP addresses (127.0.0.1 or ::1)")] + InsecureIssuer, + #[error("OIDC JWKS URI must use HTTPS; HTTP is only permitted for loopback IP addresses (127.0.0.1 or ::1)")] + InsecureJwksUri, + #[error("invalid OIDC issuer URL")] + InvalidIssuer(#[source] url::ParseError), + #[error("invalid OIDC JWKS URI")] + InvalidJwksUri(#[source] url::ParseError), + #[error("OIDC discovery returned an invalid issuer URL")] + InvalidDiscoveredIssuer(#[source] url::ParseError), + #[error("failed to build OIDC HTTP client")] + HttpClient(#[source] reqwest::Error), + #[error("OIDC provider metadata request failed")] + ProviderMetadataRequest(#[source] reqwest::Error), + #[error("OIDC JWKS request failed")] + JwksRequest(#[source] reqwest::Error), + #[error("OIDC provider response exceeded the size limit")] + ProviderResponseTooLarge, + #[error("OIDC provider returned invalid JSON")] + InvalidProviderJson(#[source] serde_json::Error), + #[error("OIDC discovery returned issuer {discovered}, expected {expected}")] + IssuerMismatch { expected: String, discovered: String }, + #[error("OIDC provider returned an empty JWKS")] + EmptyJwks, + #[error("OIDC provider returned too many JWKs")] + TooManyJwksKeys, + #[error("OIDC provider returned duplicate JWK key ID {0}")] + DuplicateKeyId(String), + #[error("OIDC JWKS refresh is temporarily backed off after a provider failure")] + JwksRefreshBackoff, + #[error("OIDC JWKS refresh coordination is unavailable")] + RefreshStateUnavailable, + #[error("bearer token is missing a key ID")] + MissingKeyId, + #[error("bearer token references an unknown key ID")] + UnknownKeyId, + #[error("bearer token subject must not be empty")] + EmptySubject, + #[error("bearer token authorized party does not match the configured audience")] + InvalidAuthorizedParty, + #[error("bearer token uses an unsupported algorithm")] + UnsupportedTokenAlgorithm, + #[error("bearer token and JWK algorithms do not match")] + AlgorithmMismatch, + #[error("bearer token validation failed")] + InvalidToken(#[source] jsonwebtoken::errors::Error), +} + +impl OidcAuthError { + fn is_dependency_failure(&self) -> bool { + matches!( + self, + Self::JwksRequest(_) + | Self::ProviderResponseTooLarge + | Self::InvalidProviderJson(_) + | Self::EmptyJwks + | Self::TooManyJwksKeys + | Self::DuplicateKeyId(_) + | Self::JwksRefreshBackoff + | Self::RefreshStateUnavailable + ) + } +} + +#[cfg(test)] +mod tests { + use super::{ + AuthenticatedPrincipal, MAX_JWKS_KEYS, MAX_JWKS_TTL, OidcAuthError, VerificationAlgorithm, compile_jwks, + jwks_ttl, verification_algorithm, + }; + use axum::http::{HeaderMap, HeaderValue, header}; + use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}; + use jsonwebtoken::{Algorithm, EncodingKey}; + use rand::rngs::OsRng; + use rsa::RsaPrivateKey; + use rsa::pkcs1::EncodeRsaPrivateKey; + use std::time::Duration; + + fn test_jwk() -> Jwk { + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("generate test RSA key"); + let private_key = private_key.to_pkcs1_der().expect("encode test RSA key"); + let mut jwk = Jwk::from_encoding_key(&EncodingKey::from_rsa_der(private_key.as_bytes()), Algorithm::RS256) + .expect("test JWK"); + jwk.common.key_id = Some("test-key".to_owned()); + jwk.common.key_algorithm = Some(KeyAlgorithm::RS256); + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + jwk + } + + #[test] + fn jwks_cache_lifetime_uses_provider_max_age_with_a_cap() { + let mut headers = HeaderMap::new(); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60")); + assert_eq!(jwks_ttl(&headers), Duration::from_secs(60)); + + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("max-age=86400")); + assert_eq!(jwks_ttl(&headers), MAX_JWKS_TTL); + + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, Max-Age=\"30\""), + ); + assert_eq!(jwks_ttl(&headers), Duration::from_secs(30)); + + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + assert_eq!(jwks_ttl(&headers), Duration::ZERO); + + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("max-age=60")); + headers.insert(header::AGE, HeaderValue::from_static("55")); + assert_eq!(jwks_ttl(&headers), Duration::from_secs(5)); + + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("max-age=86400")); + headers.insert(header::AGE, HeaderValue::from_static("4000")); + assert_eq!(jwks_ttl(&headers), MAX_JWKS_TTL); + + headers.remove(header::AGE); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + assert_eq!(jwks_ttl(&headers), Duration::ZERO); + } + + #[test] + fn authenticated_principal_expiration_includes_clock_skew() { + let principal = AuthenticatedPrincipal { + issuer: "https://issuer.example".to_owned(), + subject: "subject".to_owned(), + expires_at: 100, + }; + + assert!(!principal.is_expired_at(160)); + assert!(principal.is_expired_at(161)); + } + + #[test] + fn jwks_limits_and_signature_metadata_are_enforced() { + let mut encryption_key = test_jwk(); + encryption_key.common.public_key_use = Some(PublicKeyUse::Encryption); + assert!(matches!( + compile_jwks( + JwkSet { + keys: vec![encryption_key] + }, + Duration::from_secs(60) + ), + Err(OidcAuthError::EmptyJwks) + )); + + let mut non_verifying_key = test_jwk(); + non_verifying_key.common.key_operations = Some(vec![KeyOperations::Encrypt]); + assert!(matches!( + compile_jwks( + JwkSet { + keys: vec![non_verifying_key] + }, + Duration::from_secs(60) + ), + Err(OidcAuthError::EmptyJwks) + )); + + let mut mismatched_algorithm = test_jwk(); + mismatched_algorithm.common.key_algorithm = Some(KeyAlgorithm::RS512); + assert!(matches!( + verification_algorithm(&mismatched_algorithm), + VerificationAlgorithm::Exact(Algorithm::RS512) + )); + + let too_many_keys = vec![test_jwk(); MAX_JWKS_KEYS + 1]; + assert!(matches!( + compile_jwks(JwkSet { keys: too_many_keys }, Duration::from_secs(60)), + Err(OidcAuthError::TooManyJwksKeys) + )); + } +} diff --git a/crates/agentic-server/src/handler/mod.rs b/crates/agentic-server/src/handler/mod.rs index 2cf0dc43..d234ae56 100644 --- a/crates/agentic-server/src/handler/mod.rs +++ b/crates/agentic-server/src/handler/mod.rs @@ -5,3 +5,4 @@ pub mod websocket; pub use common::{convert_response, executor_error_response}; pub use http::{compact_response, conversations, count_tokens, health, messages, models, ready, responses}; pub use websocket::responses_ws; +pub(crate) use websocket::responses_ws_with_auth; diff --git a/crates/agentic-server/src/handler/websocket/error.rs b/crates/agentic-server/src/handler/websocket/error.rs index 43b013d6..0ffe4652 100644 --- a/crates/agentic-server/src/handler/websocket/error.rs +++ b/crates/agentic-server/src/handler/websocket/error.rs @@ -21,6 +21,9 @@ pub(super) enum WsError { #[error("websocket messages must be JSON text frames")] BinaryFrame, + #[error("OIDC bearer token expired")] + AuthenticationExpired, + #[error("websocket send failed")] SendFailed, @@ -36,6 +39,7 @@ impl WsError { match self { Self::Executor(err) => err.http_status(), Self::InvalidJson(_) | Self::UnexpectedType | Self::BinaryFrame => StatusCode::BAD_REQUEST, + Self::AuthenticationExpired => StatusCode::UNAUTHORIZED, Self::SerializeJson(_) | Self::SendFailed | Self::ClientDisconnected | Self::Receive(_) => { StatusCode::INTERNAL_SERVER_ERROR } @@ -47,6 +51,7 @@ impl WsError { Self::Executor(err) => err.error_code(), Self::InvalidJson(_) => "invalid_json", Self::UnexpectedType | Self::BinaryFrame => "invalid_request_error", + Self::AuthenticationExpired => "invalid_token", Self::SerializeJson(_) | Self::SendFailed | Self::ClientDisconnected | Self::Receive(_) => "server_error", } } diff --git a/crates/agentic-server/src/handler/websocket/mod.rs b/crates/agentic-server/src/handler/websocket/mod.rs index 14c75011..e4e248c8 100644 --- a/crates/agentic-server/src/handler/websocket/mod.rs +++ b/crates/agentic-server/src/handler/websocket/mod.rs @@ -2,3 +2,4 @@ mod error; mod responses; pub use responses::responses_ws; +pub(crate) use responses::responses_ws_with_auth; diff --git a/crates/agentic-server/src/handler/websocket/responses.rs b/crates/agentic-server/src/handler/websocket/responses.rs index b5264a1b..97fd472f 100644 --- a/crates/agentic-server/src/handler/websocket/responses.rs +++ b/crates/agentic-server/src/handler/websocket/responses.rs @@ -1,8 +1,8 @@ use std::collections::VecDeque; use std::sync::Arc; -use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Extension, State}; use axum::http::HeaderMap; use axum::response::Response; use either::Either; @@ -20,21 +20,45 @@ use agentic_core::utils::common::utcnow_str; use super::super::common::{MAX_BODY_SIZE, extract_bearer}; use super::error::WsError; use crate::app::AppState; +use crate::auth::AuthenticatedPrincipal; type WsSender = SplitSink; type WsReceiver = SplitStream; pub async fn responses_ws(State(state): State, headers: HeaderMap, ws: WebSocketUpgrade) -> Response { + upgrade_responses_ws(state, headers, ws, None) +} + +pub(crate) async fn responses_ws_with_auth( + State(state): State, + principal: Option>, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Response { + upgrade_responses_ws(state, headers, ws, principal.map(|Extension(principal)| principal)) +} + +fn upgrade_responses_ws( + state: AppState, + headers: HeaderMap, + ws: WebSocketUpgrade, + principal: Option, +) -> Response { let websocket_guard = state.websocket_tracker.track(); ws.max_message_size(MAX_BODY_SIZE) .max_frame_size(MAX_BODY_SIZE) .on_upgrade(move |socket| async move { let _websocket_guard = websocket_guard; - responses_ws_loop(socket, state, headers).await; + responses_ws_loop(socket, state, headers, principal).await; }) } -async fn responses_ws_loop(socket: WebSocket, state: AppState, headers: HeaderMap) { +async fn responses_ws_loop( + socket: WebSocket, + state: AppState, + headers: HeaderMap, + principal: Option, +) { debug!("responses websocket session opened"); let shutdown_token = state.shutdown_token.clone(); let (mut sender, mut receiver) = socket.split(); @@ -78,6 +102,11 @@ async fn responses_ws_loop(socket: WebSocket, state: AppState, headers: HeaderMa } }; + if let Some(error) = websocket_identity_error(principal.as_ref()) { + let _ = send_ws_error(&mut sender, &error).await; + break; + } + match handle_ws_text( &mut sender, &mut receiver, @@ -101,6 +130,12 @@ async fn responses_ws_loop(socket: WebSocket, state: AppState, headers: HeaderMa debug!("responses websocket session closed"); } +fn websocket_identity_error(principal: Option<&AuthenticatedPrincipal>) -> Option { + principal + .is_some_and(AuthenticatedPrincipal::is_expired) + .then_some(WsError::AuthenticationExpired) +} + async fn next_ws_message( shutdown_token: &CancellationToken, receiver: &mut Receiver, @@ -415,7 +450,10 @@ mod tests { use futures::{Sink, Stream, StreamExt, sink, stream}; use tokio_util::sync::CancellationToken; - use super::{ShutdownInput, close_ws, keep_if_running, next_shutdown_input, next_ws_message}; + use super::{ + ShutdownInput, close_ws, keep_if_running, next_shutdown_input, next_ws_message, websocket_identity_error, + }; + use crate::auth::AuthenticatedPrincipal; struct CloseErrorSink; @@ -484,6 +522,17 @@ mod tests { assert_eq!(keep_if_running(&shutdown_token, "unpolled stream"), None); } + #[test] + fn websocket_identity_expiry_selects_the_unauthorized_error_event() { + assert!(websocket_identity_error(None).is_none()); + let error = + websocket_identity_error(Some(&AuthenticatedPrincipal::expired_for_test())).expect("expired-token error"); + let frame = error.to_ws_frame().expect("client-visible error frame"); + + assert_eq!(frame["status"], 401); + assert_eq!(frame["error"]["code"], "invalid_token"); + } + #[tokio::test] async fn close_ws_ignores_late_frames_until_peer_close() { let mut sender = sink::drain(); diff --git a/crates/agentic-server/src/lib.rs b/crates/agentic-server/src/lib.rs index 55f41a01..36a9b14f 100644 --- a/crates/agentic-server/src/lib.rs +++ b/crates/agentic-server/src/lib.rs @@ -1,2 +1,3 @@ pub mod app; +pub mod auth; pub mod handler; diff --git a/crates/agentic-server/src/main.rs b/crates/agentic-server/src/main.rs index 0454eb85..06cd1d23 100644 --- a/crates/agentic-server/src/main.rs +++ b/crates/agentic-server/src/main.rs @@ -11,6 +11,7 @@ use agentic_core::config::{ PostgresConfig, SqliteConfig, SqliteTempStore, normalize_base_url, }; use agentic_core::error::Error; +use agentic_server::auth::OidcConfig; mod server; @@ -19,6 +20,14 @@ struct CommonArgs { #[arg(long, env = "OPENAI_API_KEY", hide_env_values = true, global = true)] openai_api_key: Option, + /// OIDC issuer for optional inbound bearer-token authentication. + #[arg(long, env = "OIDC_ISSUER", global = true)] + oidc_issuer: Option, + + /// Required bearer-token audience when `OIDC_ISSUER` is configured. + #[arg(long, env = "OIDC_AUDIENCE", global = true)] + oidc_audience: Option, + #[arg(long, env = "GATEWAY_HOST", default_value = "0.0.0.0", global = true)] gateway_host: String, @@ -47,6 +56,17 @@ struct CommonArgs { db_url: String, } +fn oidc_config_from_values( + issuer: Option<&str>, + audience: Option<&str>, +) -> Result, server::ServerError> { + match (issuer, audience) { + (None, None) => Ok(None), + (Some(issuer), Some(audience)) => Ok(Some(OidcConfig::new(issuer, audience)?)), + _ => Err(Error::Config("OIDC_ISSUER and OIDC_AUDIENCE must be configured together".to_owned()).into()), + } +} + #[derive(Parser)] #[command(name = "agentic-server", about = "Stateful API gateway for vLLM Responses API")] struct Cli { @@ -223,7 +243,7 @@ fn build_config(llm_api_base: String, common: &CommonArgs) -> Result Result<(), Error> { +async fn main() -> Result<(), server::ServerError> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -236,6 +256,7 @@ async fn main() -> Result<(), Error> { llm_api_base, common, } = Cli::parse(); + let oidc_config = oidc_config_from_values(common.oidc_issuer.as_deref(), common.oidc_audience.as_deref())?; match command { None => { @@ -246,20 +267,21 @@ async fn main() -> Result<(), Error> { ) })?; let config = build_config(normalize_base_url(&base), &common)?; - server::run(config, &common.gateway_host, common.gateway_port).await + server::run(config, &common.gateway_host, common.gateway_port, oidc_config).await } Some(Commands::Serve { model, port, llm_args }) => { if llm_api_base.is_some() { return Err(Error::Config( "--llm-api-base is only valid in standalone mode; remove it when using `serve`".to_owned(), - )); + ) + .into()); } let config = build_config(normalize_base_url(&format!("http://127.0.0.1:{port}")), &common)?; let mut args = vec!["--model".to_owned(), model]; args.push("--port".to_owned()); args.push(port.to_string()); args.extend(llm_args); - server::run_with_llm(config, &common.gateway_host, common.gateway_port, args).await + server::run_with_llm(config, &common.gateway_host, common.gateway_port, args, oidc_config).await } } } @@ -271,8 +293,8 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - Cli, Commands, database_configs_from_env, parse_env_duration_value, parse_env_optional_duration_value, - parse_env_temp_store_value, parse_env_u32_value, parse_env_u64_value, + Cli, Commands, database_configs_from_env, oidc_config_from_values, parse_env_duration_value, + parse_env_optional_duration_value, parse_env_temp_store_value, parse_env_u32_value, parse_env_u64_value, }; use agentic_core::config::{ DEFAULT_POSTGRES_ACQUIRE_TIMEOUT_SECONDS, DEFAULT_POSTGRES_IDLE_TIMEOUT_SECONDS, @@ -306,6 +328,18 @@ mod tests { assert!(cli.common.skip_llm_ready_check); } + #[test] + fn oidc_configuration_requires_issuer_and_audience_together() { + assert!(oidc_config_from_values(None, None).expect("disabled OIDC").is_none()); + assert!(oidc_config_from_values(Some("https://issuer.example"), None).is_err()); + assert!(oidc_config_from_values(None, Some("agentic-api")).is_err()); + assert!( + oidc_config_from_values(Some("https://issuer.example"), Some("agentic-api")) + .expect("complete OIDC configuration") + .is_some() + ); + } + #[test] fn container_runtime_options_are_bound_to_environment_variables() { let command = Cli::command(); @@ -314,6 +348,8 @@ mod tests { ("llm_api_base", "LLM_API_BASE"), ("gateway_host", "GATEWAY_HOST"), ("gateway_port", "GATEWAY_PORT"), + ("oidc_issuer", "OIDC_ISSUER"), + ("oidc_audience", "OIDC_AUDIENCE"), ] { let env = command .get_arguments() diff --git a/crates/agentic-server/src/server.rs b/crates/agentic-server/src/server.rs index c9cd19f5..a976ec10 100644 --- a/crates/agentic-server/src/server.rs +++ b/crates/agentic-server/src/server.rs @@ -4,18 +4,35 @@ use std::sync::Arc; use std::time::Duration; use agentic_core::config::Config; -use agentic_core::error::Error; +use agentic_core::error::Error as CoreError; use agentic_core::executor::ExecutionContext; use agentic_core::proxy::ProxyState; use agentic_core::readiness::wait_llm_ready; -use agentic_server::app::{AppState, ServerConfig, WebSocketTracker, build_router}; +use agentic_server::app::{AppState, ServerConfig, WebSocketTracker, build_router_with_auth}; +use agentic_server::auth::{OidcAuthError, OidcAuthenticator, OidcConfig}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; const GATEWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(8); -async fn build_state(config: &Config, shutdown_token: CancellationToken) -> Result { +#[derive(Debug, thiserror::Error)] +pub enum ServerError { + #[error(transparent)] + Core(#[from] CoreError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("failed to initialize OIDC authentication: {0}")] + Oidc(#[source] OidcAuthError), +} + +impl From for ServerError { + fn from(error: OidcAuthError) -> Self { + Self::Oidc(error) + } +} + +async fn build_state(config: &Config, shutdown_token: CancellationToken) -> Result { let proxy_state = ProxyState::new(config.clone())?; let exec_ctx = Arc::new(ExecutionContext::from_config(config).await?); @@ -29,12 +46,17 @@ async fn build_state(config: &Config, shutdown_token: CancellationToken) -> Resu }) } -async fn serve_gateway(state: AppState, host: &str, port: u16) -> Result<(), Error> { +async fn serve_gateway( + state: AppState, + host: &str, + port: u16, + authenticator: Option, +) -> Result<(), ServerError> { let addr = format!("{host}:{port}"); let server_config = ServerConfig::from_env(); let shutdown_token = state.shutdown_token.clone(); let websocket_tracker = state.websocket_tracker.clone(); - let router = build_router(state, &server_config); + let router = build_router_with_auth(state, &server_config, authenticator); let listener = TcpListener::bind(&addr).await?; info!("gateway listening on {addr}"); axum::serve(listener, router) @@ -46,9 +68,14 @@ async fn serve_gateway(state: AppState, host: &str, port: u16) -> Result<(), Err Ok(()) } -async fn serve_gateway_until_signal(state: AppState, host: &str, port: u16) -> Result<(), Error> { +async fn serve_gateway_until_signal( + state: AppState, + host: &str, + port: u16, + authenticator: Option, +) -> Result<(), ServerError> { let shutdown_token = state.shutdown_token.clone(); - let gateway = serve_gateway(state, host, port); + let gateway = serve_gateway(state, host, port, authenticator); tokio::pin!(gateway); tokio::select! { @@ -62,9 +89,9 @@ async fn serve_gateway_until_signal(state: AppState, host: &str, port: u16) -> R } } -async fn drain_gateway(gateway: Pin<&mut F>) -> Result<(), Error> +async fn drain_gateway(gateway: Pin<&mut F>) -> Result<(), ServerError> where - F: Future>, + F: Future>, { if let Ok(result) = tokio::time::timeout(GATEWAY_DRAIN_TIMEOUT, gateway).await { result @@ -92,7 +119,7 @@ async fn shutdown_signal() -> Result<(), std::io::Error> { tokio::signal::ctrl_c().await } -async fn wait_until_llm_ready(config: &Config) -> Result<(), Error> { +async fn wait_until_llm_ready(config: &Config) -> Result<(), ServerError> { if config.skip_llm_ready_check { info!("skipping LLM readiness check: {}", config.llm_api_base); return Ok(()); @@ -107,21 +134,35 @@ async fn wait_until_llm_ready(config: &Config) -> Result<(), Error> { /// /// # Errors /// -/// Returns an error if DB initialisation, LLM readiness polling, or the -/// server binding fails. -pub async fn run(config: Config, host: &str, port: u16) -> Result<(), Error> { +/// Returns an error if OIDC discovery or verification-key loading, DB +/// initialisation, LLM readiness polling, or the server binding fails. +pub async fn run(config: Config, host: &str, port: u16, oidc_config: Option) -> Result<(), ServerError> { + let authenticator = match oidc_config { + Some(config) => Some(OidcAuthenticator::discover(config).await?), + None => None, + }; wait_until_llm_ready(&config).await?; let state = build_state(&config, CancellationToken::new()).await?; - serve_gateway_until_signal(state, host, port).await + serve_gateway_until_signal(state, host, port, authenticator).await } /// Spawn vLLM as a subprocess and run the gateway in the foreground. /// /// # Errors /// -/// Returns an error if vLLM fails to start, DB init fails, or the gateway -/// errors. -pub async fn run_with_llm(config: Config, host: &str, port: u16, llm_args: Vec) -> Result<(), Error> { +/// Returns an error if OIDC discovery or verification-key loading fails, vLLM +/// fails to start, DB initialisation fails, or the gateway errors. +pub async fn run_with_llm( + config: Config, + host: &str, + port: u16, + llm_args: Vec, + oidc_config: Option, +) -> Result<(), ServerError> { + let authenticator = match oidc_config { + Some(config) => Some(OidcAuthenticator::discover(config).await?), + None => None, + }; let mut cmd = tokio::process::Command::new("python"); cmd.arg("-m").arg("vllm.entrypoints.openai.api_server"); cmd.args(&llm_args); @@ -134,10 +175,10 @@ pub async fn run_with_llm(config: Config, host: &str, port: u16, llm_args: Vec ready.map(|()| true), + ready = wait_llm_ready(&config) => ready.map(|()| true).map_err(ServerError::from), status = child.wait() => { let status = status?; - Err(Error::LlmProcessExited { status: status.to_string() }) + Err(ServerError::from(CoreError::LlmProcessExited { status: status.to_string() })) } } }; @@ -162,7 +203,7 @@ pub async fn run_with_llm(config: Config, host: &str, port: u16, llm_args: Vec { shutdown_token.cancel(); let status = status?; - Err(Error::LlmProcessExited { status: status.to_string() }) + Err(ServerError::from(CoreError::LlmProcessExited { status: status.to_string() })) }, signal = shutdown_signal() => { match signal { @@ -191,12 +232,12 @@ pub async fn run_with_llm(config: Config, host: &str, port: u16, llm_args: Vec>(); + let gateway = std::future::pending::>(); tokio::pin!(gateway); drain_gateway(gateway.as_mut()).await.unwrap(); @@ -204,7 +245,7 @@ mod tests { #[tokio::test] async fn gateway_drain_preserves_server_errors() { - let gateway = std::future::ready(Err(Error::Config("gateway failed".to_owned()))); + let gateway = std::future::ready(Err(ServerError::from(CoreError::Config("gateway failed".to_owned())))); tokio::pin!(gateway); let error = drain_gateway(gateway.as_mut()).await.unwrap_err(); diff --git a/crates/agentic-server/tests/oidc_auth_test.rs b/crates/agentic-server/tests/oidc_auth_test.rs new file mode 100644 index 00000000..c5396fb9 --- /dev/null +++ b/crates/agentic-server/tests/oidc_auth_test.rs @@ -0,0 +1,897 @@ +#[allow(dead_code)] +mod common; + +use axum::body::{Body, Bytes}; +use axum::http::{HeaderMap, HeaderValue, Response, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Extension, Json, Router, middleware}; +use common::{test_config, test_state}; +use futures::stream; +use jsonwebtoken::jwk::{Jwk, KeyAlgorithm, PublicKeyUse}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use rand::rngs::OsRng; +use rsa::RsaPrivateKey; +use rsa::pkcs1::EncodeRsaPrivateKey; +use serde::Serialize; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +use agentic_server::app::{ServerConfig, build_router_with_auth}; +use agentic_server::auth::{AuthenticatedPrincipal, OidcAuthError, OidcAuthenticator, OidcConfig, require_oidc}; + +const TEST_AUDIENCE: &str = "agentic-api"; + +struct TestGateway { + address: std::net::SocketAddr, + handle: JoinHandle<()>, +} + +impl Drop for TestGateway { + fn drop(&mut self) { + self.handle.abort(); + } +} + +async fn spawn_gateway(authenticator: OidcAuthenticator, upstream_url: &str) -> TestGateway { + let config = test_config(upstream_url); + let router = build_router_with_auth( + test_state(&config), + &ServerConfig { + cors_allowed_origins: Vec::new(), + }, + Some(authenticator), + ); + spawn_router(router).await +} + +async fn spawn_router(router: Router) -> TestGateway { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let address = listener.local_addr().expect("gateway address"); + let handle = tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve gateway"); + }); + TestGateway { address, handle } +} + +async fn discover_test_authenticator(issuer: &str) -> OidcAuthenticator { + OidcAuthenticator::discover(OidcConfig::new(issuer, TEST_AUDIENCE).expect("OIDC config")) + .await + .expect("OIDC discovery") +} + +fn test_key() -> (Vec, Value) { + test_key_with_id("test-key") +} + +fn test_key_with_id(kid: &str) -> (Vec, Value) { + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("generate test RSA key"); + let private_key_der = private_key.to_pkcs1_der().expect("encode test RSA key"); + let private_key_der = private_key_der.as_bytes().to_vec(); + let encoding_key = EncodingKey::from_rsa_der(&private_key_der); + let mut jwk = Jwk::from_encoding_key(&encoding_key, Algorithm::RS256).expect("test JWK"); + jwk.common.key_id = Some(kid.to_owned()); + jwk.common.key_algorithm = Some(KeyAlgorithm::RS256); + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + (private_key_der, serde_json::to_value(jwk).expect("serialize test JWK")) +} + +async fn spawn_rotating_oidc_provider() -> (String, Vec, Vec, std::sync::Arc, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind rotating OIDC provider"); + let issuer = format!("http://{}", listener.local_addr().expect("OIDC provider address")); + let discovery_issuer = issuer.clone(); + let discovery_jwks_uri = format!("{issuer}/jwks"); + let (old_private_key, old_jwk) = test_key_with_id("old-key"); + let (new_private_key, new_jwk) = test_key_with_id("new-key"); + let jwks_requests = std::sync::Arc::new(AtomicUsize::new(0)); + let observed_jwks_requests = std::sync::Arc::clone(&jwks_requests); + + let provider = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || { + let issuer = discovery_issuer.clone(); + let jwks_uri = discovery_jwks_uri.clone(); + async move { Json(json!({"issuer": issuer, "jwks_uri": jwks_uri})) } + }), + ) + .route( + "/jwks", + get(move || { + let old_jwk = old_jwk.clone(); + let new_jwk = new_jwk.clone(); + let observed_jwks_requests = std::sync::Arc::clone(&observed_jwks_requests); + async move { + let request = observed_jwks_requests.fetch_add(1, Ordering::Relaxed); + let (cache_control, jwk) = if request == 0 { + ("max-age=0", old_jwk) + } else { + ("max-age=0", new_jwk) + }; + ( + [(reqwest::header::CACHE_CONTROL, cache_control)], + Json(json!({"keys": [jwk]})), + ) + } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, provider) + .await + .expect("serve rotating OIDC provider"); + }); + + (issuer, old_private_key, new_private_key, jwks_requests, handle) +} + +async fn spawn_failing_refresh_provider() -> (String, Vec, std::sync::Arc, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind failing OIDC provider"); + let issuer = format!("http://{}", listener.local_addr().expect("OIDC provider address")); + let discovery_issuer = issuer.clone(); + let discovery_jwks_uri = format!("{issuer}/jwks"); + let (private_key, jwk) = test_key(); + let jwks_requests = std::sync::Arc::new(AtomicUsize::new(0)); + let observed_jwks_requests = std::sync::Arc::clone(&jwks_requests); + + let provider = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || { + let issuer = discovery_issuer.clone(); + let jwks_uri = discovery_jwks_uri.clone(); + async move { Json(json!({"issuer": issuer, "jwks_uri": jwks_uri})) } + }), + ) + .route( + "/jwks", + get(move || { + let jwk = jwk.clone(); + let observed_jwks_requests = std::sync::Arc::clone(&observed_jwks_requests); + async move { + if observed_jwks_requests.fetch_add(1, Ordering::Relaxed) == 0 { + ( + [(reqwest::header::CACHE_CONTROL, "max-age=0")], + Json(json!({"keys": [jwk]})), + ) + .into_response() + } else { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } + } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, provider) + .await + .expect("serve failing OIDC provider"); + }); + + (issuer, private_key, jwks_requests, handle) +} + +async fn spawn_metadata_provider(build_body: impl FnOnce(&str) -> String) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind metadata provider"); + let issuer = format!("http://{}", listener.local_addr().expect("metadata provider address")); + let body = std::sync::Arc::new(build_body(&issuer)); + let provider = Router::new().route( + "/.well-known/openid-configuration", + get(move || { + let body = std::sync::Arc::clone(&body); + async move { ([(reqwest::header::CONTENT_TYPE, "application/json")], body.to_string()) } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, provider).await.expect("serve metadata provider"); + }); + (issuer, handle) +} + +async fn spawn_chunked_metadata_provider() -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind metadata provider"); + let issuer = format!("http://{}", listener.local_addr().expect("metadata provider address")); + let provider = Router::new().route( + "/.well-known/openid-configuration", + get(|| async { + let chunks = stream::iter([ + Ok::<_, std::convert::Infallible>(Bytes::from(vec![b' '; 768 * 1024])), + Ok(Bytes::from(vec![b' '; 768 * 1024])), + ]); + Response::builder() + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(Body::from_stream(chunks)) + .expect("chunked metadata response") + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, provider).await.expect("serve metadata provider"); + }); + (issuer, handle) +} + +async fn spawn_oidc_provider() -> ( + String, + Vec, + Vec, + std::sync::Arc, + tokio::task::JoinHandle<()>, +) { + spawn_oidc_provider_with_algorithm(KeyAlgorithm::RS256).await +} + +async fn spawn_oidc_provider_with_algorithm( + key_algorithm: KeyAlgorithm, +) -> ( + String, + Vec, + Vec, + std::sync::Arc, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind OIDC provider"); + let issuer = format!("http://{}", listener.local_addr().expect("OIDC provider address")); + let discovery_issuer = issuer.clone(); + let discovery_jwks_uri = format!("{issuer}/jwks"); + let (private_key_der, mut jwk) = test_key(); + jwk["alg"] = Value::String( + match key_algorithm { + KeyAlgorithm::RS256 => "RS256", + KeyAlgorithm::RS512 => "RS512", + _ => panic!("test provider only supports RS256 and RS512 metadata"), + } + .to_owned(), + ); + let public_jwk = serde_json::to_vec(&jwk).expect("serialize public test JWK"); + let jwks_requests = std::sync::Arc::new(AtomicUsize::new(0)); + let observed_jwks_requests = std::sync::Arc::clone(&jwks_requests); + + let provider = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || { + let issuer = discovery_issuer.clone(); + let jwks_uri = discovery_jwks_uri.clone(); + async move { + Json(json!({ + "issuer": issuer, + "jwks_uri": jwks_uri + })) + } + }), + ) + .route( + "/jwks", + get(move || { + let jwk = jwk.clone(); + let observed_jwks_requests = std::sync::Arc::clone(&observed_jwks_requests); + async move { + observed_jwks_requests.fetch_add(1, Ordering::Relaxed); + Json(json!({ + "keys": [jwk] + })) + } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, provider).await.expect("serve OIDC provider"); + }); + + (issuer, private_key_der, public_jwk, jwks_requests, handle) +} + +fn identity_token(issuer: &str, audience: &str, expires_at: u64, kid: &str, private_key_der: &[u8]) -> String { + #[derive(Serialize)] + struct Claims<'a> { + iss: &'a str, + sub: &'a str, + aud: &'a str, + exp: u64, + } + + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_owned()); + encode( + &header, + &Claims { + iss: issuer, + sub: "github-user-123", + aud: audience, + exp: expires_at, + }, + &EncodingKey::from_rsa_der(private_key_der), + ) + .expect("encode test identity token") +} + +fn identity_token_with_audiences( + issuer: &str, + audiences: &[&str], + authorized_party: Option<&str>, + private_key_der: &[u8], +) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("test-key".to_owned()); + encode( + &header, + &json!({ + "iss": issuer, + "sub": "github-user-123", + "aud": audiences, + "azp": authorized_party, + "exp": jsonwebtoken::get_current_timestamp() + 300 + }), + &EncodingKey::from_rsa_der(private_key_der), + ) + .expect("encode multi-audience identity token") +} + +fn custom_identity_token(header: &Header, claims: &Value, private_key_der: &[u8]) -> String { + encode(header, claims, &EncodingKey::from_rsa_der(private_key_der)).expect("encode custom identity token") +} + +fn hmac_identity_token(issuer: &str, audience: &str, secret: &[u8]) -> String { + #[derive(Serialize)] + struct Claims<'a> { + iss: &'a str, + sub: &'a str, + aud: &'a str, + exp: u64, + } + + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test-key".to_owned()); + encode( + &header, + &Claims { + iss: issuer, + sub: "github-user-123", + aud: audience, + exp: jsonwebtoken::get_current_timestamp() + 300, + }, + &EncodingKey::from_secret(secret), + ) + .expect("encode test HMAC identity token") +} + +async fn spawn_models_upstream() -> ( + String, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, +) { + let observed_headers = std::sync::Arc::new(std::sync::Mutex::new(None)); + let captured_headers = std::sync::Arc::clone(&observed_headers); + let upstream = Router::new().route( + "/v1/models", + get(move |headers: HeaderMap| { + let captured_headers = std::sync::Arc::clone(&captured_headers); + async move { + *captured_headers.lock().expect("capture headers") = Some(headers); + Json(json!({"object": "list", "data": []})) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind upstream"); + let address = listener.local_addr().expect("upstream address"); + let handle = tokio::spawn(async move { + axum::serve(listener, upstream).await.expect("serve upstream"); + }); + (format!("http://{address}"), observed_headers, handle) +} + +#[tokio::test] +async fn configured_oidc_rejects_missing_bearer_before_upstream() { + let (issuer, _private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + + let response = reqwest::get(format!("http://{}/v1/models", gateway.address)) + .await + .expect("request gateway"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.expect("JSON error body"); + assert_eq!(body["error"]["code"], "missing_bearer_token"); + + let health = reqwest::get(format!("http://{}/health", gateway.address)) + .await + .expect("request health"); + assert_eq!(health.status(), reqwest::StatusCode::OK); +} + +#[tokio::test] +async fn configured_oidc_rejects_invalid_bearer_before_upstream() { + let (issuer, _private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth("not-a-jwt") + .send() + .await + .expect("request gateway"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.expect("JSON error body"); + assert_eq!(body["error"]["code"], "invalid_token"); + + let messages_response = reqwest::Client::new() + .post(format!("http://{}/v1/messages", gateway.address)) + .bearer_auth("not-a-jwt") + .send() + .await + .expect("request Messages API"); + assert_eq!(messages_response.status(), reqwest::StatusCode::UNAUTHORIZED); + let messages_body = messages_response.json::().await.expect("JSON error body"); + assert_eq!(messages_body["type"], "error"); + assert_eq!(messages_body["error"]["type"], "authentication_error"); +} + +#[tokio::test] +async fn configured_oidc_rejects_hmac_tokens_signed_with_public_key_material() { + let (issuer, _private_key, public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(hmac_identity_token(&issuer, "agentic-api", &public_jwk)) + .send() + .await + .expect("request gateway"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.expect("JSON error body"); + assert_eq!(body["error"]["code"], "invalid_token"); +} + +#[tokio::test] +async fn configured_oidc_rejects_token_and_jwk_algorithm_mismatch() { + let (issuer, private_key, _public_jwk, _jwks_requests, _provider) = + spawn_oidc_provider_with_algorithm(KeyAlgorithm::RS512).await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(identity_token( + &issuer, + TEST_AUDIENCE, + jsonwebtoken::get_current_timestamp() + 300, + "test-key", + &private_key, + )) + .send() + .await + .expect("algorithm-mismatch request"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn configured_oidc_accepts_valid_identity_and_uses_service_upstream_credential() { + let (issuer, private_key_der, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let (upstream_url, observed_headers, _upstream) = spawn_models_upstream().await; + let gateway = spawn_gateway(authenticator, &upstream_url).await; + let identity_token = identity_token( + &issuer, + "agentic-api", + jsonwebtoken::get_current_timestamp() + 300, + "test-key", + &private_key_der, + ); + let mut identity_headers = HeaderMap::new(); + identity_headers.append("x-api-key", HeaderValue::from_static("distinct-upstream-key")); + identity_headers.append( + "x-api-key", + HeaderValue::from_str(&identity_token).expect("identity header value"), + ); + + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(&identity_token) + .headers(identity_headers) + .send() + .await + .expect("request gateway"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let headers = observed_headers + .lock() + .expect("read captured headers") + .clone() + .expect("upstream request"); + assert_eq!( + headers + .get(reqwest::header::AUTHORIZATION) + .expect("service credential") + .to_str() + .expect("valid authorization"), + "Bearer test-key" + ); + assert!( + headers.get("x-api-key").is_none(), + "duplicate identity credential must not reach the upstream" + ); +} + +#[tokio::test] +async fn configured_oidc_rejects_wrong_issuer_audience_and_expired_tokens() { + let (issuer, private_key_der, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + let now = jsonwebtoken::get_current_timestamp(); + let invalid_claims = [ + ("https://other-issuer.example", "agentic-api", now + 300), + (issuer.as_str(), "other-audience", now + 300), + (issuer.as_str(), "agentic-api", now - 120), + ]; + + for (token_issuer, audience, expires_at) in invalid_claims { + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(identity_token( + token_issuer, + audience, + expires_at, + "test-key", + &private_key_der, + )) + .send() + .await + .expect("request gateway"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.expect("JSON error body"); + assert_eq!(body["error"]["code"], "invalid_token"); + } +} + +#[tokio::test] +async fn configured_oidc_rejects_missing_claims_empty_subject_future_nbf_and_bad_signature() { + let (issuer, private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let (other_private_key, _) = test_key_with_id("test-key"); + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + let now = jsonwebtoken::get_current_timestamp(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("test-key".to_owned()); + let valid_claims = json!({ + "iss": issuer, + "sub": "github-user-123", + "aud": "agentic-api", + "exp": now + 300 + }); + let mut missing_kid_header = Header::new(Algorithm::RS256); + missing_kid_header.kid = None; + let cases = [ + custom_identity_token(&missing_kid_header, &valid_claims, &private_key), + custom_identity_token( + &header, + &json!({"iss": issuer, "sub": "", "aud": "agentic-api", "exp": now + 300}), + &private_key, + ), + custom_identity_token( + &header, + &json!({ + "iss": issuer, + "sub": "github-user-123", + "aud": "agentic-api", + "exp": now + 300, + "nbf": now + 300 + }), + &private_key, + ), + custom_identity_token( + &header, + &json!({"iss": issuer, "sub": "github-user-123", "aud": "agentic-api"}), + &private_key, + ), + custom_identity_token(&header, &valid_claims, &other_private_key), + ]; + + for token in cases { + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(token) + .send() + .await + .expect("invalid token request"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + } +} + +#[tokio::test] +async fn unknown_key_ids_do_not_refresh_jwks_during_cooldown() { + let (issuer, private_key_der, _public_jwk, jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + assert_eq!(jwks_requests.load(Ordering::Relaxed), 1); + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + let expires_at = jsonwebtoken::get_current_timestamp() + 300; + + for kid in ["unknown-key-1", "unknown-key-2"] { + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(identity_token( + &issuer, + "agentic-api", + expires_at, + kid, + &private_key_der, + )) + .send() + .await + .expect("request gateway"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + } + + assert_eq!(jwks_requests.load(Ordering::Relaxed), 1); +} + +#[test] +fn oidc_configuration_enforces_secure_endpoints_and_nonempty_audience() { + for issuer in ["https://issuer.example", "http://127.0.0.1:8080", "http://[::1]:8080"] { + OidcConfig::new(issuer, "agentic-api").expect("accepted issuer"); + } + + for issuer in [ + "http://localhost:8080", + "http://192.0.2.1:8080", + "https://issuer.example?tenant=one", + "https://issuer.example#fragment", + ] { + assert!( + OidcConfig::new(issuer, "agentic-api").is_err(), + "{issuer} must be rejected" + ); + } + assert!(OidcConfig::new("https://issuer.example", " \t").is_err()); +} + +#[tokio::test] +async fn discovery_rejects_mismatched_issuer_insecure_jwks_and_oversized_metadata() { + let (issuer, _provider) = spawn_metadata_provider(|_| { + json!({ + "issuer": "https://other-issuer.example", + "jwks_uri": "https://other-issuer.example/jwks" + }) + .to_string() + }) + .await; + assert!(matches!( + OidcAuthenticator::discover(OidcConfig::new(&issuer, TEST_AUDIENCE).expect("OIDC config")).await, + Err(OidcAuthError::IssuerMismatch { .. }) + )); + + let (issuer, _provider) = spawn_metadata_provider(|issuer| { + json!({ + "issuer": issuer, + "jwks_uri": "http://192.0.2.1/jwks" + }) + .to_string() + }) + .await; + assert!(matches!( + OidcAuthenticator::discover(OidcConfig::new(&issuer, TEST_AUDIENCE).expect("OIDC config")).await, + Err(OidcAuthError::InsecureJwksUri) + )); + + let (issuer, _provider) = spawn_metadata_provider(|_| " ".repeat(1024 * 1024 + 1)).await; + assert!(matches!( + OidcAuthenticator::discover(OidcConfig::new(&issuer, TEST_AUDIENCE).expect("OIDC config")).await, + Err(OidcAuthError::ProviderResponseTooLarge) + )); + + let (issuer, _provider) = spawn_chunked_metadata_provider().await; + assert!(matches!( + OidcAuthenticator::discover(OidcConfig::new(&issuer, TEST_AUDIENCE).expect("OIDC config")).await, + Err(OidcAuthError::ProviderResponseTooLarge) + )); +} + +#[tokio::test] +async fn zero_ttl_rotated_jwks_is_coalesced_and_revokes_the_old_key() { + let (issuer, old_private_key, new_private_key, jwks_requests, _provider) = spawn_rotating_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + assert_eq!(jwks_requests.load(Ordering::Relaxed), 1); + let (upstream_url, _observed_headers, _upstream) = spawn_models_upstream().await; + let gateway = spawn_gateway(authenticator, &upstream_url).await; + let expires_at = jsonwebtoken::get_current_timestamp() + 300; + let new_token = identity_token(&issuer, "agentic-api", expires_at, "new-key", &new_private_key); + let client = reqwest::Client::new(); + + let first = client + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(&new_token) + .send(); + let second = client + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(&new_token) + .send(); + let (first, second) = tokio::join!(first, second); + assert_eq!( + first.expect("first rotated-key request").status(), + reqwest::StatusCode::OK + ); + assert_eq!( + second.expect("second rotated-key request").status(), + reqwest::StatusCode::OK + ); + assert_eq!(jwks_requests.load(Ordering::Relaxed), 2); + + let cached = client + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(&new_token) + .send() + .await + .expect("cached rotated-key request"); + assert_eq!(cached.status(), reqwest::StatusCode::OK); + assert_eq!(jwks_requests.load(Ordering::Relaxed), 2); + + let revoked = client + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(identity_token( + &issuer, + "agentic-api", + expires_at, + "old-key", + &old_private_key, + )) + .send() + .await + .expect("revoked-key request"); + assert_eq!(revoked.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn jwks_refresh_failure_returns_protocol_specific_service_errors() { + let (issuer, private_key, jwks_requests, _provider) = spawn_failing_refresh_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + let token = identity_token( + &issuer, + "agentic-api", + jsonwebtoken::get_current_timestamp() + 300, + "test-key", + &private_key, + ); + let client = reqwest::Client::new(); + + let openai_response = client + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(&token) + .send(); + let anthropic_response = client + .post(format!("http://{}/v1/messages", gateway.address)) + .bearer_auth(&token) + .send(); + let (response, anthropic_response) = tokio::join!(openai_response, anthropic_response); + let response = response.expect("OpenAI-style dependency failure"); + assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + assert!(response.headers().get(reqwest::header::WWW_AUTHENTICATE).is_none()); + let body = response.json::().await.expect("OpenAI service error"); + assert_eq!(body["error"]["code"], "authentication_service_unavailable"); + + let response = anthropic_response.expect("Anthropic-style dependency failure"); + assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + let body = response.json::().await.expect("Anthropic service error"); + assert_eq!(body["error"]["type"], "api_error"); + assert_eq!(jwks_requests.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn multi_audience_tokens_require_the_expected_authorized_party() { + let (issuer, private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let (upstream_url, _observed_headers, _upstream) = spawn_models_upstream().await; + let gateway = spawn_gateway(authenticator, &upstream_url).await; + + for (authorized_party, expected_status) in [ + (Some("other-client"), reqwest::StatusCode::UNAUTHORIZED), + (None, reqwest::StatusCode::UNAUTHORIZED), + (Some("agentic-api"), reqwest::StatusCode::OK), + ] { + let response = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(identity_token_with_audiences( + &issuer, + &["agentic-api", "other-client"], + authorized_party, + &private_key, + )) + .send() + .await + .expect("multi-audience request"); + assert_eq!(response.status(), expected_status); + } +} + +#[tokio::test] +async fn authenticated_principal_is_inserted_and_identity_header_is_removed() { + let (issuer, private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let router = Router::new() + .route( + "/v1/principal", + get( + |Extension(principal): Extension, headers: HeaderMap| async move { + Json(json!({ + "issuer": principal.issuer(), + "subject": principal.subject(), + "authorization_present": headers.contains_key(reqwest::header::AUTHORIZATION) + })) + }, + ), + ) + .route_layer(middleware::from_fn_with_state(authenticator, require_oidc)); + let gateway = spawn_router(router).await; + + let response = reqwest::Client::new() + .get(format!("http://{}/v1/principal", gateway.address)) + .bearer_auth(identity_token( + &issuer, + "agentic-api", + jsonwebtoken::get_current_timestamp() + 300, + "test-key", + &private_key, + )) + .send() + .await + .expect("principal request"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.json::().await.expect("principal JSON"); + assert_eq!(body["issuer"], issuer); + assert_eq!(body["subject"], "github-user-123"); + assert_eq!(body["authorization_present"], false); +} + +#[tokio::test] +async fn every_v1_route_rejects_missing_credentials() { + let (issuer, _private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + let client = reqwest::Client::new(); + + for path in [ + "/v1/conversations", + "/v1/messages", + "/v1/messages/count_tokens", + "/v1/responses", + ] { + let response = client + .post(format!("http://{}{path}", gateway.address)) + .send() + .await + .expect("protected POST"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED, "{path}"); + } + let models = client + .get(format!("http://{}/v1/models", gateway.address)) + .send() + .await + .expect("protected models request"); + assert_eq!(models.status(), reqwest::StatusCode::UNAUTHORIZED); + + let websocket_error = tokio_tungstenite::connect_async(format!("ws://{}/v1/responses", gateway.address)) + .await + .expect_err("missing bearer must reject WebSocket upgrade"); + match websocket_error { + tokio_tungstenite::tungstenite::Error::Http(response) => { + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + } + error => panic!("unexpected WebSocket error: {error}"), + } + + let ready = client + .get(format!("http://{}/ready", gateway.address)) + .send() + .await + .expect("public readiness request"); + assert_ne!(ready.status(), reqwest::StatusCode::UNAUTHORIZED); +} diff --git a/docs/api/index.md b/docs/api/index.md index 7220372c..57ff19e2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,5 +1,45 @@ # API Reference +## Authentication + +Inbound authentication is optional. When the gateway starts with both `OIDC_ISSUER` and `OIDC_AUDIENCE`, every +`/v1/*` HTTP route and the `/v1/responses` WebSocket upgrade require an OIDC `Authorization: Bearer `. +`/health` and `/ready` remain public. Supplying only one OIDC setting is a startup error. + +The gateway validates the token signature, issuer, audience, authorized party for multi-audience tokens, subject, +expiration, and not-before time. It consumes the identity token at the gateway boundary instead of forwarding it to +the inference service. WebSocket sessions reject new `response.create` messages after the validated token expires. + +Missing or rejected credentials return `401 Unauthorized` with `WWW-Authenticate: Bearer`. OpenAI-compatible routes +use this envelope: + +```json +{ + "error": { + "message": "invalid bearer token", + "type": "authentication_error", + "param": null, + "code": "invalid_token" + } +} +``` + +`/v1/messages` and `/v1/messages/count_tokens` use the Anthropic-compatible envelope: + +```json +{ + "type": "error", + "error": { + "type": "authentication_error", + "message": "invalid bearer token" + } +} +``` + +A JWKS refresh failure returns `503 Service Unavailable`, without `WWW-Authenticate`, so clients can distinguish an +identity-provider dependency failure from rejected credentials. See +[OIDC bearer authentication](../design/oidc-bearer-authentication.md) for configuration and key-cache behavior. + ## Responses ### `POST /v1/responses` diff --git a/docs/deploying/container.md b/docs/deploying/container.md index 73866d72..3cb534fb 100644 --- a/docs/deploying/container.md +++ b/docs/deploying/container.md @@ -37,6 +37,8 @@ The image starts `agentic-server` in standalone mode. At minimum, set `LLM_API_B | `POSTGRES_IDLE_TIMEOUT_SECONDS` | `600` | Recycle idle PostgreSQL connections; `0` disables | | `POSTGRES_MAX_LIFETIME_SECONDS` | `1800` | Recycle PostgreSQL connections after this lifetime; `0` disables | | `OPENAI_API_KEY` | none | Credential sent to the upstream service when the client does not supply one | +| `OIDC_ISSUER` | none | Optional OIDC issuer for inbound bearer-token authentication | +| `OIDC_AUDIENCE` | none | Required token audience when `OIDC_ISSUER` is set | | `SKIP_LLM_READY_CHECK` | `false` | Skip the startup probe for hosted providers without `/health` | | `CORS_ALLOWED_ORIGINS` | none | Comma-separated browser origins | @@ -53,7 +55,25 @@ docker run --rm --name agentic-api \ agentic-api:dev ``` -The gateway does not provide inbound client authentication. `OPENAI_API_KEY` is an upstream credential, not a password for callers, so keep the port bound to loopback unless an authenticated ingress or proxy protects it. +Inbound authentication is disabled by default. `OPENAI_API_KEY` is an upstream credential, not a password for callers, +so keep the port bound to loopback unless an authenticated ingress protects it or OIDC is enabled. + +To enable OIDC bearer authentication, configure both variables: + +```console +docker run --rm --name agentic-api \ + --publish 127.0.0.1:9000:9000 \ + --env LLM_API_BASE=https://vllm.example.com \ + --env OPENAI_API_KEY \ + --env OIDC_ISSUER=https://identity.example.com \ + --env OIDC_AUDIENCE=agentic-api \ + agentic-api:dev +``` + +The gateway discovers the provider and its JSON Web Key Set before listening. `/health` and `/ready` remain public; +all `/v1/*` routes then require an OIDC `Authorization: Bearer` token. The identity token is consumed by the gateway, +and `OPENAI_API_KEY` supplies the upstream inference credential. See +[OIDC bearer authentication](../design/oidc-bearer-authentication.md) for the validation and key-rotation contract. If the upstream is running on the Docker host, use `http://host.docker.internal:` on Docker Desktop. On Linux, add `--add-host host.docker.internal:host-gateway`. diff --git a/docs/design/oidc-bearer-authentication.md b/docs/design/oidc-bearer-authentication.md new file mode 100644 index 00000000..fedf599d --- /dev/null +++ b/docs/design/oidc-bearer-authentication.md @@ -0,0 +1,67 @@ +# OIDC bearer authentication + +## Scope + +vLLM Agentic API can optionally authenticate API callers with JSON Web Tokens issued by an OpenID Connect (OIDC) +provider. This is the first authentication slice for [issue #104](https://github.com/vllm-project/agentic-api/issues/104): +it establishes a verified principal at the HTTP boundary without adding a browser login, callback, or server-side +session. + +The bearer-token model works with API clients such as Codex and Claude Code. GitHub login can be supplied by an OIDC +provider configured to federate GitHub identities; the gateway does not add GitHub-specific authorization logic. + +## Configuration and startup + +Authentication is disabled unless both `OIDC_ISSUER` and `OIDC_AUDIENCE` are set. Supplying only one is a startup +error. When enabled, the gateway: + +1. fetches the issuer's `/.well-known/openid-configuration` document without following redirects; +2. requires the discovered issuer to match the configured issuer; +3. fetches and caches the JSON Web Key Set (JWKS); +4. refuses to listen if discovery or the initial JWKS request fails. + +Issuer and JWKS URLs must use HTTPS. HTTP is accepted only for literal loopback IP addresses (`127.0.0.1` or `::1`) +in local tests and development, and an HTTPS issuer cannot redirect JWKS retrieval to loopback HTTP. Provider +responses are limited to 1 MiB and JWKS documents to 100 keys. + +Verification keys are cached for the provider's `Cache-Control: max-age` duration, capped at one hour, or five minutes +when no cache lifetime is supplied. A stale cache is refreshed before a cached key is accepted, so a provider can +remove a compromised key without requiring a gateway restart. Unknown key IDs can trigger at most one refresh per +30-second cooldown after a completed fetch. Refreshes are single-flight, and every successfully fetched key set is +installed even when it does not contain the key requested by the triggering token. +Concurrent refresh waiters reuse the completed result. After a refresh failure, another provider request is suppressed +for 30 seconds and callers receive `503 Service Unavailable`; a one-second coalescing window also prevents a +provider-supplied zero-second cache lifetime from causing one fetch per concurrent request. + +## Request boundary + +`/health` and `/ready` remain public so orchestrators can probe the process. Every `/v1/*` route requires +`Authorization: Bearer ` when OIDC is enabled, including HTTP streaming and the Responses WebSocket upgrade. +`/ready` continues to report inference-service readiness; it does not treat a temporary identity-provider refresh +failure as a reason to remove an otherwise healthy gateway from service. Those request-time dependency failures +return `503 Service Unavailable` as described above. + +The gateway verifies: + +- an asymmetric token signing algorithm and a signature from the provider JWKS; +- a signing key whose `kid`, `alg`, `use`, and `key_ops` permit verification; +- required `iss`, `aud`, `sub`, and `exp` claims; +- issuer and audience equality, plus `azp` equality when a token has multiple audiences; +- expiration and, when present, the not-before time. + +Successful authentication inserts the stable issuer and subject pair into request extensions as the authenticated +principal. Tenant and persisted-state authorization remain follow-up work under +[issue #107](https://github.com/vllm-project/agentic-api/issues/107). + +For WebSockets, authentication occurs during the HTTP upgrade. The validated expiration is retained with the +principal, and the gateway rejects new `response.create` messages after the token expires (including clock skew). + +## Credential separation + +The verified identity token is consumed at the gateway and is never forwarded to the inference service. OpenAI-style +upstream requests use `OPENAI_API_KEY` after authentication removes the inbound `Authorization` header. +Anthropic-compatible requests may continue to supply an upstream `x-api-key`; otherwise they also fall back to +`OPENAI_API_KEY`. + +This separation prevents an OIDC identity token from being mistaken for an inference-provider credential. Deployments +that do not enable OIDC retain the existing pass-through behavior for client credentials. From 6feb0bafd46453f39c8479e1571d2fb7b5617b75 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 05:10:26 -0400 Subject: [PATCH 02/13] fix: protect compact responses with OIDC Signed-off-by: Francisco Javier Arceo --- crates/agentic-server/src/app.rs | 3 ++- crates/agentic-server/tests/oidc_auth_test.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/agentic-server/src/app.rs b/crates/agentic-server/src/app.rs index 83a0273c..e84bd28d 100644 --- a/crates/agentic-server/src/app.rs +++ b/crates/agentic-server/src/app.rs @@ -137,7 +137,8 @@ pub fn build_router_with_auth( .route("/v1/models", get(models)) .route(ANTHROPIC_MESSAGES_PATH, post(messages)) .route(ANTHROPIC_COUNT_TOKENS_PATH, post(count_tokens)) - .route("/v1/responses", post(responses).get(responses_ws_with_auth)); + .route("/v1/responses", post(responses).get(responses_ws_with_auth)) + .route("/v1/responses/compact", post(compact_response)); let protected_routes = match authenticator { Some(authenticator) => { protected_routes.route_layer(middleware::from_fn_with_state(authenticator, require_oidc)) diff --git a/crates/agentic-server/tests/oidc_auth_test.rs b/crates/agentic-server/tests/oidc_auth_test.rs index c5396fb9..a5d8e415 100644 --- a/crates/agentic-server/tests/oidc_auth_test.rs +++ b/crates/agentic-server/tests/oidc_auth_test.rs @@ -863,6 +863,7 @@ async fn every_v1_route_rejects_missing_credentials() { "/v1/messages", "/v1/messages/count_tokens", "/v1/responses", + "/v1/responses/compact", ] { let response = client .post(format!("http://{}{path}", gateway.address)) From 946f5ffaf396bb55307cea706e8c628ab700cae2 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 05:30:58 -0400 Subject: [PATCH 03/13] test: isolate CLI OIDC environment Signed-off-by: Francisco Javier Arceo --- crates/agentic-server/tests/cli_test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/agentic-server/tests/cli_test.rs b/crates/agentic-server/tests/cli_test.rs index 413eb308..5f8ef143 100644 --- a/crates/agentic-server/tests/cli_test.rs +++ b/crates/agentic-server/tests/cli_test.rs @@ -4,6 +4,8 @@ use std::process::Command; fn missing_llm_api_base_error_mentions_environment_and_flag() { let output = Command::new(env!("CARGO_BIN_EXE_agentic-server")) .env_remove("LLM_API_BASE") + .env_remove("OIDC_ISSUER") + .env_remove("OIDC_AUDIENCE") .output() .expect("agentic-server must run"); From be01882ebc6b99f3e5a25ec79d73ed87728281a8 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 14:42:50 -0400 Subject: [PATCH 04/13] docs: design GitHub OIDC deployment tutorial Signed-off-by: Francisco Javier Arceo --- ...-github-oidc-deployment-tutorial-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-github-oidc-deployment-tutorial-design.md diff --git a/docs/superpowers/specs/2026-07-31-github-oidc-deployment-tutorial-design.md b/docs/superpowers/specs/2026-07-31-github-oidc-deployment-tutorial-design.md new file mode 100644 index 00000000..278147e1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-github-oidc-deployment-tutorial-design.md @@ -0,0 +1,104 @@ +# GitHub OIDC deployment tutorial design + +## Goal + +Add a user-facing deployment tutorial that explains how to authenticate agentic-api callers with GitHub identities +through an OpenID Connect (OIDC) broker. The guide must be runnable, distinguish GitHub OAuth from OIDC, and never +embed real credentials or personal identity data. + +## Audience and placement + +The primary audience is an operator deploying agentic-api who wants GitHub-backed user authentication. The tutorial +will live at `docs/deploying/github-oidc.md` and appear in the MkDocs navigation as **Deploying → GitHub authentication +with Dex**. + +The page will be linked from: + +- the OIDC section of `docs/deploying/container.md`; +- the authentication section of `docs/api/index.md`; and +- the Codex and Claude Code authentication examples in `README.md`. + +## Reader model + +The tutorial will establish this data flow before presenting commands: + +```text +GitHub OAuth → Dex OIDC ID token → agentic-api bearer validation +``` + +It will explain that GitHub API and OAuth access tokens are not OIDC ID tokens and therefore cannot be passed directly +to agentic-api. Dex is the concrete, tested broker in the walkthrough, but agentic-api accepts tokens from any OIDC +provider that satisfies its documented issuer, audience, asymmetric-signature, and claim requirements. + +The guide will also state that this feature authenticates a principal at the request boundary. Tenant isolation and +persisted-state authorization remain separate work under issue #107. + +## Tutorial structure + +The tutorial will lead the reader through these steps: + +1. Choose the Dex issuer, agentic-api audience, and OIDC client callback URL. +2. Register a GitHub OAuth App whose authorization callback is exactly the Dex issuer plus `/callback`. +3. Configure the Dex GitHub connector with the OAuth App client ID and client secret supplied through environment + variables or a secret manager. +4. Register a public Dex client that uses the authorization-code flow with Proof Key for Code Exchange (PKCE). +5. Start agentic-api with matching `OIDC_ISSUER` and `OIDC_AUDIENCE` values while keeping `OPENAI_API_KEY` as the + separate inference-service credential. +6. Obtain an OIDC ID token with `oauth2c`, emitting only the `id_token` when used as a credential helper. +7. Configure Codex command-backed bearer authentication or Claude Code's `ANTHROPIC_AUTH_TOKEN`. +8. Verify that an unauthenticated `/v1/*` request returns `401`, the GitHub-authenticated request succeeds, and the + identity token is not forwarded to the inference service. + +## Development and production guidance + +The page will include a runnable loopback Docker example for validation. That example may use HTTP loopback addresses +and Dex's in-memory storage, and it will be labelled as development-only. + +The production section will require: + +- an HTTPS Dex issuer and HTTPS callback URL; +- persistent, backed-up Dex storage appropriate to the deployment topology; +- runtime secret injection instead of image layers, build arguments, checked-in files, or shell history; +- GitHub OAuth App secret rotation; +- a stable, deployment-specific audience; +- optional GitHub organization or team restrictions when the deployment needs them; and +- normal network and ingress controls around both Dex and agentic-api. + +## Secret and privacy boundaries + +Every example will use placeholders or environment-variable references. The tutorial will not contain: + +- the temporary OAuth App ID or client ID used during validation; +- a GitHub client secret, Dex token, refresh token, authorization code, or PKCE verifier; +- personal names, usernames, email addresses, subject identifiers, or other token claims; or +- machine-specific temporary paths and local database contents. + +The guide will explicitly warn readers not to print tokens in shared logs and not to confuse the inbound identity token +with `OPENAI_API_KEY`, which is an upstream service credential. + +## Troubleshooting + +Troubleshooting will cover the failure modes observed or validated during the live test and implementation review: + +- the GitHub OAuth App callback must be the Dex issuer plus `/callback`; +- Dex discovery issuer and agentic-api `OIDC_ISSUER` must match exactly; +- the ID token audience must equal `OIDC_AUDIENCE`; +- GitHub API or OAuth access tokens are rejected because they are not Dex-issued OIDC ID tokens; +- HTTP issuers are accepted only for literal loopback addresses; and +- an OIDC identity token must be consumed by the gateway rather than forwarded upstream. + +## Documentation verification + +The implementation will be verified with: + +- a placeholder and secret-pattern scan over the new and modified documentation; +- a link and navigation review; +- `uv run --with-requirements docs/requirements.txt mkdocs build`; +- `uvx pre-commit run --all-files`; and +- a final diff review confirming that no generated credentials or personal test data were committed. + +## Out of scope + +This documentation change will not add a browser callback to agentic-api, ship or support a repository-owned token +helper, prescribe Dex as the only supported broker, document a complete highly available Dex deployment, or implement +tenant authorization from issue #107. From 103f1eef132f5344e218a73701df0a2707e67ccf Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 14:47:03 -0400 Subject: [PATCH 05/13] docs: plan GitHub OIDC deployment tutorial Signed-off-by: Francisco Javier Arceo --- ...6-07-31-github-oidc-deployment-tutorial.md | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md diff --git a/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md b/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md new file mode 100644 index 00000000..421386d1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md @@ -0,0 +1,558 @@ +# GitHub OIDC Deployment Tutorial Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish a secret-free, copy-pasteable deployment tutorial for authenticating agentic-api callers with +GitHub identities through Dex-issued OpenID Connect (OIDC) ID tokens. + +**Architecture:** Add one focused deployment page that explains the generic OIDC contract and then walks through the +validated GitHub OAuth → Dex OIDC → agentic-api flow. Wire that page into the existing navigation and authentication +entry points without changing runtime code or expanding into tenant authorization. + +**Tech Stack:** MkDocs Material, Markdown, Docker, Dex v2.45.1, GitHub OAuth Apps, `oauth2c`, `jq`, Codex, Claude Code + +## Global Constraints + +- The tutorial lives at `docs/deploying/github-oidc.md` and appears as **Deploying → GitHub authentication with Dex**. +- Explain the generic OIDC contract before using Dex as the concrete, tested broker. +- Use the flow `GitHub OAuth → Dex OIDC ID token → agentic-api bearer validation`. +- State that GitHub API and OAuth access tokens are not OIDC ID tokens and are not accepted directly. +- State that authentication establishes a principal at the request boundary; tenant isolation remains issue #107. +- Use HTTP loopback and Dex in-memory storage only in a development-only example. +- Require HTTPS, persistent backed-up storage, runtime secret injection, secret rotation, a stable audience, and normal + ingress controls for production. +- Do not include any generated client ID, client secret, token, authorization code, PKCE verifier, personal identity + claim, machine-specific temporary path, or local database content. +- Keep `OPENAI_API_KEY` documented as the upstream inference-service credential, separate from the inbound OIDC ID + token. +- Do not add a gateway browser callback, repository-owned token helper, Dex-only requirement, complete highly + available Dex deployment, or tenant authorization. +- Follow the preferred prose in `TERMINOLOGY.md` and preserve exact protocol field names. +- Keep Markdown lines within the repository's 120-character formatting convention. + +--- + +## File Map + +- Create `docs/deploying/github-oidc.md`: owns the complete GitHub-backed OIDC deployment walkthrough, development + example, client setup, production guidance, and troubleshooting. +- Modify `mkdocs.yaml`: adds the new tutorial to the Deploying navigation. +- Modify `docs/deploying/container.md`: routes operators from the container OIDC settings to the runnable tutorial. +- Modify `docs/api/index.md`: routes API readers from the authentication contract to the runnable tutorial. +- Modify `README.md`: routes Codex and Claude Code users from their OIDC snippets to the complete setup. +- Preserve `docs/superpowers/specs/2026-07-31-github-oidc-deployment-tutorial-design.md`: this is the approved design + record and is not implementation content. + +### Task 1: Author the GitHub and Dex deployment tutorial + +**Files:** + +- Create: `docs/deploying/github-oidc.md` +- Reference: `docs/design/oidc-bearer-authentication.md` +- Reference: `docs/deploying/container.md` +- Reference: `TERMINOLOGY.md` + +**Interfaces:** + +- Consumes: the existing `OIDC_ISSUER`, `OIDC_AUDIENCE`, and `OPENAI_API_KEY` configuration contract. +- Produces: a page with stable sections for architecture, local validation, Codex, Claude Code, production, and + troubleshooting that the navigation and cross-links in Task 2 target. + +- [ ] **Step 1: Confirm the runtime contract and external command syntax** + +Read the local OIDC contract and the official sources used by the walkthrough: + +```bash +sed -n '1,260p' docs/design/oidc-bearer-authentication.md +``` + +Use these primary sources for claims about external tools: + +- `https://dexidp.io/docs/connectors/github/` +- `https://dexidp.io/docs/getting-started/` +- `https://github.com/SecureAuthCorp/oauth2c` +- `https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app` + +Confirm these exact `oauth2c` flags remain supported before publishing the command: + +```bash +oauth2c --help | + rg -- '--auth-method|--client-id|--grant-type|--pkce|--redirect-url' +oauth2c --help | + rg -- '--response-mode|--response-types|--scopes|--silent' +``` + +Expected: every flag is listed; `--redirect-url` documents `http://localhost:9876/callback` as the default. + +- [ ] **Step 2: Write the architecture and security-boundary introduction** + +Create `docs/deploying/github-oidc.md` with this opening structure and meaning: + +```markdown +# Authenticate with GitHub through Dex + +Use GitHub identities to authenticate callers by placing an OpenID Connect (OIDC) provider such as Dex in front of +agentic-api: + +```text +GitHub OAuth → Dex OIDC ID token → agentic-api bearer validation +``` + +GitHub's API and OAuth access tokens are not OIDC ID tokens, so callers cannot send them directly to agentic-api. +Dex is the tested provider in this walkthrough, but any OIDC provider can be used when its issuer, audience, +asymmetric signing keys, and claims satisfy the gateway's validation contract. +``` + +Immediately follow the introduction with an admonition that says: + +- this authenticates the caller at the request boundary; +- tenant isolation and persisted-state authorization are separate work tracked by issue #107; +- the inbound ID token is consumed by agentic-api and must not be forwarded to the inference service; and +- `OPENAI_API_KEY` is the separate upstream inference credential. + +Link “validation contract” to `../design/oidc-bearer-authentication.md` and issue #107 to +`https://github.com/vllm-project/agentic-api/issues/107`. + +- [ ] **Step 3: Add prerequisites and choose the three local values** + +Document Docker, a running inference endpoint, `oauth2c`, and `jq` as prerequisites. Install `oauth2c` using the +officially documented package command for the reader's platform rather than a repository script. + +Use this development-only value table: + +| Setting | Development value | Used by | +| --- | --- | --- | +| Dex issuer | `http://127.0.0.1:5556/dex` | Dex and `OIDC_ISSUER` | +| OIDC audience/client ID | `agentic-api-local` | Dex, `oauth2c`, and `OIDC_AUDIENCE` | +| OIDC client callback | `http://localhost:9876/callback` | Dex and `oauth2c` | +| GitHub OAuth callback | `http://127.0.0.1:5556/dex/callback` | GitHub and the Dex connector | + +Label every HTTP address and in-memory setting in this section as loopback-only and development-only. + +- [ ] **Step 4: Document GitHub OAuth App registration without embedding credentials** + +Tell the operator to create a GitHub OAuth App with: + +```text +Homepage URL: http://127.0.0.1:5556/dex +Authorization callback URL: http://127.0.0.1:5556/dex/callback +``` + +Explain that the GitHub callback is the Dex issuer plus `/callback`, not the `oauth2c` callback. Tell the operator to +provide the generated client ID and secret to Dex through the environment or a secret manager and never place the +secret in the repository, image, build arguments, command line, or shell history. + +For the local shell, show interactive environment setup that does not echo the secret: + +```bash +printf 'GitHub OAuth App client ID: ' +IFS= read -r GITHUB_CLIENT_ID +printf 'GitHub OAuth App client secret: ' +IFS= read -rs GITHUB_CLIENT_SECRET +printf '\n' +export GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET +``` + +- [ ] **Step 5: Add the complete development-only Dex configuration** + +Provide a `dex.local.yaml` example containing no literal secret: + +```yaml +issuer: http://127.0.0.1:5556/dex + +storage: + type: memory + +web: + http: 0.0.0.0:5556 + +connectors: + - type: github + id: github + name: GitHub + config: + clientID: $GITHUB_CLIENT_ID + clientSecret: $GITHUB_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + +staticClients: + - id: agentic-api-local + name: agentic-api local + public: true + redirectURIs: + - http://localhost:9876/callback +``` + +Explain that Dex expands the two environment references at runtime and that `public: true` enables a public client +using authorization code plus Proof Key for Code Exchange (PKCE), so there is no client secret in the caller. + +Run the pinned image with the config mounted read-only: + +```bash +docker run --rm --name agentic-api-dex \ + --publish 127.0.0.1:5556:5556 \ + --env GITHUB_CLIENT_ID \ + --env GITHUB_CLIENT_SECRET \ + --volume "$PWD/dex.local.yaml:/etc/dex/config.yaml:ro" \ + ghcr.io/dexidp/dex:v2.45.1 dex serve /etc/dex/config.yaml +``` + +State that the in-memory store loses sessions and signing state on restart and is unsuitable for production. + +- [ ] **Step 6: Document matching agentic-api configuration and credential separation** + +Show the gateway process with a service credential supplied independently of OIDC: + +```bash +export OPENAI_API_KEY +cargo run -p agentic-server -- \ + --llm-api-base http://127.0.0.1:5050 \ + --oidc-issuer http://127.0.0.1:5556/dex \ + --oidc-audience agentic-api-local +``` + +Before finalizing the page, run `cargo run -p agentic-server -- --help` and confirm the three option names shown above +match the current binary. Explain that gateway startup performs OIDC discovery and JWKS loading before it begins +listening. + +- [ ] **Step 7: Add the PKCE login and token handling commands** + +Obtain an ID token with a public client and only place the ID token in the `OIDC_TOKEN` variable: + +```bash +OIDC_TOKEN="$( + oauth2c http://127.0.0.1:5556/dex \ + --client-id agentic-api-local \ + --response-types code \ + --response-mode query \ + --grant-type authorization_code \ + --auth-method none \ + --scopes openid,email,profile \ + --redirect-url http://localhost:9876/callback \ + --pkce \ + --silent | + jq -er '.id_token' +)" +``` + +Explain that `oauth2c` opens the GitHub login in a browser and listens only for its own callback. Warn readers not to +print, log, paste, or commit the token, complete token response, authorization code, or PKCE verifier. Do not decode +claims in the tutorial. + +- [ ] **Step 8: Add the three verification checks** + +First verify the unauthenticated boundary: + +```bash +curl -i http://127.0.0.1:9000/v1/models +``` + +Expected: `401 Unauthorized` with `WWW-Authenticate: Bearer`. + +Then verify the GitHub-authenticated request: + +```bash +curl --fail-with-body \ + --header "Authorization: Bearer $OIDC_TOKEN" \ + http://127.0.0.1:9000/v1/models +``` + +Expected: an upstream model-list response rather than an authentication error. + +Finally explain how to verify credential separation with an inference-service access log or mock: the upstream must +receive `OPENAI_API_KEY`, never `$OIDC_TOKEN`. Do not suggest printing either credential. + +- [ ] **Step 9: Add Codex and Claude Code client sections** + +For Codex, create a user-owned executable helper outside the repository. The helper must send only the ID token to +stdout: + +```sh +#!/usr/bin/env sh +set -eu + +oauth2c http://127.0.0.1:5556/dex \ + --client-id agentic-api-local \ + --response-types code \ + --response-mode query \ + --grant-type authorization_code \ + --auth-method none \ + --scopes openid,email,profile \ + --redirect-url http://localhost:9876/callback \ + --pkce \ + --silent | + jq -er '.id_token' +``` + +Use that helper with the current README's supported configuration: + +```toml +[model_providers.agentic-api.auth] +command = "/absolute/path/to/print-oidc-token" +args = [] +refresh_interval_ms = 300000 +``` + +Explain that this development helper initiates an interactive browser flow; production operators should use their +provider's secure refresh or credential-helper workflow and keep refresh tokens out of repository files and logs. + +For Claude Code, use the token already obtained in the current shell: + +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:9000 +export ANTHROPIC_AUTH_TOKEN="$OIDC_TOKEN" +unset ANTHROPIC_API_KEY + +claude -p "summarize the files in this directory" +``` + +Explain that `ANTHROPIC_AUTH_TOKEN` must be refreshed before expiry and must not also be supplied as +`ANTHROPIC_API_KEY`. + +- [ ] **Step 10: Add production guidance and troubleshooting** + +Add a production checklist requiring: + +- an HTTPS Dex issuer and HTTPS callbacks; +- a persistent, backed-up Dex datastore appropriate to the deployment topology; +- secret-manager injection at runtime, never image layers, build arguments, checked-in files, or shell history; +- rotation of the GitHub OAuth App secret; +- a stable, deployment-specific audience shared only by intended callers; +- optional Dex `orgs` and `teams` restrictions when deployment policy requires them; and +- network and ingress controls around Dex and agentic-api. + +Add a troubleshooting section with these exact diagnoses: + +- **GitHub rejects the callback:** the GitHub OAuth App callback is the Dex issuer plus `/callback`. +- **Discovery or startup fails:** Dex discovery's `issuer` must exactly equal `OIDC_ISSUER`, including scheme, host, + port, and path. +- **Token is rejected:** the ID token `aud` must include `OIDC_AUDIENCE`; a GitHub access token is not a substitute. +- **HTTP issuer is rejected:** HTTP is allowed only for literal loopback issuers; use HTTPS elsewhere. +- **Upstream sees the identity token:** configure `OPENAI_API_KEY` separately and stop forwarding the identity token. + +Link the relevant rows to the local OIDC design and the official Dex GitHub connector documentation. + +- [ ] **Step 11: Run focused content and privacy checks** + +Run: + +```bash +rg -n '^## ' docs/deploying/github-oidc.md +rg -n 'GitHub OAuth|Dex OIDC ID token|OIDC_ISSUER|OIDC_AUDIENCE|OPENAI_API_KEY|issue #107|PKCE' \ + docs/deploying/github-oidc.md +rg -n '(gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)' \ + docs/deploying/github-oidc.md +git diff --check +``` + +Expected: all required concepts appear; the credential/JWT scan prints no matches; `git diff --check` exits zero. +Manually inspect the diff for personal names, usernames, email addresses, subject claims, generated IDs, local database +paths, and tokens. Remove any such value before committing. + +- [ ] **Step 12: Commit the standalone tutorial** + +```bash +git add docs/deploying/github-oidc.md +git commit -s -m "docs: add GitHub OIDC deployment tutorial" +``` + +### Task 2: Integrate the tutorial into the documentation entry points + +**Files:** + +- Modify: `mkdocs.yaml:92-101` +- Modify: `docs/deploying/container.md:70-85` +- Modify: `docs/api/index.md:1-35` +- Modify: `README.md:127-174` + +**Interfaces:** + +- Consumes: `docs/deploying/github-oidc.md` from Task 1. +- Produces: discoverable navigation and cross-links from every OIDC configuration surface named in the approved spec. + +- [ ] **Step 1: Add the MkDocs navigation entry** + +Change the Deploying navigation to: + +```yaml + - Deploying: + - Container: deploying/container.md + - GitHub authentication with Dex: deploying/github-oidc.md +``` + +- [ ] **Step 2: Link the container guide to the runnable tutorial** + +After the existing OIDC validation-contract link in `docs/deploying/container.md`, add a sentence with this intent: + +```markdown +For a runnable GitHub-backed setup, follow [GitHub authentication with Dex](github-oidc.md). +``` + +Keep the design link for protocol behavior and the tutorial link for deployment steps. + +- [ ] **Step 3: Link the API authentication section to the runnable tutorial** + +After the validation-contract link in `docs/api/index.md`, add: + +```markdown +For a complete GitHub-backed deployment example, see +[GitHub authentication with Dex](../deploying/github-oidc.md). +``` + +- [ ] **Step 4: Link both README client examples to the tutorial** + +After the Codex OIDC credential-helper paragraph, add: + +```markdown +See [GitHub authentication with Dex](docs/deploying/github-oidc.md) for a complete GitHub login, token-helper, and +gateway setup. +``` + +After the Claude Code bearer-token paragraph, add: + +```markdown +The same [GitHub authentication with Dex](docs/deploying/github-oidc.md) guide shows how to obtain the ID token +without embedding a client secret in Claude Code. +``` + +- [ ] **Step 5: Build the documentation and inspect navigation/link output** + +```bash +uv run --with-requirements docs/requirements.txt mkdocs build +``` + +Expected: exit zero. Review every warning and verify no new warning points to `deploying/github-oidc.md` or a link +added by this task. Pre-existing unrelated warnings may remain but must be identified in the final test report. + +- [ ] **Step 6: Run repository documentation hooks and diff checks** + +```bash +uvx pre-commit run --all-files +git diff --check +git diff -- README.md mkdocs.yaml docs/api/index.md docs/deploying/container.md docs/deploying/github-oidc.md +``` + +Expected: pre-commit and `git diff --check` pass. Review the rendered-link destinations, exact navigation label, +credential separation language, and absence of real secrets or personal test data. + +- [ ] **Step 7: Commit navigation and cross-links** + +```bash +git add README.md mkdocs.yaml docs/api/index.md docs/deploying/container.md +git commit -s -m "docs: link GitHub OIDC deployment guide" +``` + +### Task 3: Run the required review gates and update PR #149 + +**Files:** + +- Review: every file changed since `origin/main` +- Modify only when feedback is actionable and within issue #102. +- Update: existing GitHub PR `https://github.com/vllm-project/agentic-api/pull/149` + +**Interfaces:** + +- Consumes: the tutorial and documentation integration from Tasks 1 and 2. +- Produces: a reviewed, verified, secret-free update to the existing OIDC pull request; do not open a second PR. + +- [ ] **Step 1: Apply the Rust guidance during self-review** + +Read `/Users/farceo/.agents/skills/rust-skills/SKILL.md` completely. Although this slice is documentation-only, +confirm the tutorial matches the implemented Rust configuration, authentication boundary, error behavior, and +credential forwarding behavior. Do not change Rust unless the documentation exposes an in-scope correctness defect. + +- [ ] **Step 2: Run the read-only Claude review** + +Read `/Users/farceo/.codex/skills/claude-review/SKILL.md` completely and run its worktree review against the diff from +`origin/main`. Record every finding and classify it as actionable, already addressed, out of scope, or incorrect with +specific evidence. + +- [ ] **Step 3: Run the gstack pre-landing review** + +Read `/Users/farceo/.agents/skills/gstack/review/SKILL.md` completely and run the prescribed pre-landing review against +the same diff. Pay particular attention to credential leakage, misleading production advice, broken commands, broken +links, and scope expansion into issue #107. + +- [ ] **Step 4: Resolve all actionable review feedback** + +For each actionable finding: + +1. Reproduce or verify it against the documentation and current binary. +2. Make the smallest in-scope correction. +3. Re-run the focused command, link, privacy, or docs-build check that proves the correction. +4. Re-run the relevant review until it reports no unresolved actionable findings. + +If fixes change files, commit them separately: + +```bash +git add README.md mkdocs.yaml docs/api/index.md docs/deploying/container.md docs/deploying/github-oidc.md +git commit -s -m "docs: address OIDC tutorial review" +``` + +Do not broaden the PR into tenant authorization, a token-helper implementation, or a production Dex platform. + +- [ ] **Step 5: Run final local verification** + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test +uv run --with-requirements docs/requirements.txt mkdocs build +uvx pre-commit run --all-files +git diff --check origin/main...HEAD +git status --short +``` + +Expected: all commands pass and `git status --short` is empty. Review all MkDocs warnings and confirm none were +introduced by this tutorial. + +Run the final secret/privacy scan over the complete OIDC branch diff: + +```bash +git diff origin/main...HEAD | rg -n \ + '(gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)' +``` + +Expected: no output. Manually confirm that the diff contains no real client ID, secret, token, authorization code, +PKCE verifier, personal identity claim, machine-specific temporary path, or local database content. + +- [ ] **Step 6: Push the reviewed commits and update the existing PR description** + +```bash +git push origin codex/issue-102-oidc-auth +``` + +Update PR #149 rather than creating another PR. Preserve its existing OIDC implementation summary, then add: + +```markdown +- documents a tested GitHub → Dex → agentic-api deployment flow, including Codex and Claude Code bearer setup +``` + +Add these exact categories to its **Test Plan** with the real final results: + +```markdown +- live local GitHub OAuth login through Dex v2.45.1 using authorization code with PKCE +- unauthenticated and authenticated `/v1/models` checks, plus authenticated `/v1/responses/compact` +- upstream credential-separation check proving the OIDC ID token was not forwarded +- `uv run --with-requirements docs/requirements.txt mkdocs build` +- `uvx pre-commit run --all-files` +``` + +Do not include any token, generated identifier, user claim, OAuth App secret, or local temporary path in the PR body. + +- [ ] **Step 7: Verify GitHub state and report the outcome** + +```bash +gh pr view 149 --json url,state,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup +``` + +Expected: PR #149 targets `main`, uses `codex/issue-102-oidc-auth`, and contains the pushed documentation commits. +Wait for required checks, investigate failures caused by this branch, and report: + +- what documentation changed; +- the live GitHub/Dex checks already completed; +- all final local checks; +- Claude and gstack findings and how each actionable item was resolved; +- the overlap audit result for issue #102; and +- the PR URL. From 26cb0b66409f6fb427730feb57751d16e65907ae Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 14:54:22 -0400 Subject: [PATCH 06/13] docs: add GitHub OIDC deployment tutorial Signed-off-by: Francisco Javier Arceo --- docs/deploying/github-oidc.md | 262 ++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/deploying/github-oidc.md diff --git a/docs/deploying/github-oidc.md b/docs/deploying/github-oidc.md new file mode 100644 index 00000000..d3f256c5 --- /dev/null +++ b/docs/deploying/github-oidc.md @@ -0,0 +1,262 @@ +# Authenticate with GitHub through Dex + +Use GitHub identities to authenticate callers by placing an OpenID Connect (OIDC) provider such as Dex in front of +agentic-api: + +```text +GitHub OAuth → Dex OIDC ID token → agentic-api bearer validation +``` + +GitHub's API and OAuth access tokens are not OIDC ID tokens, so callers cannot send them directly to agentic-api. +Dex is the tested provider in this walkthrough, but any OIDC provider can be used when its issuer, audience, +asymmetric signing keys, and claims satisfy the gateway's [validation contract](../design/oidc-bearer-authentication.md). + +!!! warning "Security boundary" + + This configuration authenticates the caller at the request boundary. Tenant isolation and persisted-state + authorization are separate work tracked by [issue #107](https://github.com/vllm-project/agentic-api/issues/107). + agentic-api consumes the inbound ID token; it must not forward that token to the inference service. + `OPENAI_API_KEY` is a separate upstream inference credential. + +## Architecture and security boundary + +GitHub authenticates the user, and Dex turns that result into a Dex-issued OIDC ID token for the client. agentic-api +validates that token at its `/v1/*` request boundary, then uses `OPENAI_API_KEY` independently when it calls the +inference service. The gateway performs OIDC discovery and loads the JSON Web Key Set (JWKS) before it begins +listening; see the [OIDC validation contract](../design/oidc-bearer-authentication.md) for issuer, key, claim, and +loopback-HTTP requirements. + +## Local validation + +This walkthrough is for local development only. Every HTTP URL below is loopback-only and development-only, and the +Dex `memory` storage setting is also development-only. Do not reuse these HTTP URLs or in-memory storage in a +production deployment. + +### Prerequisites and local values + +Install Docker, `jq`, `oauth2c`, and have an inference endpoint running at `http://127.0.0.1:5050`. Install +`oauth2c` with the [official package command for your platform](https://github.com/SecureAuthCorp/oauth2c#installation), +for example on macOS: + +```bash +brew install cloudentity/tap/oauth2c +``` + +Use these development-only values. Each HTTP address in this table is loopback-only and development-only. + +| Setting | Development value | Used by | +| --- | --- | --- | +| Dex issuer | `http://127.0.0.1:5556/dex` | Dex and `OIDC_ISSUER` | +| OIDC audience/client ID | `agentic-api-local` | Dex, `oauth2c`, and `OIDC_AUDIENCE` | +| OIDC client callback | `http://localhost:9876/callback` | Dex and `oauth2c` | +| GitHub OAuth callback | `http://127.0.0.1:5556/dex/callback` | GitHub and the Dex connector | + +### Register the GitHub OAuth App + +Create a [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) with +these loopback-only, development-only URLs: + +```text +Homepage URL: http://127.0.0.1:5556/dex +Authorization callback URL: http://127.0.0.1:5556/dex/callback +``` + +The GitHub callback is the Dex issuer plus `/callback`, not the `oauth2c` callback. Supply the generated client ID +and secret to Dex through environment variables or a secret manager. Never put the secret in the repository, image, +build arguments, command line, or shell history. + +For a local shell, enter the values interactively so the secret is not echoed: + +```bash +printf 'GitHub OAuth App client ID: ' +IFS= read -r GITHUB_CLIENT_ID +printf 'GitHub OAuth App client secret: ' +IFS= read -rs GITHUB_CLIENT_SECRET +printf '\n' +export GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET +``` + +### Configure and run Dex + +Create `dex.local.yaml` outside version control. This complete configuration is development-only: its HTTP issuer and +callback URLs are loopback-only, and `type: memory` loses all sessions and signing state when Dex restarts. + +```yaml +issuer: http://127.0.0.1:5556/dex + +storage: + type: memory + +web: + http: 0.0.0.0:5556 + +connectors: + - type: github + id: github + name: GitHub + config: + clientID: $GITHUB_CLIENT_ID + clientSecret: $GITHUB_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + +staticClients: + - id: agentic-api-local + name: agentic-api local + public: true + redirectURIs: + - http://localhost:9876/callback +``` + +Dex expands the two environment references at runtime. `public: true` enables a public client using authorization +code plus Proof Key for Code Exchange (PKCE), so the caller has no client secret. The in-memory store is unsuitable +for production because it loses sessions and signing state on restart. Dex listens on `0.0.0.0` only inside its +container; the Docker `--publish` setting below exposes it only at the loopback-only development address. + +Run the pinned Dex image with the configuration mounted read-only: + +```bash +docker run --rm --name agentic-api-dex \ + --publish 127.0.0.1:5556:5556 \ + --env GITHUB_CLIENT_ID \ + --env GITHUB_CLIENT_SECRET \ + --volume "$PWD/dex.local.yaml:/etc/dex/config.yaml:ro" \ + ghcr.io/dexidp/dex:v2.45.1 dex serve /etc/dex/config.yaml +``` + +See the [Dex GitHub connector documentation](https://dexidp.io/docs/connectors/github/) for connector options, +including organization and team restrictions. + +### Run agentic-api + +Supply the service credential independently of OIDC, then start the gateway with the matching loopback-only, +development-only issuer and audience: + +```bash +export OPENAI_API_KEY +cargo run -p agentic-server -- \ + --llm-api-base http://127.0.0.1:5050 \ + --oidc-issuer http://127.0.0.1:5556/dex \ + --oidc-audience agentic-api-local +``` + +The gateway performs OIDC discovery and JWKS loading before it begins listening. `OPENAI_API_KEY` remains the +inference-service credential; it is not an OIDC caller credential. + +### Sign in with PKCE + +Obtain an ID token with the public client. `oauth2c` opens the GitHub login in a browser and listens only for its own +loopback-only, development-only callback. Put only the ID token in `OIDC_TOKEN`: + +```bash +OIDC_TOKEN="$( + oauth2c http://127.0.0.1:5556/dex \ + --client-id agentic-api-local \ + --response-types code \ + --response-mode query \ + --grant-type authorization_code \ + --auth-method none \ + --scopes openid,email,profile \ + --redirect-url http://localhost:9876/callback \ + --pkce \ + --silent | + jq -er '.id_token' +)" +``` + +Do not print, log, paste, or commit the token, complete token response, authorization code, or PKCE verifier. Do +not decode claims in this walkthrough. + +### Verify the request boundary + +First verify the unauthenticated boundary: + +```bash +curl -i http://127.0.0.1:9000/v1/models +``` + +Expected: `401 Unauthorized` with `WWW-Authenticate: Bearer`. + +Then verify the GitHub-authenticated request: + +```bash +curl --fail-with-body \ + --header "Authorization: Bearer $OIDC_TOKEN" \ + http://127.0.0.1:9000/v1/models +``` + +Expected: an upstream model-list response rather than an authentication error. + +To verify credential separation, inspect an inference-service access log or use a mock inference service: the +upstream must receive `OPENAI_API_KEY`, never `$OIDC_TOKEN`. Do not print either credential. + +## Codex + +Create a user-owned executable helper outside the repository, for example `print-oidc-token`. It must send only the +ID token to stdout: + +```sh +#!/usr/bin/env sh +set -eu + +oauth2c http://127.0.0.1:5556/dex \ + --client-id agentic-api-local \ + --response-types code \ + --response-mode query \ + --grant-type authorization_code \ + --auth-method none \ + --scopes openid,email,profile \ + --redirect-url http://localhost:9876/callback \ + --pkce \ + --silent | + jq -er '.id_token' +``` + +Use the helper with Codex's supported command-backed bearer authentication: + +```toml +[model_providers.agentic-api.auth] +command = "/absolute/path/to/print-oidc-token" +args = [] +refresh_interval_ms = 300000 +``` + +This development helper initiates an interactive browser flow. Production operators should use their provider's +secure refresh or credential-helper workflow and keep refresh tokens out of repository files and logs. + +## Claude Code + +Use the token already obtained in the current shell: + +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:9000 +export ANTHROPIC_AUTH_TOKEN="$OIDC_TOKEN" +unset ANTHROPIC_API_KEY + +claude -p "summarize the files in this directory" +``` + +`ANTHROPIC_AUTH_TOKEN` must be refreshed before expiry and must not also be supplied as `ANTHROPIC_API_KEY`. + +## Production checklist + +- Use an HTTPS Dex issuer and HTTPS callbacks. +- Use a persistent, backed-up Dex datastore appropriate to the deployment topology. +- Inject secrets from a secret manager at runtime, never through image layers, build arguments, checked-in files, or + shell history. +- Rotate the GitHub OAuth App secret. +- Use a stable, deployment-specific audience shared only by intended callers. +- Apply optional Dex `orgs` and `teams` restrictions when deployment policy requires them. +- Apply network and ingress controls around Dex and agentic-api. + +## Troubleshooting + +- **GitHub rejects the callback:** the GitHub OAuth App callback is the Dex issuer plus `/callback`. See the + [Dex GitHub connector documentation](https://dexidp.io/docs/connectors/github/). +- **Discovery or startup fails:** Dex discovery's `issuer` must exactly equal `OIDC_ISSUER`, including scheme, host, + port, and path. See the [OIDC validation contract](../design/oidc-bearer-authentication.md). +- **Token is rejected:** the ID token `aud` must include `OIDC_AUDIENCE`; a GitHub access token is not a substitute. + See the [OIDC validation contract](../design/oidc-bearer-authentication.md). +- **HTTP issuer is rejected:** HTTP is allowed only for literal loopback issuers; use HTTPS elsewhere. See the + [OIDC validation contract](../design/oidc-bearer-authentication.md). +- **Upstream sees the identity token:** configure `OPENAI_API_KEY` separately and stop forwarding the identity token. + See the [OIDC validation contract](../design/oidc-bearer-authentication.md). From 223c806f98470715ba29265d240384db5eb8f1f5 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 14:58:22 -0400 Subject: [PATCH 07/13] docs: wrap OIDC tutorial prose Signed-off-by: Francisco Javier Arceo --- docs/deploying/github-oidc.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/deploying/github-oidc.md b/docs/deploying/github-oidc.md index d3f256c5..50f6f869 100644 --- a/docs/deploying/github-oidc.md +++ b/docs/deploying/github-oidc.md @@ -9,7 +9,8 @@ GitHub OAuth → Dex OIDC ID token → agentic-api bearer validation GitHub's API and OAuth access tokens are not OIDC ID tokens, so callers cannot send them directly to agentic-api. Dex is the tested provider in this walkthrough, but any OIDC provider can be used when its issuer, audience, -asymmetric signing keys, and claims satisfy the gateway's [validation contract](../design/oidc-bearer-authentication.md). +asymmetric signing keys, and claims satisfy the gateway's +[validation contract](../design/oidc-bearer-authentication.md). !!! warning "Security boundary" From d6353a1f9b524a3650f7ca3dc3771de7cd69b85c Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 15:00:20 -0400 Subject: [PATCH 08/13] docs: link GitHub OIDC deployment guide Signed-off-by: Francisco Javier Arceo --- README.md | 4 ++++ docs/api/index.md | 2 ++ docs/deploying/container.md | 1 + mkdocs.yaml | 1 + 4 files changed, 8 insertions(+) diff --git a/README.md b/README.md index e3baf2b0..db58d860 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ provider bearer token. See the [Codex custom-provider authentication reference](https://developers.openai.com/codex/config-advanced#custom-model-providers). Keep the inference credential in the gateway's `OPENAI_API_KEY`; do not print that service credential from the token command. +See [GitHub authentication with Dex](docs/deploying/github-oidc.md) for a complete GitHub login, token-helper, and +gateway setup. ## 🧑‍💻 Claude Code on your own GPUs @@ -171,6 +173,8 @@ claude -p "summarize the files in this directory" Refresh `ANTHROPIC_AUTH_TOKEN` before it expires. For supported dynamic credential helpers, see Anthropic's [LLM gateway authentication guide](https://docs.anthropic.com/en/docs/claude-code/llm-gateway). +The same [GitHub authentication with Dex](docs/deploying/github-oidc.md) guide shows how to obtain the ID token +without embedding a client secret in Claude Code. Claude Code's own tools (Bash, Edit, Read, …) stay **client-owned** — Claude Code runs them, as usual. diff --git a/docs/api/index.md b/docs/api/index.md index 57ff19e2..b681fc36 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -39,6 +39,8 @@ use this envelope: A JWKS refresh failure returns `503 Service Unavailable`, without `WWW-Authenticate`, so clients can distinguish an identity-provider dependency failure from rejected credentials. See [OIDC bearer authentication](../design/oidc-bearer-authentication.md) for configuration and key-cache behavior. +For a complete GitHub-backed deployment example, see +[GitHub authentication with Dex](../deploying/github-oidc.md). ## Responses diff --git a/docs/deploying/container.md b/docs/deploying/container.md index 3cb534fb..c6c84bc9 100644 --- a/docs/deploying/container.md +++ b/docs/deploying/container.md @@ -74,6 +74,7 @@ The gateway discovers the provider and its JSON Web Key Set before listening. `/ all `/v1/*` routes then require an OIDC `Authorization: Bearer` token. The identity token is consumed by the gateway, and `OPENAI_API_KEY` supplies the upstream inference credential. See [OIDC bearer authentication](../design/oidc-bearer-authentication.md) for the validation and key-rotation contract. +For a runnable GitHub-backed setup, follow [GitHub authentication with Dex](github-oidc.md). If the upstream is running on the Docker host, use `http://host.docker.internal:` on Docker Desktop. On Linux, add `--add-host host.docker.internal:host-gateway`. diff --git a/mkdocs.yaml b/mkdocs.yaml index 1861ae86..701c7c2b 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -98,6 +98,7 @@ nav: - Getting Started: developing/getting-started.md - Deploying: - Container: deploying/container.md + - GitHub authentication with Dex: deploying/github-oidc.md - Community: community/index.md extra: From 479dc019bc1dca7d182857b8088021d91df41002 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 31 Jul 2026 15:13:37 -0400 Subject: [PATCH 09/13] docs: remove local paths from tutorial plan Signed-off-by: Francisco Javier Arceo --- ...6-07-31-github-oidc-deployment-tutorial.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md b/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md index 421386d1..4a96463f 100644 --- a/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md +++ b/docs/superpowers/plans/2026-07-31-github-oidc-deployment-tutorial.md @@ -458,21 +458,22 @@ git commit -s -m "docs: link GitHub OIDC deployment guide" - [ ] **Step 1: Apply the Rust guidance during self-review** -Read `/Users/farceo/.agents/skills/rust-skills/SKILL.md` completely. Although this slice is documentation-only, -confirm the tutorial matches the implemented Rust configuration, authentication boundary, error behavior, and -credential forwarding behavior. Do not change Rust unless the documentation exposes an in-scope correctness defect. +Read the `rust-skills` instructions from the available skills catalog completely. Although this slice is +documentation-only, confirm the tutorial matches the implemented Rust configuration, authentication boundary, error +behavior, and credential forwarding behavior. Do not change Rust unless the documentation exposes an in-scope +correctness defect. - [ ] **Step 2: Run the read-only Claude review** -Read `/Users/farceo/.codex/skills/claude-review/SKILL.md` completely and run its worktree review against the diff from -`origin/main`. Record every finding and classify it as actionable, already addressed, out of scope, or incorrect with -specific evidence. +Read the `claude-review` instructions from the available skills catalog completely and run its worktree review against +the diff from `origin/main`. Record every finding and classify it as actionable, already addressed, out of scope, or +incorrect with specific evidence. - [ ] **Step 3: Run the gstack pre-landing review** -Read `/Users/farceo/.agents/skills/gstack/review/SKILL.md` completely and run the prescribed pre-landing review against -the same diff. Pay particular attention to credential leakage, misleading production advice, broken commands, broken -links, and scope expansion into issue #107. +Read the `review` instructions from the available skills catalog completely and run the prescribed pre-landing review +against the same diff. Pay particular attention to credential leakage, misleading production advice, broken commands, +broken links, and scope expansion into issue #107. - [ ] **Step 4: Resolve all actionable review feedback** From 7f038de12fadbaf2ec2cfc9a09e7b5aa3dfd8333 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Sat, 1 Aug 2026 13:41:15 -0400 Subject: [PATCH 10/13] docs: clarify JWKS refresh cooldown Signed-off-by: Francisco Javier Arceo --- docs/design/oidc-bearer-authentication.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design/oidc-bearer-authentication.md b/docs/design/oidc-bearer-authentication.md index fedf599d..5c0adc89 100644 --- a/docs/design/oidc-bearer-authentication.md +++ b/docs/design/oidc-bearer-authentication.md @@ -26,12 +26,12 @@ responses are limited to 1 MiB and JWKS documents to 100 keys. Verification keys are cached for the provider's `Cache-Control: max-age` duration, capped at one hour, or five minutes when no cache lifetime is supplied. A stale cache is refreshed before a cached key is accepted, so a provider can -remove a compromised key without requiring a gateway restart. Unknown key IDs can trigger at most one refresh per -30-second cooldown after a completed fetch. Refreshes are single-flight, and every successfully fetched key set is -installed even when it does not contain the key requested by the triggering token. +remove a compromised key without requiring a gateway restart. While cached keys remain fresh, unknown key IDs can +trigger at most one refresh per 30-second cooldown after a completed fetch. Refreshes are single-flight, and every +successfully fetched key set is installed even when it does not contain the key requested by the triggering token. Concurrent refresh waiters reuse the completed result. After a refresh failure, another provider request is suppressed for 30 seconds and callers receive `503 Service Unavailable`; a one-second coalescing window also prevents a -provider-supplied zero-second cache lifetime from causing one fetch per concurrent request. +provider-supplied zero-second cache lifetime from causing one fetch per concurrent request after the cache expires. ## Request boundary From b2ff6850523bec66adbe9f0d6e76bae9a82c3b64 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Mon, 3 Aug 2026 10:24:05 -0400 Subject: [PATCH 11/13] docs: design OIDC review follow-ups Signed-off-by: Francisco Javier Arceo --- ...2026-08-03-oidc-review-followups-design.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-oidc-review-followups-design.md diff --git a/docs/superpowers/specs/2026-08-03-oidc-review-followups-design.md b/docs/superpowers/specs/2026-08-03-oidc-review-followups-design.md new file mode 100644 index 00000000..f5965a3b --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-oidc-review-followups-design.md @@ -0,0 +1,68 @@ +# OIDC review follow-ups design + +## Goal + +Resolve all four actionable review threads on pull request #149 without broadening optional OpenID Connect (OIDC) +authentication into tenant authorization or changing pre-existing protocol behavior outside the reviewed paths. + +## Considered approaches + +### Focused compatibility fixes (selected) + +Keep the gateway's single configured audience as the complete trust set, protect the existing JWKS refresh future with +a scoped cancellation guard, emit an OpenAI-compatible error event only for OIDC expiry in Responses WebSocket mode, +and add Anthropic request IDs only to authentication errors synthesized by this feature. This is the smallest approach +that satisfies the protocol contracts and preserves behavior newly tested on `main`. + +### Detached JWKS refresh and configurable audience allowlist + +Run refreshes in shared background tasks and add configuration for multiple trusted audiences. This makes cancellation +independent of request lifetime and supports deliberately multi-audience tokens, but adds task lifecycle, shutdown, +configuration, and documentation surface that issue #104 does not require. + +### Normalize every gateway protocol error + +Replace all Responses WebSocket and Anthropic error envelopes with fully typed protocol errors. This would improve +broader consistency, but current `main` explicitly tests the generic nested WebSocket envelope for persistence +failures, and non-authentication Anthropic request IDs are pre-existing behavior outside this pull request. + +## Audience and authorized-party validation + +`OIDC_AUDIENCE` is the gateway's only trusted audience. A scalar audience or every entry in an audience array must +equal that value. When `azp` is present, it must also equal the configured audience. Tokens containing an unconfigured +additional audience or a conflicting authorized party are rejected as invalid tokens. + +Tests will cover scalar and array audiences, additional audiences, and `azp` values that are absent, matching, or +conflicting. + +## Cancellation-safe JWKS refresh + +The refresh gate continues to serialize provider requests. After setting `retry_after`, a scoped guard owns that exact +in-flight deadline. If the future is dropped before a provider result is processed, the guard clears the deadline only +when it still matches its own value. A completed provider request disarms the guard: successful refresh retains the +existing success state, while a real provider failure retains the intended 30-second retry backoff. + +An integration test will pause a JWKS refresh, abort the authenticating task, release the provider, and prove that the +next refresh-dependent request is allowed to contact the provider instead of receiving a synthetic backoff response. + +## Protocol error compatibility + +When an authenticated Responses WebSocket principal expires between requests, the gateway sends a terminal error +event with top-level `type`, `code`, `message`, `param`, and `sequence_number`, then closes the connection. The event +uses sequence number zero because expiry is detected before processing the next `response.create`. Other `WsError` +variants retain their current envelope. + +Anthropic authentication errors generate one `req_`-prefixed UUIDv7 using the existing core helper. The same value is +placed in the `request-id` response header and the top-level `request_id` error-body field. OpenAI-shaped authentication +errors remain unchanged. + +## Rebase and verification + +Rebase onto current `main` and combine the OIDC and PostgreSQL helper imports in the mechanical `main.rs` test conflict. +Each behavior change follows a failing-test-first cycle. After focused tests pass, run formatting, Clippy, the full Rust +test suite, pre-commit, documentation build, read-only Claude review, gstack pre-landing review, and GitHub CI. + +## Out of scope + +This follow-up does not add multiple trusted-audience configuration, change non-authentication WebSocket errors, add +request IDs to pre-existing non-authentication Anthropic paths, or implement tenant authorization from issue #107. From a8e3f00e5748776ea9556ff96738a47ec24c3feb Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Mon, 3 Aug 2026 10:26:07 -0400 Subject: [PATCH 12/13] docs: plan OIDC review follow-ups Signed-off-by: Francisco Javier Arceo --- .../plans/2026-08-03-oidc-review-followups.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-oidc-review-followups.md diff --git a/docs/superpowers/plans/2026-08-03-oidc-review-followups.md b/docs/superpowers/plans/2026-08-03-oidc-review-followups.md new file mode 100644 index 00000000..44eadeb6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-oidc-review-followups.md @@ -0,0 +1,356 @@ +# OIDC Review Follow-ups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve all four actionable review threads on PR #149 while preserving unrelated protocol behavior. + +**Architecture:** Keep the configured OIDC audience as the complete trust set, use a scoped RAII guard for refresh +cancellation, and add protocol-specific response data only at the two authentication boundaries under review. Rebase +first so every TDD cycle runs against current `main`. + +**Tech Stack:** Rust 2024, Axum, Tokio, jsonwebtoken, serde/serde_json, reqwest, UUIDv7, cargo test. + +## Global Constraints + +- Rust MSRV is 1.85 and `unsafe` is forbidden. +- Do not hold `Mutex` or `RwLock` guards across `.await`. +- Keep non-authentication WebSocket error envelopes unchanged. +- Keep non-authentication Anthropic response behavior outside this follow-up. +- Follow `TERMINOLOGY.md` and preserve exact protocol field names. +- Every commit must use a conventional prefix and `git commit -s`. + +--- + +### Task 1: Rebase onto current main + +**Files:** + +- Resolve: `crates/agentic-server/src/main.rs` + +**Interfaces:** + +- Consumes: the OIDC helpers from PR #149 and PostgreSQL helpers from current `main`. +- Produces: a branch based on current `origin/main` with both helper sets imported in the `main.rs` test module. + +- [ ] **Step 1: Verify the worktree and remote branch are unchanged** + +Run: + +```bash +git status --short +git rev-parse HEAD +git rev-parse origin/codex/issue-102-oidc-auth +``` + +Expected: clean status and matching local/remote heads before history changes. + +- [ ] **Step 2: Rebase onto current main** + +Run: + +```bash +git rebase origin/main +``` + +Expected: one conflict in the `crates/agentic-server/src/main.rs` test import block. + +- [ ] **Step 3: Combine the imports** + +Retain `oidc_config_from_values` alongside `database_configs_from_env`, `parse_env_duration_value`, +`parse_env_optional_duration_value`, and the existing integer/temp-store parsers. Retain both OIDC tests and every +PostgreSQL parser/configuration test from `main`. + +- [ ] **Step 4: Continue and verify the rebase** + +Run: + +```bash +git add crates/agentic-server/src/main.rs +git rebase --continue +cargo test -p agentic-server --bin agentic-server +``` + +Expected: rebase completes and binary unit tests pass. + +### Task 2: Enforce the complete audience trust set + +**Files:** + +- Modify: `crates/agentic-server/src/auth.rs` +- Test: `crates/agentic-server/tests/oidc_auth_test.rs` + +**Interfaces:** + +- Consumes: `IdentityClaims { aud: AudienceClaim, azp: Option }` and the configured audience string. +- Produces: `IdentityClaims::audience_allows(&self, expected: &str) -> bool`, accepting only trusted audience values + and an absent or matching authorized party. + +- [ ] **Step 1: Write failing integration cases** + +Extend `multi_audience_tokens_require_the_expected_authorized_party` so these literal cases are checked: + +```rust +(&["agentic-api"][..], None, StatusCode::OK), +(&["agentic-api"][..], Some("agentic-api"), StatusCode::OK), +(&["agentic-api"][..], Some("other-client"), StatusCode::UNAUTHORIZED), +(&["agentic-api", "other-client"][..], Some("agentic-api"), StatusCode::UNAUTHORIZED), +(&["agentic-api", "other-client"][..], None, StatusCode::UNAUTHORIZED), +``` + +Add a scalar-audience token with `azp: "other-client"` and assert `401 Unauthorized` so scalar and array parsing both +exercise authorized-party validation. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cargo test -p agentic-server --test oidc_auth_test multi_audience_tokens_require_the_expected_authorized_party +``` + +Expected: FAIL because an unconfigured additional audience and a conflicting scalar `azp` are currently accepted. + +- [ ] **Step 3: Implement minimal validation** + +Implement the equivalent of: + +```rust +fn audience_allows(&self, expected: &str) -> bool { + let audiences_match = match &self.aud { + AudienceClaim::One(audience) => audience == expected, + AudienceClaim::Many(audiences) => { + !audiences.is_empty() && audiences.iter().all(|audience| audience == expected) + } + }; + audiences_match && self.azp.as_deref().is_none_or(|authorized_party| authorized_party == expected) +} +``` + +Use an MSRV-compatible expression if `Option::is_none_or` is unavailable on Rust 1.85. + +- [ ] **Step 4: Verify GREEN** + +Run the focused test from Step 2, then: + +```bash +cargo test -p agentic-server --test oidc_auth_test configured_oidc_rejects_wrong_issuer_audience_and_expired_tokens +``` + +Expected: both tests pass. + +### Task 3: Make JWKS refresh cancellation-safe + +**Files:** + +- Modify: `crates/agentic-server/src/auth.rs` +- Test: `crates/agentic-server/src/auth.rs` + +**Interfaces:** + +- Consumes: `Arc>` and the exact `retry_after` deadline installed for one refresh. +- Produces: a private `RefreshAttempt` guard whose `Drop` clears only its own cancelled in-flight deadline and whose + completion methods retain a real provider-failure backoff or the successful refresh state. + +- [ ] **Step 1: Add a pausable provider and failing cancellation test** + +Inside the existing `auth.rs` test module, start an Axum OIDC provider whose initial JWKS response contains `old-key` +with `max-age=0`. Later JWKS requests signal a zero-permit `Semaphore`, wait on a second semaphore, and return +`new-key`. Discover the authenticator, spawn `authenticate` with a token signed by `new-key`, wait for refresh start, +abort and await that task, release two provider requests, then authenticate again. + +Assert that the second authentication succeeds and the JWKS request count reaches three: initial discovery, cancelled +refresh, and replacement refresh. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cargo test -p agentic-server cancelled_jwks_refresh_does_not_install_backoff -- --exact +``` + +Expected: FAIL with `JwksRefreshBackoff` because the cancelled future leaves `retry_after` set. + +- [ ] **Step 3: Implement the scoped refresh guard** + +Add a private guard shaped like: + +```rust +struct RefreshAttempt { + state: Arc>, + retry_after: Instant, + clear_on_drop: bool, +} +``` + +Its constructor installs the deadline. `retain_backoff` and `complete` set `clear_on_drop = false`. `Drop` takes the +synchronous lock and clears `retry_after` only when `clear_on_drop` is true and the stored deadline equals its own. +Create the guard after acquiring `refresh_gate`; retain backoff only when `fetch_jwks` returns a real error, and +complete it only after the success state has been written. Do not hold either lock across provider or Tokio lock +awaits. + +- [ ] **Step 4: Verify GREEN and real-failure behavior** + +Run the focused test from Step 2, then: + +```bash +cargo test -p agentic-server --test oidc_auth_test jwks_refresh_failure_returns_protocol_specific_service_errors +``` + +Expected: cancellation test passes and genuine provider failures still use the existing backoff behavior. + +### Task 4: Emit the canonical OIDC-expiry WebSocket event + +**Files:** + +- Modify: `crates/agentic-server/src/handler/websocket/responses.rs` +- Test: `crates/agentic-server/src/handler/websocket/responses.rs` + +**Interfaces:** + +- Consumes: `Option<&AuthenticatedPrincipal>` before processing a queued or newly received `response.create`. +- Produces: `Option` containing exactly the OIDC-expiry Responses WebSocket error event. + +- [ ] **Step 1: Write the failing unit assertion** + +Change the expiry test to require this literal value: + +```rust +json!({ + "type": "error", + "code": "invalid_token", + "message": "OIDC bearer token expired", + "param": null, + "sequence_number": 0, +}) +``` + +Keep a separate assertion proving the generic `WsError::to_ws_frame` behavior is untouched. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cargo test -p agentic-server websocket_identity_expiry_uses_responses_error_event -- --exact +``` + +Expected: FAIL because expiry currently uses the nested generic WebSocket envelope. + +- [ ] **Step 3: Implement the narrow event path** + +Replace `websocket_identity_error` with an event helper returning the literal top-level fields above. In +`responses_ws_loop`, send that JSON value directly with `send_ws_json` and then close. Leave `WsError`, +`handle_ws_error`, and every non-authentication error path unchanged. + +- [ ] **Step 4: Verify GREEN and existing WebSocket behavior** + +Run the focused test from Step 2, then: + +```bash +cargo test -p agentic-server --test responses_websocket_test +``` + +Expected: the expiry test and all existing WebSocket tests pass. + +### Task 5: Add Anthropic authentication request IDs + +**Files:** + +- Modify: `crates/agentic-server/src/auth.rs` +- Test: `crates/agentic-server/tests/oidc_auth_test.rs` + +**Interfaces:** + +- Consumes: `agentic_core::utils::common::uuid7_str("req_")` when rendering an Anthropic authentication error. +- Produces: one identifier used by both the `request-id` response header and top-level `request_id` body field. + +- [ ] **Step 1: Write the failing integration test** + +Request `/v1/messages` without a bearer token, copy the `request-id` header before consuming the body, and assert: + +```rust +assert!(request_id.starts_with("req_")); +assert_eq!(body["request_id"], request_id); +assert_eq!(body["error"]["type"], "authentication_error"); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cargo test -p agentic-server --test oidc_auth_test anthropic_authentication_errors_include_matching_request_id +``` + +Expected: FAIL because the header and body field are absent. + +- [ ] **Step 3: Implement header/body parity** + +In `protocol_error`, generate a request ID only for `AuthErrorFormat::Anthropic`, include it as top-level +`request_id`, and add the same value through `Response::builder().header("request-id", request_id)`. Keep OpenAI-shaped +errors byte-for-byte unchanged apart from formatting performed by rustfmt. + +- [ ] **Step 4: Verify GREEN and both dependency-error formats** + +Run the focused test from Step 2, then: + +```bash +cargo test -p agentic-server --test oidc_auth_test jwks_refresh_failure_returns_protocol_specific_service_errors +``` + +Expected: both tests pass. + +### Task 6: Review, verify, and update PR #149 + +**Files:** + +- Review: every file changed against `origin/main` +- Update: PR #149 Summary and Test Plan + +**Interfaces:** + +- Consumes: the rebased, locally verified branch and all four unresolved reviewer threads. +- Produces: signed commits, an updated remote branch, thread replies/resolutions, and green CI. + +- [ ] **Step 1: Run local Rust gates** + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test +git diff --check origin/main...HEAD +``` + +Expected: all commands pass without new warnings. + +- [ ] **Step 2: Run repository gates** + +```bash +uvx pre-commit run --all-files +uv run --with-requirements docs/requirements.txt mkdocs build +``` + +Expected: hooks pass; MkDocs has no warning attributable to this follow-up. + +- [ ] **Step 3: Run required reviews in order** + +Apply the Rust self-review checklist, run the read-only Claude review, then run the gstack pre-landing review. Fix every +actionable finding with focused tests and repeat relevant reviews until clean. + +- [ ] **Step 4: Commit and push** + +```bash +git add crates/agentic-server/src/auth.rs crates/agentic-server/src/handler/websocket/responses.rs \ + crates/agentic-server/tests/oidc_auth_test.rs docs/superpowers/specs docs/superpowers/plans +git commit -s -m "fix: address OIDC authentication review feedback" +git push --force-with-lease origin codex/issue-102-oidc-auth +``` + +Expected: the rewritten branch is pushed without overwriting an unexpected remote head. + +- [ ] **Step 5: Update GitHub review state** + +Reply in each inline thread with the exact fix and focused test, resolve only those four completed threads, update the +PR Summary/Test Plan, and monitor DCO, pre-commit, Rust, and container checks until terminal. From c25567f053057cdc9deb9a2d7c3db5801f33013b Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Mon, 3 Aug 2026 11:01:43 -0400 Subject: [PATCH 13/13] fix: address OIDC authentication review feedback Signed-off-by: Francisco Javier Arceo --- crates/agentic-server/src/auth.rs | 435 +++++++++++++++--- .../src/handler/websocket/error.rs | 5 - .../src/handler/websocket/responses.rs | 51 +- crates/agentic-server/tests/oidc_auth_test.rs | 226 ++++++++- docs/api/index.md | 12 +- docs/deploying/github-oidc.md | 5 +- docs/design/oidc-bearer-authentication.md | 10 +- 7 files changed, 628 insertions(+), 116 deletions(-) diff --git a/crates/agentic-server/src/auth.rs b/crates/agentic-server/src/auth.rs index 48366d34..490819a4 100644 --- a/crates/agentic-server/src/auth.rs +++ b/crates/agentic-server/src/auth.rs @@ -16,14 +16,28 @@ use tokio::sync::RwLock; use tracing::{debug, warn}; use url::{Host, Url}; +use agentic_core::utils::common::uuid7_str; + const OIDC_HTTP_TIMEOUT: Duration = Duration::from_secs(10); const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(30); const JWKS_REFRESH_COALESCE_WINDOW: Duration = Duration::from_secs(1); +const JWKS_REFRESH_WAIT_TIMEOUT: Duration = Duration::from_secs(1); const DEFAULT_JWKS_TTL: Duration = Duration::from_secs(300); const MAX_JWKS_TTL: Duration = Duration::from_secs(3600); const MAX_PROVIDER_RESPONSE_BYTES: usize = 1024 * 1024; const MAX_JWKS_KEYS: usize = 100; const JWT_CLOCK_SKEW_SECONDS: u64 = 60; +const SUPPORTED_VERIFICATION_ALGORITHMS: [Algorithm; 9] = [ + Algorithm::ES256, + Algorithm::ES384, + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::EdDSA, +]; pub(crate) const ANTHROPIC_MESSAGES_PATH: &str = "/v1/messages"; pub(crate) const ANTHROPIC_COUNT_TOKENS_PATH: &str = "/v1/messages/count_tokens"; @@ -101,6 +115,48 @@ struct RefreshState { coalesce_until: Option, } +struct RefreshAttempt { + refresh_state: Arc>, + retry_after: Instant, + clear_on_drop: bool, +} + +impl RefreshAttempt { + fn begin(refresh_state: Arc>) -> Result { + let retry_after = Instant::now() + JWKS_REFRESH_COOLDOWN; + refresh_state + .lock() + .map_err(|_| OidcAuthError::RefreshStateUnavailable)? + .retry_after = Some(retry_after); + Ok(Self { + refresh_state, + retry_after, + clear_on_drop: true, + }) + } + + fn retain_backoff(mut self) { + self.clear_on_drop = false; + } + + fn complete(mut self) { + self.clear_on_drop = false; + } +} + +impl Drop for RefreshAttempt { + fn drop(&mut self) { + if !self.clear_on_drop { + return; + } + if let Ok(mut refresh_state) = self.refresh_state.lock() + && refresh_state.retry_after == Some(self.retry_after) + { + refresh_state.retry_after = None; + } + } +} + #[derive(Clone)] pub struct OidcAuthenticator { audience: String, @@ -200,10 +256,9 @@ impl OidcAuthenticator { } } - let _refresh_permit = self - .refresh_gate - .acquire() + let _refresh_permit = tokio::time::timeout(JWKS_REFRESH_WAIT_TIMEOUT, self.refresh_gate.acquire()) .await + .map_err(|_| OidcAuthError::JwksRefreshBackoff)? .map_err(|_| OidcAuthError::RefreshStateUnavailable)?; { let keys = self.keys.read().await; @@ -228,11 +283,14 @@ impl OidcAuthenticator { } } - self.refresh_state - .lock() - .map_err(|_| OidcAuthError::RefreshStateUnavailable)? - .retry_after = Some(Instant::now() + JWKS_REFRESH_COOLDOWN); - let refreshed = fetch_jwks(&self.client, self.jwks_uri.clone()).await?; + let refresh_attempt = RefreshAttempt::begin(Arc::clone(&self.refresh_state))?; + let refreshed = match fetch_jwks(&self.client, self.jwks_uri.clone()).await { + Ok(refreshed) => refreshed, + Err(error) => { + refresh_attempt.retain_backoff(); + return Err(error); + } + }; let key = refreshed.keys.get(kid).cloned(); *self.keys.write().await = refreshed; let refresh_completed = Instant::now(); @@ -243,6 +301,8 @@ impl OidcAuthenticator { refresh_state.last_completed = refresh_completed; refresh_state.retry_after = None; refresh_state.coalesce_until = Some(refresh_completed + JWKS_REFRESH_COALESCE_WINDOW); + drop(refresh_state); + refresh_attempt.complete(); key.ok_or(OidcAuthError::UnknownKeyId) } } @@ -306,7 +366,7 @@ pub async fn require_oidc( match authenticator.authenticate(token).await { Ok(principal) => { request.headers_mut().remove(header::AUTHORIZATION); - if duplicate_identity_api_key { + if duplicate_identity_api_key || !error_format.allows_upstream_api_key() { request.headers_mut().remove("x-api-key"); } request.extensions_mut().insert(principal); @@ -350,6 +410,10 @@ impl AuthErrorFormat { Self::OpenAi } } + + fn allows_upstream_api_key(self) -> bool { + matches!(self, Self::Anthropic) + } } fn authentication_error(format: AuthErrorFormat, code: &'static str, message: &'static str) -> Response { @@ -385,26 +449,39 @@ fn protocol_error( message: &'static str, challenge: bool, ) -> Response { - let body = match format { - AuthErrorFormat::OpenAi => json!({ - "error": { - "message": message, - "type": openai_error_type, - "param": null, - "code": code - } - }), - AuthErrorFormat::Anthropic => json!({ - "type": "error", - "error": { - "type": anthropic_error_type, - "message": message - } - }), + let (body, request_id) = match format { + AuthErrorFormat::OpenAi => ( + json!({ + "error": { + "message": message, + "type": openai_error_type, + "param": null, + "code": code + } + }), + None, + ), + AuthErrorFormat::Anthropic => { + let request_id = uuid7_str("req_"); + ( + json!({ + "type": "error", + "error": { + "type": anthropic_error_type, + "message": message + }, + "request_id": &request_id + }), + Some(request_id), + ) + } }; let mut builder = Response::builder() .status(status) .header(header::CONTENT_TYPE, "application/json"); + if let Some(request_id) = request_id.as_deref() { + builder = builder.header("request-id", request_id); + } if challenge { builder = builder.header(header::WWW_AUTHENTICATE, "Bearer"); } @@ -540,51 +617,48 @@ fn verification_algorithm(key: &Jwk) -> VerificationAlgorithm { } match key.common.key_algorithm { - Some(key_algorithm) => VerificationAlgorithm::Exact(match key_algorithm { - KeyAlgorithm::ES256 => Algorithm::ES256, - KeyAlgorithm::ES384 => Algorithm::ES384, - KeyAlgorithm::RS256 => Algorithm::RS256, - KeyAlgorithm::RS384 => Algorithm::RS384, - KeyAlgorithm::RS512 => Algorithm::RS512, - KeyAlgorithm::PS256 => Algorithm::PS256, - KeyAlgorithm::PS384 => Algorithm::PS384, - KeyAlgorithm::PS512 => Algorithm::PS512, - KeyAlgorithm::EdDSA => Algorithm::EdDSA, - KeyAlgorithm::HS256 - | KeyAlgorithm::HS384 - | KeyAlgorithm::HS512 - | KeyAlgorithm::RSA1_5 - | KeyAlgorithm::RSA_OAEP - | KeyAlgorithm::RSA_OAEP_256 - | KeyAlgorithm::UNKNOWN_ALGORITHM => return VerificationAlgorithm::Skip, - }), + Some(key_algorithm) => { + let algorithm = match key_algorithm { + KeyAlgorithm::ES256 => Algorithm::ES256, + KeyAlgorithm::ES384 => Algorithm::ES384, + KeyAlgorithm::RS256 => Algorithm::RS256, + KeyAlgorithm::RS384 => Algorithm::RS384, + KeyAlgorithm::RS512 => Algorithm::RS512, + KeyAlgorithm::PS256 => Algorithm::PS256, + KeyAlgorithm::PS384 => Algorithm::PS384, + KeyAlgorithm::PS512 => Algorithm::PS512, + KeyAlgorithm::EdDSA => Algorithm::EdDSA, + KeyAlgorithm::HS256 + | KeyAlgorithm::HS384 + | KeyAlgorithm::HS512 + | KeyAlgorithm::RSA1_5 + | KeyAlgorithm::RSA_OAEP + | KeyAlgorithm::RSA_OAEP_256 + | KeyAlgorithm::UNKNOWN_ALGORITHM => return VerificationAlgorithm::Skip, + }; + if SUPPORTED_VERIFICATION_ALGORITHMS.contains(&algorithm) { + VerificationAlgorithm::Exact(algorithm) + } else { + VerificationAlgorithm::Skip + } + } None => VerificationAlgorithm::AnyAsymmetric, } } fn build_validations(issuer: &str, audience: &str) -> Vec<(Algorithm, Validation)> { - [ - Algorithm::ES256, - Algorithm::ES384, - Algorithm::RS256, - Algorithm::RS384, - Algorithm::RS512, - Algorithm::PS256, - Algorithm::PS384, - Algorithm::PS512, - Algorithm::EdDSA, - ] - .into_iter() - .map(|algorithm| { - let mut validation = Validation::new(algorithm); - validation.leeway = JWT_CLOCK_SKEW_SECONDS; - validation.set_audience(&[audience]); - validation.set_issuer(&[issuer]); - validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]); - validation.validate_nbf = true; - (algorithm, validation) - }) - .collect() + SUPPORTED_VERIFICATION_ALGORITHMS + .into_iter() + .map(|algorithm| { + let mut validation = Validation::new(algorithm); + validation.leeway = JWT_CLOCK_SKEW_SECONDS; + validation.set_audience(&[audience]); + validation.set_issuer(&[issuer]); + validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]); + validation.validate_nbf = true; + (algorithm, validation) + }) + .collect() } fn jwks_ttl(headers: &HeaderMap) -> Duration { @@ -639,13 +713,17 @@ struct IdentityClaims { impl IdentityClaims { fn audience_allows(&self, expected: &str) -> bool { - match &self.aud { + let audiences_match = match &self.aud { AudienceClaim::One(audience) => audience == expected, - AudienceClaim::Many(audiences) if audiences.len() == 1 => audiences[0] == expected, AudienceClaim::Many(audiences) => { - audiences.iter().any(|audience| audience == expected) && self.azp.as_deref() == Some(expected) + !audiences.is_empty() && audiences.iter().all(|audience| audience == expected) } - } + }; + audiences_match + && self + .azp + .as_deref() + .is_none_or(|authorized_party| authorized_party == expected) } } @@ -730,16 +808,23 @@ impl OidcAuthError { #[cfg(test)] mod tests { use super::{ - AuthenticatedPrincipal, MAX_JWKS_KEYS, MAX_JWKS_TTL, OidcAuthError, VerificationAlgorithm, compile_jwks, - jwks_ttl, verification_algorithm, + AuthenticatedPrincipal, JWKS_REFRESH_COOLDOWN, MAX_JWKS_KEYS, MAX_JWKS_TTL, OidcAuthError, OidcAuthenticator, + OidcConfig, VerificationAlgorithm, compile_jwks, jwks_ttl, verification_algorithm, }; use axum::http::{HeaderMap, HeaderValue, header}; + use axum::routing::get; + use axum::{Json, Router}; use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}; - use jsonwebtoken::{Algorithm, EncodingKey}; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use rand::rngs::OsRng; use rsa::RsaPrivateKey; use rsa::pkcs1::EncodeRsaPrivateKey; - use std::time::Duration; + use serde_json::json; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + use tokio::net::TcpListener; + use tokio::sync::Semaphore; fn test_jwk() -> Jwk { let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("generate test RSA key"); @@ -752,6 +837,107 @@ mod tests { jwk } + fn test_key_with_id(kid: &str) -> (Vec, Jwk) { + let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("generate test RSA key"); + let private_key = private_key.to_pkcs1_der().expect("encode test RSA key"); + let private_key = private_key.as_bytes().to_vec(); + let mut jwk = + Jwk::from_encoding_key(&EncodingKey::from_rsa_der(&private_key), Algorithm::RS256).expect("test JWK"); + jwk.common.key_id = Some(kid.to_owned()); + jwk.common.key_algorithm = Some(KeyAlgorithm::RS256); + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + (private_key, jwk) + } + + async fn cancellation_test_authenticator() -> ( + OidcAuthenticator, + String, + Arc, + Arc, + Arc, + tokio::task::JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind cancellation test provider"); + let issuer = format!("http://{}", listener.local_addr().expect("provider address")); + let discovery_issuer = issuer.clone(); + let discovery_jwks_uri = format!("{issuer}/jwks"); + let (_old_private_key, old_jwk) = test_key_with_id("old-key"); + let (new_private_key, new_jwk) = test_key_with_id("new-key"); + let requests = Arc::new(AtomicUsize::new(0)); + let observed_requests = Arc::clone(&requests); + let refresh_started = Arc::new(Semaphore::new(0)); + let observed_refresh_started = Arc::clone(&refresh_started); + let release_refresh = Arc::new(Semaphore::new(0)); + let observed_release_refresh = Arc::clone(&release_refresh); + + let provider = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || { + let issuer = discovery_issuer.clone(); + let jwks_uri = discovery_jwks_uri.clone(); + async move { Json(json!({"issuer": issuer, "jwks_uri": jwks_uri})) } + }), + ) + .route( + "/jwks", + get(move || { + let old_jwk = old_jwk.clone(); + let new_jwk = new_jwk.clone(); + let requests = Arc::clone(&observed_requests); + let refresh_started = Arc::clone(&observed_refresh_started); + let release_refresh = Arc::clone(&observed_release_refresh); + async move { + let request = requests.fetch_add(1, Ordering::Relaxed); + let jwk = if request == 0 { + old_jwk + } else { + refresh_started.add_permits(1); + release_refresh + .acquire() + .await + .expect("release semaphore remains open") + .forget(); + new_jwk + }; + ([(header::CACHE_CONTROL, "max-age=0")], Json(json!({"keys": [jwk]}))) + } + }), + ); + let provider_handle = tokio::spawn(async move { + axum::serve(listener, provider) + .await + .expect("serve cancellation test provider"); + }); + let authenticator = OidcAuthenticator::discover(OidcConfig::new(&issuer, "agentic-api").expect("OIDC config")) + .await + .expect("discover cancellation test provider"); + let mut token_header = Header::new(Algorithm::RS256); + token_header.kid = Some("new-key".to_owned()); + let token = encode( + &token_header, + &json!({ + "iss": issuer, + "sub": "subject", + "aud": "agentic-api", + "exp": jsonwebtoken::get_current_timestamp() + 300 + }), + &EncodingKey::from_rsa_der(&new_private_key), + ) + .expect("encode cancellation test token"); + + ( + authenticator, + token, + requests, + refresh_started, + release_refresh, + provider_handle, + ) + } + #[test] fn jwks_cache_lifetime_uses_provider_max_age_with_a_cap() { let mut headers = HeaderMap::new(); @@ -795,6 +981,97 @@ mod tests { assert!(principal.is_expired_at(161)); } + #[tokio::test] + async fn cancelled_jwks_refresh_does_not_install_backoff() { + let (authenticator, token, requests, refresh_started, release_refresh, _provider) = + cancellation_test_authenticator().await; + let cancelled_authenticator = authenticator.clone(); + let cancelled_token = token.clone(); + let cancelled_refresh = + tokio::spawn(async move { cancelled_authenticator.authenticate(&cancelled_token).await }); + + tokio::time::timeout(Duration::from_secs(2), refresh_started.acquire()) + .await + .expect("refresh must start") + .expect("refresh semaphore remains open") + .forget(); + cancelled_refresh.abort(); + assert!( + cancelled_refresh + .await + .expect_err("refresh task must be cancelled") + .is_cancelled() + ); + release_refresh.add_permits(2); + + let principal = tokio::time::timeout(Duration::from_secs(2), authenticator.authenticate(&token)) + .await + .expect("replacement refresh must finish") + .expect("replacement refresh must authenticate"); + assert_eq!(principal.subject(), "subject"); + assert_eq!(requests.load(Ordering::Relaxed), 3); + } + + #[tokio::test] + async fn stalled_jwks_refresh_bounds_waiters() { + let (authenticator, token, _requests, refresh_started, release_refresh, _provider) = + cancellation_test_authenticator().await; + let refreshing_authenticator = authenticator.clone(); + let refreshing_token = token.clone(); + let refreshing = tokio::spawn(async move { refreshing_authenticator.authenticate(&refreshing_token).await }); + tokio::time::timeout(Duration::from_secs(2), refresh_started.acquire()) + .await + .expect("refresh must start") + .expect("refresh semaphore remains open") + .forget(); + + let waiting = tokio::time::timeout(Duration::from_secs(2), authenticator.authenticate(&token)) + .await + .expect("refresh waiter must be bounded"); + assert!(matches!(waiting, Err(OidcAuthError::JwksRefreshBackoff))); + + refreshing.abort(); + assert!( + refreshing + .await + .expect_err("refresh task must be cancelled") + .is_cancelled() + ); + release_refresh.add_permits(1); + } + + #[tokio::test] + async fn fresh_cache_unknown_key_refreshes_once_after_cooldown() { + let (authenticator, token, requests, _refresh_started, release_refresh, _provider) = + cancellation_test_authenticator().await; + authenticator.keys.write().await.expires_at = Instant::now() + Duration::from_secs(60); + authenticator + .refresh_state + .lock() + .expect("refresh state") + .last_completed = Instant::now() + .checked_sub(JWKS_REFRESH_COOLDOWN + Duration::from_secs(1)) + .expect("test cooldown instant"); + release_refresh.add_permits(1); + + let first_authenticator = authenticator.clone(); + let first_token = token.clone(); + let first = tokio::spawn(async move { first_authenticator.authenticate(&first_token).await }); + let second_authenticator = authenticator.clone(); + let second = tokio::spawn(async move { second_authenticator.authenticate(&token).await }); + let (first, second) = tokio::join!(first, second); + + assert_eq!( + first.expect("first task").expect("first authentication").subject(), + "subject" + ); + assert_eq!( + second.expect("second task").expect("second authentication").subject(), + "subject" + ); + assert_eq!(requests.load(Ordering::Relaxed), 2); + } + #[test] fn jwks_limits_and_signature_metadata_are_enforced() { let mut encryption_key = test_jwk(); @@ -833,5 +1110,17 @@ mod tests { compile_jwks(JwkSet { keys: too_many_keys }, Duration::from_secs(60)), Err(OidcAuthError::TooManyJwksKeys) )); + + let duplicate_key = test_jwk(); + let duplicate_key_id = duplicate_key.common.key_id.clone().expect("test key ID"); + assert!(matches!( + compile_jwks( + JwkSet { + keys: vec![duplicate_key.clone(), duplicate_key] + }, + Duration::from_secs(60) + ), + Err(OidcAuthError::DuplicateKeyId(key_id)) if key_id == duplicate_key_id + )); } } diff --git a/crates/agentic-server/src/handler/websocket/error.rs b/crates/agentic-server/src/handler/websocket/error.rs index 0ffe4652..43b013d6 100644 --- a/crates/agentic-server/src/handler/websocket/error.rs +++ b/crates/agentic-server/src/handler/websocket/error.rs @@ -21,9 +21,6 @@ pub(super) enum WsError { #[error("websocket messages must be JSON text frames")] BinaryFrame, - #[error("OIDC bearer token expired")] - AuthenticationExpired, - #[error("websocket send failed")] SendFailed, @@ -39,7 +36,6 @@ impl WsError { match self { Self::Executor(err) => err.http_status(), Self::InvalidJson(_) | Self::UnexpectedType | Self::BinaryFrame => StatusCode::BAD_REQUEST, - Self::AuthenticationExpired => StatusCode::UNAUTHORIZED, Self::SerializeJson(_) | Self::SendFailed | Self::ClientDisconnected | Self::Receive(_) => { StatusCode::INTERNAL_SERVER_ERROR } @@ -51,7 +47,6 @@ impl WsError { Self::Executor(err) => err.error_code(), Self::InvalidJson(_) => "invalid_json", Self::UnexpectedType | Self::BinaryFrame => "invalid_request_error", - Self::AuthenticationExpired => "invalid_token", Self::SerializeJson(_) | Self::SendFailed | Self::ClientDisconnected | Self::Receive(_) => "server_error", } } diff --git a/crates/agentic-server/src/handler/websocket/responses.rs b/crates/agentic-server/src/handler/websocket/responses.rs index 97fd472f..ce49ea23 100644 --- a/crates/agentic-server/src/handler/websocket/responses.rs +++ b/crates/agentic-server/src/handler/websocket/responses.rs @@ -102,8 +102,8 @@ async fn responses_ws_loop( } }; - if let Some(error) = websocket_identity_error(principal.as_ref()) { - let _ = send_ws_error(&mut sender, &error).await; + if let Some(event) = websocket_identity_error_event(principal.as_ref()) { + let _ = send_ws_json(&mut sender, event).await; break; } @@ -130,10 +130,16 @@ async fn responses_ws_loop( debug!("responses websocket session closed"); } -fn websocket_identity_error(principal: Option<&AuthenticatedPrincipal>) -> Option { - principal - .is_some_and(AuthenticatedPrincipal::is_expired) - .then_some(WsError::AuthenticationExpired) +fn websocket_identity_error_event(principal: Option<&AuthenticatedPrincipal>) -> Option { + principal.is_some_and(AuthenticatedPrincipal::is_expired).then(|| { + serde_json::json!({ + "type": "error", + "code": "invalid_token", + "message": "OIDC bearer token expired", + "param": null, + "sequence_number": 0, + }) + }) } async fn next_ws_message( @@ -448,10 +454,12 @@ mod tests { use axum::extract::ws::Message; use futures::{Sink, Stream, StreamExt, sink, stream}; + use serde_json::json; use tokio_util::sync::CancellationToken; use super::{ - ShutdownInput, close_ws, keep_if_running, next_shutdown_input, next_ws_message, websocket_identity_error, + ShutdownInput, WsError, close_ws, keep_if_running, next_shutdown_input, next_ws_message, + websocket_identity_error_event, }; use crate::auth::AuthenticatedPrincipal; @@ -523,14 +531,27 @@ mod tests { } #[test] - fn websocket_identity_expiry_selects_the_unauthorized_error_event() { - assert!(websocket_identity_error(None).is_none()); - let error = - websocket_identity_error(Some(&AuthenticatedPrincipal::expired_for_test())).expect("expired-token error"); - let frame = error.to_ws_frame().expect("client-visible error frame"); - - assert_eq!(frame["status"], 401); - assert_eq!(frame["error"]["code"], "invalid_token"); + fn websocket_identity_expiry_uses_responses_error_event() { + assert!(websocket_identity_error_event(None).is_none()); + let frame = websocket_identity_error_event(Some(&AuthenticatedPrincipal::expired_for_test())) + .expect("expired-token error event"); + + assert_eq!( + frame, + json!({ + "type": "error", + "code": "invalid_token", + "message": "OIDC bearer token expired", + "param": null, + "sequence_number": 0, + }) + ); + + let generic_frame = WsError::UnexpectedType + .to_ws_frame() + .expect("generic client-visible error frame"); + assert_eq!(generic_frame["status"], 400); + assert_eq!(generic_frame["error"]["code"], "invalid_request_error"); } #[tokio::test] diff --git a/crates/agentic-server/tests/oidc_auth_test.rs b/crates/agentic-server/tests/oidc_auth_test.rs index a5d8e415..c10d8fc0 100644 --- a/crates/agentic-server/tests/oidc_auth_test.rs +++ b/crates/agentic-server/tests/oidc_auth_test.rs @@ -4,10 +4,10 @@ mod common; use axum::body::{Body, Bytes}; use axum::http::{HeaderMap, HeaderValue, Response, StatusCode}; use axum::response::IntoResponse; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Extension, Json, Router, middleware}; use common::{test_config, test_state}; -use futures::stream; +use futures::{SinkExt, StreamExt, stream}; use jsonwebtoken::jwk::{Jwk, KeyAlgorithm, PublicKeyUse}; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use rand::rngs::OsRng; @@ -18,6 +18,8 @@ use serde_json::{Value, json}; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::net::TcpListener; use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::Message as TungsteniteMessage; use agentic_server::app::{ServerConfig, build_router_with_auth}; use agentic_server::auth::{AuthenticatedPrincipal, OidcAuthError, OidcAuthenticator, OidcConfig, require_oidc}; @@ -330,6 +332,28 @@ fn identity_token_with_audiences( .expect("encode multi-audience identity token") } +fn identity_token_with_authorized_party( + issuer: &str, + audience: &str, + authorized_party: &str, + private_key_der: &[u8], +) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("test-key".to_owned()); + encode( + &header, + &json!({ + "iss": issuer, + "sub": "github-user-123", + "aud": audience, + "azp": authorized_party, + "exp": jsonwebtoken::get_current_timestamp() + 300 + }), + &EncodingKey::from_rsa_der(private_key_der), + ) + .expect("encode identity token with authorized party") +} + fn custom_identity_token(header: &Header, claims: &Value, private_key_der: &[u8]) -> String { encode(header, claims, &EncodingKey::from_rsa_der(private_key_der)).expect("encode custom identity token") } @@ -383,6 +407,40 @@ async fn spawn_models_upstream() -> ( (format!("http://{address}"), observed_headers, handle) } +async fn spawn_anthropic_upstream() -> ( + String, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, +) { + let observed_headers = std::sync::Arc::new(std::sync::Mutex::new(None)); + let captured_headers = std::sync::Arc::clone(&observed_headers); + let upstream = Router::new().route( + "/v1/messages", + post(move |headers: HeaderMap| { + let captured_headers = std::sync::Arc::clone(&captured_headers); + async move { + *captured_headers.lock().expect("capture headers") = Some(headers); + Json(json!({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [], + "model": "test-model", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 0, "output_tokens": 0} + })) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind upstream"); + let address = listener.local_addr().expect("upstream address"); + let handle = tokio::spawn(async move { + axum::serve(listener, upstream).await.expect("serve upstream"); + }); + (format!("http://{address}"), observed_headers, handle) +} + #[tokio::test] async fn configured_oidc_rejects_missing_bearer_before_upstream() { let (issuer, _private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; @@ -488,10 +546,6 @@ async fn configured_oidc_accepts_valid_identity_and_uses_service_upstream_creden ); let mut identity_headers = HeaderMap::new(); identity_headers.append("x-api-key", HeaderValue::from_static("distinct-upstream-key")); - identity_headers.append( - "x-api-key", - HeaderValue::from_str(&identity_token).expect("identity header value"), - ); let response = reqwest::Client::new() .get(format!("http://{}/v1/models", gateway.address)) @@ -517,8 +571,44 @@ async fn configured_oidc_accepts_valid_identity_and_uses_service_upstream_creden ); assert!( headers.get("x-api-key").is_none(), - "duplicate identity credential must not reach the upstream" + "caller-supplied OpenAI API key must not reach the upstream" + ); +} + +#[tokio::test] +async fn configured_oidc_preserves_distinct_anthropic_upstream_credential() { + let (issuer, private_key_der, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let (upstream_url, observed_headers, _upstream) = spawn_anthropic_upstream().await; + let gateway = spawn_gateway(authenticator, &upstream_url).await; + let identity_token = identity_token( + &issuer, + TEST_AUDIENCE, + jsonwebtoken::get_current_timestamp() + 300, + "test-key", + &private_key_der, + ); + + let response = reqwest::Client::new() + .post(format!("http://{}/v1/messages", gateway.address)) + .bearer_auth(identity_token) + .header("x-api-key", "distinct-upstream-key") + .json(&json!({"model": "test-model", "max_tokens": 1, "messages": []})) + .send() + .await + .expect("request gateway"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let headers = observed_headers + .lock() + .expect("read captured headers") + .clone() + .expect("upstream request"); + assert_eq!( + headers.get("x-api-key"), + Some(&HeaderValue::from_static("distinct-upstream-key")) ); + assert!(headers.get(reqwest::header::AUTHORIZATION).is_none()); } #[tokio::test] @@ -786,22 +876,36 @@ async fn jwks_refresh_failure_returns_protocol_specific_service_errors() { } #[tokio::test] -async fn multi_audience_tokens_require_the_expected_authorized_party() { +async fn tokens_require_only_trusted_audiences_and_matching_authorized_party() { let (issuer, private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; let authenticator = discover_test_authenticator(&issuer).await; let (upstream_url, _observed_headers, _upstream) = spawn_models_upstream().await; let gateway = spawn_gateway(authenticator, &upstream_url).await; - for (authorized_party, expected_status) in [ - (Some("other-client"), reqwest::StatusCode::UNAUTHORIZED), - (None, reqwest::StatusCode::UNAUTHORIZED), - (Some("agentic-api"), reqwest::StatusCode::OK), + for (audiences, authorized_party, expected_status) in [ + (&["agentic-api"][..], None, reqwest::StatusCode::OK), + (&["agentic-api"][..], Some("agentic-api"), reqwest::StatusCode::OK), + ( + &["agentic-api"][..], + Some("other-client"), + reqwest::StatusCode::UNAUTHORIZED, + ), + ( + &["agentic-api", "other-client"][..], + Some("agentic-api"), + reqwest::StatusCode::UNAUTHORIZED, + ), + ( + &["agentic-api", "other-client"][..], + None, + reqwest::StatusCode::UNAUTHORIZED, + ), ] { let response = reqwest::Client::new() .get(format!("http://{}/v1/models", gateway.address)) .bearer_auth(identity_token_with_audiences( &issuer, - &["agentic-api", "other-client"], + audiences, authorized_party, &private_key, )) @@ -810,6 +914,22 @@ async fn multi_audience_tokens_require_the_expected_authorized_party() { .expect("multi-audience request"); assert_eq!(response.status(), expected_status); } + + let scalar_audience_with_conflicting_party = reqwest::Client::new() + .get(format!("http://{}/v1/models", gateway.address)) + .bearer_auth(identity_token_with_authorized_party( + &issuer, + TEST_AUDIENCE, + "other-client", + &private_key, + )) + .send() + .await + .expect("scalar-audience request"); + assert_eq!( + scalar_audience_with_conflicting_party.status(), + reqwest::StatusCode::UNAUTHORIZED + ); } #[tokio::test] @@ -896,3 +1016,83 @@ async fn every_v1_route_rejects_missing_credentials() { .expect("public readiness request"); assert_ne!(ready.status(), reqwest::StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn authenticated_websocket_rejects_requests_after_identity_expiry() { + let (issuer, private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + let expires_at = jsonwebtoken::get_current_timestamp().saturating_sub(50); + let token = identity_token(&issuer, TEST_AUDIENCE, expires_at, "test-key", &private_key); + let mut request = format!("ws://{}/v1/responses", gateway.address) + .into_client_request() + .expect("WebSocket request"); + request.headers_mut().insert( + reqwest::header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).expect("identity header"), + ); + let (mut websocket, _response) = tokio_tungstenite::connect_async(request) + .await + .expect("authenticated WebSocket upgrade"); + + while jsonwebtoken::get_current_timestamp() <= expires_at.saturating_add(60) { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + websocket + .send(TungsteniteMessage::Text( + json!({"type": "response.create", "response": {"input": "must not run"}}) + .to_string() + .into(), + )) + .await + .expect("send post-expiry request"); + let event = tokio::time::timeout(std::time::Duration::from_secs(2), websocket.next()) + .await + .expect("expiry event must arrive promptly") + .expect("expiry event") + .expect("valid expiry frame") + .into_text() + .expect("text expiry frame"); + assert_eq!( + serde_json::from_str::(&event).expect("expiry event JSON"), + json!({ + "type": "error", + "code": "invalid_token", + "message": "OIDC bearer token expired", + "param": null, + "sequence_number": 0 + }) + ); + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_secs(2), websocket.next()) + .await + .expect("WebSocket must close promptly after expiry"), + Some(Ok(TungsteniteMessage::Close(_))) | None + )); +} + +#[tokio::test] +async fn anthropic_authentication_errors_include_matching_request_id() { + let (issuer, _private_key, _public_jwk, _jwks_requests, _provider) = spawn_oidc_provider().await; + let authenticator = discover_test_authenticator(&issuer).await; + let gateway = spawn_gateway(authenticator, "http://127.0.0.1:9").await; + + let response = reqwest::Client::new() + .post(format!("http://{}/v1/messages", gateway.address)) + .send() + .await + .expect("missing-credential Anthropic request"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let request_id = response + .headers() + .get("request-id") + .expect("Anthropic request-id header") + .to_str() + .expect("request ID must be ASCII") + .to_owned(); + let body = response.json::().await.expect("Anthropic authentication error"); + + assert!(request_id.starts_with("req_")); + assert_eq!(body["request_id"], request_id); + assert_eq!(body["error"]["type"], "authentication_error"); +} diff --git a/docs/api/index.md b/docs/api/index.md index b681fc36..dd57c5aa 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -6,9 +6,10 @@ Inbound authentication is optional. When the gateway starts with both `OIDC_ISSU `/v1/*` HTTP route and the `/v1/responses` WebSocket upgrade require an OIDC `Authorization: Bearer `. `/health` and `/ready` remain public. Supplying only one OIDC setting is a startup error. -The gateway validates the token signature, issuer, audience, authorized party for multi-audience tokens, subject, -expiration, and not-before time. It consumes the identity token at the gateway boundary instead of forwarding it to -the inference service. WebSocket sessions reject new `response.create` messages after the validated token expires. +The gateway treats `OIDC_AUDIENCE` as the complete audience trust set: every `aud` value must equal it, and any +present `azp` value must also equal it. It also validates the token signature, issuer, subject, expiration, and +not-before time. The identity token is consumed at the gateway boundary instead of being forwarded to the inference +service. WebSocket sessions reject new `response.create` messages after the validated token expires. Missing or rejected credentials return `401 Unauthorized` with `WWW-Authenticate: Bearer`. OpenAI-compatible routes use this envelope: @@ -32,10 +33,13 @@ use this envelope: "error": { "type": "authentication_error", "message": "invalid bearer token" - } + }, + "request_id": "req_019..." } ``` +The same `req_`-prefixed identifier is returned in the `request-id` response header. + A JWKS refresh failure returns `503 Service Unavailable`, without `WWW-Authenticate`, so clients can distinguish an identity-provider dependency failure from rejected credentials. See [OIDC bearer authentication](../design/oidc-bearer-authentication.md) for configuration and key-cache behavior. diff --git a/docs/deploying/github-oidc.md b/docs/deploying/github-oidc.md index 50f6f869..f9408bf7 100644 --- a/docs/deploying/github-oidc.md +++ b/docs/deploying/github-oidc.md @@ -255,8 +255,9 @@ claude -p "summarize the files in this directory" [Dex GitHub connector documentation](https://dexidp.io/docs/connectors/github/). - **Discovery or startup fails:** Dex discovery's `issuer` must exactly equal `OIDC_ISSUER`, including scheme, host, port, and path. See the [OIDC validation contract](../design/oidc-bearer-authentication.md). -- **Token is rejected:** the ID token `aud` must include `OIDC_AUDIENCE`; a GitHub access token is not a substitute. - See the [OIDC validation contract](../design/oidc-bearer-authentication.md). +- **Token is rejected:** every ID-token `aud` value must equal `OIDC_AUDIENCE`, and any present `azp` must also equal + it; a GitHub access token is not a substitute. See the + [OIDC validation contract](../design/oidc-bearer-authentication.md). - **HTTP issuer is rejected:** HTTP is allowed only for literal loopback issuers; use HTTPS elsewhere. See the [OIDC validation contract](../design/oidc-bearer-authentication.md). - **Upstream sees the identity token:** configure `OPENAI_API_KEY` separately and stop forwarding the identity token. diff --git a/docs/design/oidc-bearer-authentication.md b/docs/design/oidc-bearer-authentication.md index 5c0adc89..ea96de45 100644 --- a/docs/design/oidc-bearer-authentication.md +++ b/docs/design/oidc-bearer-authentication.md @@ -29,9 +29,11 @@ when no cache lifetime is supplied. A stale cache is refreshed before a cached k remove a compromised key without requiring a gateway restart. While cached keys remain fresh, unknown key IDs can trigger at most one refresh per 30-second cooldown after a completed fetch. Refreshes are single-flight, and every successfully fetched key set is installed even when it does not contain the key requested by the triggering token. -Concurrent refresh waiters reuse the completed result. After a refresh failure, another provider request is suppressed -for 30 seconds and callers receive `503 Service Unavailable`; a one-second coalescing window also prevents a -provider-supplied zero-second cache lifetime from causing one fetch per concurrent request after the cache expires. +Concurrent refresh waiters reuse a result that completes within one second; longer waits return `503 Service +Unavailable` so stalled provider fetches cannot accumulate unbounded request waiters. After a refresh failure, another +provider request is suppressed for 30 seconds and callers receive `503 Service Unavailable`; a one-second coalescing +window also prevents a provider-supplied zero-second cache lifetime from causing one fetch per concurrent request +after the cache expires. ## Request boundary @@ -46,7 +48,7 @@ The gateway verifies: - an asymmetric token signing algorithm and a signature from the provider JWKS; - a signing key whose `kid`, `alg`, `use`, and `key_ops` permit verification; - required `iss`, `aud`, `sub`, and `exp` claims; -- issuer and audience equality, plus `azp` equality when a token has multiple audiences; +- issuer equality; every `aud` value equal to the configured audience; and any present `azp` value equal to it; - expiration and, when present, the not-before time. Successful authentication inserts the stable issuer and subject pair into request extensions as the authenticated