diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9596d4f..eeea76f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,13 @@ jobs: - name: aws_lc_rs # Opt-in constant-time backend (aws-lc, C FFI); disables the default. flags: "--no-default-features --features aws_lc_rs,redis" + - name: injected_verifier + # No built-in verifier at all: the build a consumer takes when it + # injects its own `hooks::TokenVerifier`. Links no JWT crypto (and + # so no `rsa`), which is only true as long as nothing outside the + # `builtin_jwt` gate reaches for jsonwebtoken — this leg is what + # keeps that honest. + flags: "--no-default-features --features redis" steps: - uses: actions/checkout@v7 with: diff --git a/Cargo.toml b/Cargo.toml index c6f4ae7..466fce1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,10 +76,14 @@ ipnet = "2" redis = { version = "1.2", features = ["tokio-comp", "connection-manager"], optional = true } # Auth (JWT validation, route policies). -# Crypto backend is selected via this crate's `rust_crypto` / `aws_lc_rs` -# features (see [features] below); `use_pem` is always required for PEM key -# parsing and is therefore enabled unconditionally. -jsonwebtoken = { version = "11", default-features = false, features = ["use_pem"] } +# Optional: it backs the BUILT-IN verifier only. A consumer that injects its own +# `hooks::TokenVerifier` builds with `default-features = false` and links no JWT +# crypto at all. Crypto backend is selected via this crate's `rust_crypto` / +# `aws_lc_rs` features (see [features] below); `use_pem` is always required for +# PEM key parsing and is therefore enabled unconditionally. +jsonwebtoken = { version = "11", default-features = false, features = [ + "use_pem", +], optional = true } # reqwest 0.13's `rustls` feature hardwires the aws-lc-rs provider (C FFI, awkward # for the musl static build). We keep the pure-Rust `ring` provider instead: # `rustls-no-provider` builds rustls without a bundled provider, and auth/jwks.rs @@ -103,18 +107,27 @@ envoy-types = "0.7" [features] default = ["rust_crypto"] -# JWT crypto backend. Mutually exclusive: enable exactly one. jsonwebtoken -# picks its provider from these features and panics at runtime if both (or -# neither) are on, so do NOT build with `--all-features`. +# The built-in JWT verifier (config-driven keys: PEM file or JWKS endpoint), +# implemented with `jsonwebtoken`. Implied by both crypto backends below, so it +# is never enabled on its own. With neither backend the crate links no JWT +# crypto: `auth.mode: "jwt"` then requires a verifier injected through +# `ProxyServer::with_token_verifier`. +builtin_jwt = ["dep:jsonwebtoken"] + +# Crypto backend for the BUILT-IN verifier. Mutually exclusive: enable at most +# one. jsonwebtoken picks its provider from these features and panics at runtime +# if both are on, so do NOT build with `--all-features`. They no longer decide +# for the whole dependency graph: a consumer that cannot accept this crate's +# choice supplies its own verifier instead (see `builtin_jwt` above). # # `rust_crypto` (default): pure-Rust RustCrypto backend. Pulls in `rsa`, which # carries RUSTSEC-2023-0071 (Marvin Attack). Not exploitable on our verify-only # path (public key, no private-key ops); see deny.toml / issue #48. -rust_crypto = ["jsonwebtoken/rust_crypto"] +rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto"] # `aws_lc_rs`: constant-time / FIPS-capable backend via aws-lc (C FFI), # advisory-free. Opt-in for consumers that allow FFI; enable with # `default-features = false, features = ["aws_lc_rs"]`. -aws_lc_rs = ["jsonwebtoken/aws_lc_rs"] +aws_lc_rs = ["builtin_jwt", "jsonwebtoken/aws_lc_rs"] # Shared Redis-backed rate-limit store for multi-instance deployments. redis = ["dep:redis"] diff --git a/README.md b/README.md index 110eda5..feb1408 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Works with **any** gRPC service via proto descriptor files. No code generation, - **Prometheus metrics** at `/metrics` - **CORS** with a configurable origin allow-list - **Rate limiting (Shield)**: local GCRA shaper (no blocking latency) keyed by client IP, header, or validated JWT claim; named limit tiers as config data; optional async cross-instance reconciliation for an approximate fleet-wide limit (requires both the `redis` feature and a configured `sync` block) -- **JWT auth**: validate `Bearer` tokens via an Ed25519 PEM key or JWKS auto-discovery, enforce per-route `require_auth` / `required_roles`, and forward claims as headers +- **JWT auth**: validate `Bearer` tokens via an Ed25519 PEM key or JWKS auto-discovery, enforce per-route `require_auth` / `required_roles`, and forward claims as headers — or hand the signature check to your own verifier (a validated / FIPS module, an HSM) without changing anything else - **OIDC discovery**: serve `/.well-known/openid-configuration` and a JWKS endpoint (Ed25519) built from config, to front an identity provider - **Forward-auth**: a verification endpoint (`/auth/verify`) for a fronting proxy (nginx `auth_request`, Traefik `forwardAuth`) to delegate auth, returning the verified identity as headers - **External AuthZ**: gate proxied requests through an Envoy ext_authz gRPC server (`envoy.service.auth.v3.Authorization/Check`), interoperating with OPA and any ext_authz server, with fail-open/closed control @@ -316,12 +316,81 @@ The hooks are: - **`with_auth_decider`** — an in-process forward-auth / PDP decision, run inline on every proxied request and exposed at `/verify` (path configurable via `with_verify_path`). +- **`with_token_verifier`** — replaces the built-in JWT signature check + (see [JWT verification](#jwt-verification)) while keeping the route policies, + the roles claim, and the claim→header forwarding. - **`with_oidc_backend`** — backs the stateless OIDC surface (discovery, JWKS, userinfo) with your key/client metadata; supersedes the config-driven static discovery. - **`with_extra_routes`** — registers extra stateless routes through a framework-agnostic adapter (request parts in, response parts out). +## JWT verification + +The bearer-token check sits behind the `TokenVerifier` hook. A build gets one of +two implementations. + +**The built-in verifier** is what a plain config-driven deployment uses: keys +from `auth.jwt` (an Ed25519 PEM file or a JWKS endpoint), verified with +`jsonwebtoken`. Its crypto backend is a Cargo feature: + +| Feature | Backend | Notes | +|---------|---------|-------| +| `rust_crypto` (default) | RustCrypto | Pure Rust. Pulls in `rsa`, which carries [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071); the Marvin attack targets private-key timing, and this path only verifies with public keys (see `deny.toml`). | +| `aws_lc_rs` | aws-lc | Constant-time / FIPS-capable, advisory-free, links aws-lc through C FFI. | + +They are mutually exclusive, and enabling both is a compile error rather than a +runtime panic. Do **not** build with `--all-features`. + +**An injected verifier** is what you supply when neither of those is the right +answer for your binary: a validated / FIPS crypto module, an HSM, or a verifier +your service already owns. + +```rust +use std::sync::Arc; +use structured_proxy::hooks::TokenVerifier; +use structured_proxy::{config::ProxyConfig, ProxyServer}; + +struct MyVerifier; // your signature + claim check + +#[async_trait::async_trait] +impl TokenVerifier for MyVerifier { + async fn verify(&self, token: &str) -> Option { + // claims out, or None to reject the request with 401 + # let _ = token; + None + } +} + +# async fn run(config: ProxyConfig) -> anyhow::Result<()> { +ProxyServer::from_config(config) + .with_token_verifier(Arc::new(MyVerifier)) + .serve() + .await +# } +``` + +Injection also resolves a problem the features cannot: Cargo unifies features +across the whole dependency graph, so `rust_crypto` / `aws_lc_rs` is a property +of the *resolution*, not of a binary. Two crates in one workspace that link this +one and want different backends cannot both get their way — and if both features +end up enabled, `jsonwebtoken` refuses the combination. A consumer that injects +its own verifier is not in that argument at all: it takes + +```toml +[dependencies] +structured-proxy = { version = "3", default-features = false } +# What the verifier above is written with: the trait is `#[async_trait]`, and +# claims cross it as `serde_json::Value`. Neither is re-exported. +async-trait = "0.1" +serde_json = "1" +``` + +which links no JWT crypto (and therefore no `rsa`), and supplies the backend +from its own binary. With no verifier injected and no backend feature, an +`auth.mode: "jwt"` config is rejected at startup with that instruction, rather +than silently accepting tokens. + ## How It Works 1. Load the proto descriptor from a pre-compiled descriptor file diff --git a/deny.toml b/deny.toml index 9dba480..5d35dd4 100644 --- a/deny.toml +++ b/deny.toml @@ -9,13 +9,22 @@ # Chain: rsa -> jsonwebtoken (rust_crypto backend) -> structured-proxy. # # Ignored deliberately. See https://github.com/structured-world/structured-proxy/issues/48 -# 1. No fix exists: latest `rsa` is 0.10.0-rc and the advisory has patched:[] / -# unaffected:[] in the RustSec DB. Nothing to upgrade to. +# 1. No fix exists: the advisory has patched:[] / unaffected:[] in the RustSec +# DB, and it names both the latest stable `rsa` (0.9.10) and the latest +# pre-release (0.10.0-rc.18) as affected. There is nothing to upgrade to, +# release candidates included. # 2. We keep jsonwebtoken's pure-Rust `rust_crypto` backend over `aws_lc_rs` # (C FFI), required by no-FFI consumers (e.g. CoordiNode ADR-013). # 3. RSA here is used for JWT *verification* (public key) only. Marvin targets # private-key timing (decrypt/sign), which never runs on the verify path. # Revisit when RustCrypto ships a constant-time `rsa` stable release. +# +# The ignore covers the DEFAULT build only. `rsa` arrives with jsonwebtoken's +# whole `rust_crypto` bundle (it has no per-algorithm features), so it cannot be +# dropped by a feature of ours while that backend is in use. A deployment that +# will not carry the advisory at all builds with `default-features = false` and +# injects its own `hooks::TokenVerifier`: no jsonwebtoken, no `rsa`, nothing to +# ignore. The `injected_verifier` CI leg builds exactly that. ignore = [ - { id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists (latest 0.10.0-rc; advisory patched:[]). We deliberately keep jsonwebtoken's pure-Rust `rust_crypto` backend over `aws_lc_rs` (C FFI). RSA is used for JWT verification (public key) only; Marvin targets private-key timing, not exploitable on the verify path. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" }, + { id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists: the advisory is patched:[] and names both the latest stable (0.9.10) and the latest pre-release (0.10.0-rc.18) as affected. We deliberately keep jsonwebtoken's pure-Rust `rust_crypto` backend over `aws_lc_rs` (C FFI). RSA is used for JWT verification (public key) only; Marvin targets private-key timing, not exploitable on the verify path. A build that must not link `rsa` at all takes default-features = false and injects its own TokenVerifier. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" }, ] diff --git a/src/auth/forward.rs b/src/auth/forward.rs index 9aa13bc..873225b 100644 --- a/src/auth/forward.rs +++ b/src/auth/forward.rs @@ -109,7 +109,9 @@ fn forwarded(headers: &HeaderMap, names: &[&str]) -> Option { .map(str::to_string) } -#[cfg(test)] +// The endpoint is exercised end to end against the built-in verifier, so these +// tests need a crypto backend; the `Auth` seam itself is covered in `tests.rs`. +#[cfg(all(test, feature = "builtin_jwt"))] mod tests { use super::*; use crate::config::{AuthConfig, ForwardAuthConfig, JwtConfig, RoutePolicyConfig}; @@ -182,7 +184,7 @@ mod tests { }), authz: None, }; - let auth = Auth::build(&config).unwrap().unwrap(); + let auth = Auth::build(&config, None).unwrap().unwrap(); ForwardAuth::build(&config, auth).unwrap() } diff --git a/src/auth/jwks.rs b/src/auth/jwks.rs index 58387a8..2abc12b 100644 --- a/src/auth/jwks.rs +++ b/src/auth/jwks.rs @@ -33,21 +33,6 @@ const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(60); /// Bound the worst-case latency of a slow/stalled JWKS endpoint. const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5); -/// Build the rustls client config for the JWKS HTTPS client. -/// -/// Uses the pure-Rust `ring` provider (installed per-config, not process-global) -/// and bundles Mozilla's root store via `webpki-roots`, so the binary needs no -/// system CA bundle and works in musl / scratch / distroless images. -pub(crate) fn build_tls_config() -> rustls::ClientConfig { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) - .with_safe_default_protocol_versions() - .expect("ring provider supports the default TLS protocol versions") - .with_root_certificates(roots) - .with_no_client_auth() -} - impl JwksCache { /// Create a cache for `uri` (keys are loaded lazily on first lookup). pub fn new(uri: String) -> Self { @@ -56,7 +41,7 @@ impl JwksCache { // Hand reqwest a fully preconfigured rustls backend rather than // relying on a process-global default provider: no install ordering // constraint, no global side effect, safe for library/test callers. - .tls_backend_preconfigured(build_tls_config()) + .tls_backend_preconfigured(crate::tls::client_config()) .build() .unwrap_or_default(); Self { diff --git a/src/auth/mod.rs b/src/auth/mod.rs index b10c454..fe680ab 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,14 +1,26 @@ //! JWT authentication and route-level authorization. //! -//! Validates `Authorization: Bearer` JWTs against a configured key source (an -//! Ed25519 PEM file or a JWKS endpoint), enforces per-route policies +//! Validates `Authorization: Bearer` JWTs, enforces per-route policies //! (`require_auth` / `required_roles`), and forwards selected claims to the //! upstream as request headers. Active only when `auth.mode == "jwt"`. +//! +//! Verification itself sits behind [`TokenVerifier`]: by default the built-in +//! one (keys from `auth.jwt` — an Ed25519 PEM file or a JWKS endpoint, checked +//! with `jsonwebtoken`), or an embedder-supplied one injected through +//! [`ProxyServer::with_token_verifier`](crate::ProxyServer::with_token_verifier). +//! Everything else here — policies, roles, claim headers — is independent of +//! which one verified the token. pub mod authz; pub mod forward; +#[cfg(feature = "builtin_jwt")] pub mod jwks; pub mod policy; +#[cfg(feature = "builtin_jwt")] +mod verifier; + +#[cfg(test)] +mod tests; use std::collections::HashMap; use std::collections::HashSet; @@ -20,26 +32,28 @@ use axum::http::{HeaderMap, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum::Json; -use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; use serde_json::Value; -use crate::config::AuthConfig; -use jwks::JwksCache; +use crate::config::{default_roles_claim, AuthConfig}; +use crate::hooks::TokenVerifier; use policy::Policies; -/// Where verifying keys come from. -enum KeySource { - /// A single Ed25519 public key (EdDSA). - Pem(Arc), - /// Keys discovered from a JWKS endpoint, selected by `kid`. - Jwks(JwksCache), +/// Which implementation checks a token's signature and claims. +enum Verifier { + /// The built-in one, called directly: the default path pays no dynamic + /// dispatch and allocates no boxed future per verification. Boxed only to + /// keep the enum small — it holds an inline JWKS cache, and this costs one + /// pointer hop at build time, not per request. + #[cfg(feature = "builtin_jwt")] + Builtin(Box), + /// The embedder's, behind the public hook. + Injected(Arc), } -/// Compiled auth configuration: keys, expected claims, and route policies. +/// Compiled auth configuration: the verifier, the claims to forward, and the +/// route policies. pub struct Auth { - keys: KeySource, - issuer: Option, - audience: Option, + verifier: Verifier, claims_headers: HashMap, roles_claim: String, policies: Policies, @@ -48,28 +62,37 @@ pub struct Auth { impl Auth { /// Build auth from config, or `None` when `auth.mode` is not `"jwt"`. /// + /// `verifier` is the embedder-supplied token verifier, if any. With `None` + /// the built-in one is built from `auth.jwt`, which then must name a key + /// source. With `Some`, the key source in config is unused (the verifier + /// owns its own keys) and `auth.jwt` may be omitted entirely; the rest of + /// the block (`claims_headers`, `roles_claim`) still applies. + /// /// # Errors - /// Returns an error string when the JWT config is missing a key source, the - /// PEM file cannot be read, or a policy glob fails to compile. - pub fn build(config: &AuthConfig) -> Result>, String> { + /// Returns an error string when the built-in verifier is required but this + /// build has no crypto backend, when its key source is missing or unusable, + /// or when a policy glob fails to compile. + pub fn build( + config: &AuthConfig, + verifier: Option>, + ) -> Result>, String> { if config.mode != "jwt" { return Ok(None); } - let jwt = config - .jwt - .as_ref() - .ok_or("auth.mode is \"jwt\" but auth.jwt is not set")?; - let keys = if let Some(uri) = &jwt.jwks_uri { - KeySource::Jwks(JwksCache::new(uri.clone())) - } else if let Some(pem_path) = &jwt.public_key_pem_file { - let pem = std::fs::read(pem_path) - .map_err(|e| format!("failed to read auth.jwt.public_key_pem_file: {e}"))?; - let key = DecodingKey::from_ed_pem(&pem) - .map_err(|e| format!("invalid Ed25519 public key PEM: {e}"))?; - KeySource::Pem(Arc::new(key)) - } else { - return Err("auth.jwt requires either jwks_uri or public_key_pem_file".to_string()); + let verifier = match verifier { + Some(v) => { + if let Some(jwt) = &config.jwt { + if jwt.jwks_uri.is_some() || jwt.public_key_pem_file.is_some() { + tracing::warn!( + "auth.jwt names a key source, but an injected TokenVerifier is in use; \ + the configured keys are ignored" + ); + } + } + Verifier::Injected(v) + } + None => builtin_verifier(config)?, }; let policies = match &config.forward_auth { @@ -77,45 +100,54 @@ impl Auth { None => Policies::default(), }; + // With an injected verifier `auth.jwt` is optional, so the claim + // forwarding settings fall back to the same defaults the deserializer + // would have applied. + let (claims_headers, roles_claim) = match &config.jwt { + Some(jwt) => (jwt.claims_headers.clone(), jwt.roles_claim.clone()), + None => (HashMap::new(), default_roles_claim()), + }; + Ok(Some(Arc::new(Self { - keys, - issuer: jwt.issuer.clone(), - audience: jwt.audience.clone(), - claims_headers: jwt.claims_headers.clone(), - roles_claim: jwt.roles_claim.clone(), + verifier, + claims_headers, + roles_claim, policies, }))) } /// Verify a token and return its claims, or `None` if invalid. async fn verify(&self, token: &str) -> Option { - let header = decode_header(token).ok()?; - let (key, algorithm) = match &self.keys { - KeySource::Pem(k) => (k.clone(), Algorithm::EdDSA), - KeySource::Jwks(cache) => { - let kid = header.kid.as_deref()?; - let vk = cache.key_for(kid).await?; - (vk.key, vk.algorithm) - } - }; - // Reject algorithm confusion: the token must use the key's algorithm. - if header.alg != algorithm { - return None; + match &self.verifier { + #[cfg(feature = "builtin_jwt")] + Verifier::Builtin(v) => v.verify(token).await, + Verifier::Injected(v) => v.verify(token).await, } + } +} - let mut validation = Validation::new(algorithm); - if let Some(iss) = &self.issuer { - validation.set_issuer(&[iss]); - } - match &self.audience { - Some(aud) => validation.set_audience(&[aud]), - None => validation.validate_aud = false, - } +/// The built-in verifier, built from `auth.jwt`. +#[cfg(feature = "builtin_jwt")] +fn builtin_verifier(config: &AuthConfig) -> Result { + let jwt = config + .jwt + .as_ref() + .ok_or("auth.mode is \"jwt\" but auth.jwt is not set")?; + Ok(Verifier::Builtin(Box::new( + verifier::ConfigVerifier::build(jwt)?, + ))) +} - decode::(token, &key, &validation) - .ok() - .map(|data| data.claims) - } +/// Without a crypto backend there is no built-in verifier to build: a JWT +/// deployment must inject one. +#[cfg(not(feature = "builtin_jwt"))] +fn builtin_verifier(_config: &AuthConfig) -> Result { + Err( + "auth.mode is \"jwt\" but this build has no JWT crypto backend: enable the \ + `rust_crypto` or `aws_lc_rs` feature, or inject a verifier with \ + ProxyServer::with_token_verifier" + .to_string(), + ) } /// The outcome of an auth check for a request. @@ -149,7 +181,7 @@ impl Auth { ) -> AuthDecision { // A token that is present but invalid is always a 401, regardless of policy. let claims = match bearer_token(headers) { - Some(token) => match self.verify(&token).await { + Some(token) => match self.verify(token).await { Some(c) => Some(c), None => return AuthDecision::Unauthenticated("invalid or expired token"), }, @@ -216,13 +248,16 @@ pub async fn middleware( } /// Extract the bearer token from the `Authorization` header. -fn bearer_token(headers: &HeaderMap) -> Option { +/// +/// Borrowed from the header, not copied: the token is read and dropped within +/// the request's own auth decision, so there is nothing to own. +fn bearer_token(headers: &HeaderMap) -> Option<&str> { let value = headers.get("authorization")?.to_str().ok()?; let token = value .strip_prefix("Bearer ") .or_else(|| value.strip_prefix("bearer "))?; let token = token.trim(); - (!token.is_empty()).then(|| token.to_string()) + (!token.is_empty()).then_some(token) } /// Resolve a (possibly dotted) claim path to a JSON value. @@ -297,281 +332,3 @@ fn forbidden(message: &str) -> Response { ) .into_response() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bearer_token_parsing() { - let mut h = HeaderMap::new(); - h.insert("authorization", "Bearer abc.def.ghi".parse().unwrap()); - assert_eq!(bearer_token(&h).as_deref(), Some("abc.def.ghi")); - - let mut h2 = HeaderMap::new(); - h2.insert("authorization", "Basic xyz".parse().unwrap()); - assert_eq!(bearer_token(&h2), None); - assert_eq!(bearer_token(&HeaderMap::new()), None); - } - - #[test] - fn extract_roles_reads_array_and_dotted_path() { - let claims = serde_json::json!({ - "roles": ["admin", "billing"], - "realm_access": { "roles": ["nested"] } - }); - assert!(extract_roles(&claims, "roles").contains("admin")); - assert!(extract_roles(&claims, "realm_access.roles").contains("nested")); - assert!(extract_roles(&claims, "missing").is_empty()); - } - - #[test] - fn inject_claim_headers_renders_scalars() { - let claims = serde_json::json!({ "sub": "u-1", "n": 7, "obj": {"x": 1} }); - let mapping = HashMap::from([ - ("sub".to_string(), "x-user-id".to_string()), - ("n".to_string(), "x-n".to_string()), - ("obj".to_string(), "x-obj".to_string()), - ]); - let mut headers = HeaderMap::new(); - inject_claim_headers(&mut headers, &claims, &mapping); - assert_eq!(headers["x-user-id"], "u-1"); - assert_eq!(headers["x-n"], "7"); - // Object claim is skipped (not a scalar). - assert!(!headers.contains_key("x-obj")); - } - - // --- end-to-end JWT validation + policy enforcement --- - - use crate::config::{AuthConfig, ForwardAuthConfig, JwtConfig, RoutePolicyConfig}; - use axum::http::Request as HttpRequest; - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::sync::atomic::{AtomicU32, Ordering}; - use tower::ServiceExt; - - // Ed25519 test keypair (generated for tests only; not a secret). - const TEST_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MC4CAQAwBQYDK2VwBCIEIEVVO7H+T5tERRn/dzukOc8i9iYEKKtPh//qcrES+dCt\n\ - -----END PRIVATE KEY-----\n"; - const TEST_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ - MCowBQYDK2VwAyEARCMxEnaM2/dblLuPNgBZpTvSUXO5ir+XQ1nyzJm4CFw=\n\ - -----END PUBLIC KEY-----\n"; - - fn temp_pub_pem() -> std::path::PathBuf { - static N: AtomicU32 = AtomicU32::new(0); - let path = std::env::temp_dir().join(format!( - "sp_auth_{}_{}.pem", - std::process::id(), - N.fetch_add(1, Ordering::Relaxed) - )); - std::fs::write(&path, TEST_PUB_PEM).unwrap(); - path - } - - fn sign(claims: serde_json::Value) -> String { - let key = EncodingKey::from_ed_pem(TEST_PRIV_PEM.as_bytes()).unwrap(); - encode(&Header::new(Algorithm::EdDSA), &claims, &key).unwrap() - } - - fn future_exp() -> i64 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - now + 3600 - } - - fn auth_with_policy(roles: &[&str]) -> Arc { - let cfg = AuthConfig { - mode: "jwt".into(), - jwt: Some(JwtConfig { - jwks_uri: None, - issuer: Some("test-iss".into()), - audience: Some("test-aud".into()), - public_key_pem_file: Some(temp_pub_pem()), - claims_headers: HashMap::from([("sub".to_string(), "x-user".to_string())]), - roles_claim: "roles".into(), - }), - forward_auth: Some(ForwardAuthConfig { - enabled: true, - path: "/auth/verify".into(), - policies: vec![RoutePolicyConfig { - path: "/secure".into(), - methods: vec!["*".into()], - require_auth: true, - required_roles: roles.iter().map(|s| s.to_string()).collect(), - }], - login_url: None, - applications_path: None, - }), - authz: None, - }; - Auth::build(&cfg).unwrap().unwrap() - } - - fn app(auth: Arc) -> axum::Router { - // Both routes echo the x-user header the upstream would receive. - let echo = |headers: HeaderMap| async move { - headers - .get("x-user") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string() - }; - axum::Router::new() - .route("/secure", axum::routing::get(echo)) - .route("/open", axum::routing::get(echo)) - .layer(axum::middleware::from_fn_with_state(auth, middleware)) - } - - async fn body_string(resp: axum::response::Response) -> String { - let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); - String::from_utf8(bytes.to_vec()).unwrap() - } - - #[tokio::test] - async fn strips_client_supplied_claim_headers() { - // A client forges x-user on an unprotected route with no token; the - // proxy must not forward the forged value to the upstream. - let app = app(auth_with_policy(&[])); - let resp = app - .oneshot( - HttpRequest::get("/open") - .header("x-user", "forged-admin") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - assert_eq!(body_string(resp).await, ""); - } - - #[tokio::test] - async fn unauthenticated_role_check_is_401_not_403() { - // Policy requires a role but not auth; a request with no token is - // unauthenticated, so it must get 401, not 403. - let cfg = AuthConfig { - mode: "jwt".into(), - jwt: Some(JwtConfig { - jwks_uri: None, - issuer: None, - audience: None, - public_key_pem_file: Some(temp_pub_pem()), - claims_headers: HashMap::new(), - roles_claim: "roles".into(), - }), - forward_auth: Some(ForwardAuthConfig { - enabled: true, - path: "/auth/verify".into(), - policies: vec![RoutePolicyConfig { - path: "/secure".into(), - methods: vec!["*".into()], - require_auth: false, - required_roles: vec!["admin".into()], - }], - login_url: None, - applications_path: None, - }), - authz: None, - }; - let auth = Auth::build(&cfg).unwrap().unwrap(); - let resp = app(auth) - .oneshot( - HttpRequest::get("/secure") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn rejects_missing_token_on_protected_route() { - let app = app(auth_with_policy(&[])); - let resp = app - .oneshot( - HttpRequest::get("/secure") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn accepts_valid_token_and_injects_claim_header() { - let app = app(auth_with_policy(&["admin"])); - let token = sign(serde_json::json!({ - "iss": "test-iss", "aud": "test-aud", "exp": future_exp(), - "sub": "user-42", "roles": ["admin"] - })); - let resp = app - .oneshot( - HttpRequest::get("/secure") - .header("authorization", format!("Bearer {token}")) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); - // The sub claim was forwarded to the handler as x-user. - assert_eq!(&body[..], b"user-42"); - } - - #[tokio::test] - async fn forbids_when_required_role_missing() { - let app = app(auth_with_policy(&["admin"])); - let token = sign(serde_json::json!({ - "iss": "test-iss", "aud": "test-aud", "exp": future_exp(), - "sub": "user-42", "roles": ["viewer"] - })); - let resp = app - .oneshot( - HttpRequest::get("/secure") - .header("authorization", format!("Bearer {token}")) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn rejects_expired_and_wrong_issuer() { - let app = app(auth_with_policy(&[])); - let expired = sign(serde_json::json!({ - "iss": "test-iss", "aud": "test-aud", "exp": 1, "sub": "u", "roles": ["admin"] - })); - let resp = app - .clone() - .oneshot( - HttpRequest::get("/secure") - .header("authorization", format!("Bearer {expired}")) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - let wrong_iss = sign(serde_json::json!({ - "iss": "evil", "aud": "test-aud", "exp": future_exp(), "sub": "u", "roles": ["admin"] - })); - let resp = app - .oneshot( - HttpRequest::get("/secure") - .header("authorization", format!("Bearer {wrong_iss}")) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } -} diff --git a/src/auth/tests.rs b/src/auth/tests.rs new file mode 100644 index 0000000..fd9a0c7 --- /dev/null +++ b/src/auth/tests.rs @@ -0,0 +1,474 @@ +//! Auth middleware tests: token handling, route policies, claim forwarding, +//! and the two verifier sources (built-in and injected). + +use super::*; + +use crate::config::{AuthConfig, ForwardAuthConfig, JwtConfig, RoutePolicyConfig}; +use axum::http::Request as HttpRequest; +use tower::ServiceExt; + +#[test] +fn bearer_token_parsing() { + let mut h = HeaderMap::new(); + h.insert("authorization", "Bearer abc.def.ghi".parse().unwrap()); + assert_eq!(bearer_token(&h), Some("abc.def.ghi")); + + let mut h2 = HeaderMap::new(); + h2.insert("authorization", "Basic xyz".parse().unwrap()); + assert_eq!(bearer_token(&h2), None); + assert_eq!(bearer_token(&HeaderMap::new()), None); +} + +#[test] +fn extract_roles_reads_array_and_dotted_path() { + let claims = serde_json::json!({ + "roles": ["admin", "billing"], + "realm_access": { "roles": ["nested"] } + }); + assert!(extract_roles(&claims, "roles").contains("admin")); + assert!(extract_roles(&claims, "realm_access.roles").contains("nested")); + assert!(extract_roles(&claims, "missing").is_empty()); +} + +#[test] +fn inject_claim_headers_renders_scalars() { + let claims = serde_json::json!({ "sub": "u-1", "n": 7, "obj": {"x": 1} }); + let mapping = HashMap::from([ + ("sub".to_string(), "x-user-id".to_string()), + ("n".to_string(), "x-n".to_string()), + ("obj".to_string(), "x-obj".to_string()), + ]); + let mut headers = HeaderMap::new(); + inject_claim_headers(&mut headers, &claims, &mapping); + assert_eq!(headers["x-user-id"], "u-1"); + assert_eq!(headers["x-n"], "7"); + // Object claim is skipped (not a scalar). + assert!(!headers.contains_key("x-obj")); +} + +// --- shared harness --- + +/// A router whose routes echo the `x-user` header the upstream would receive, +/// behind the auth middleware. +fn app(auth: Arc) -> axum::Router { + let echo = |headers: HeaderMap| async move { + headers + .get("x-user") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + axum::Router::new() + .route("/secure", axum::routing::get(echo)) + .route("/open", axum::routing::get(echo)) + .layer(axum::middleware::from_fn_with_state(auth, middleware)) +} + +async fn body_string(resp: axum::response::Response) -> String { + let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +/// An `auth.jwt` block carrying only the claim-forwarding settings: no key +/// source, since an injected verifier owns its own keys. +fn jwt_claims_only() -> JwtConfig { + JwtConfig { + jwks_uri: None, + issuer: None, + audience: None, + public_key_pem_file: None, + claims_headers: HashMap::from([("sub".to_string(), "x-user".to_string())]), + roles_claim: "roles".into(), + } +} + +/// Policies for `/secure`: auth required, plus `roles` when given. +fn secure_policy(roles: &[&str]) -> ForwardAuthConfig { + ForwardAuthConfig { + enabled: true, + path: "/auth/verify".into(), + policies: vec![RoutePolicyConfig { + path: "/secure".into(), + methods: vec!["*".into()], + require_auth: true, + required_roles: roles.iter().map(|s| s.to_string()).collect(), + }], + login_url: None, + applications_path: None, + } +} + +// --- injected verifier (no crypto backend needed) --- + +/// A verifier that accepts exactly one token and answers with fixed claims, so +/// the middleware can be exercised in a build with no JWT crypto at all. +struct StubVerifier { + accepts: &'static str, + claims: Value, +} + +#[async_trait::async_trait] +impl TokenVerifier for StubVerifier { + async fn verify(&self, token: &str) -> Option { + (token == self.accepts).then(|| self.claims.clone()) + } +} + +fn auth_with_stub(roles: &[&str], jwt: Option) -> Arc { + let cfg = AuthConfig { + mode: "jwt".into(), + jwt, + forward_auth: Some(secure_policy(roles)), + authz: None, + }; + let stub = StubVerifier { + accepts: "good-token", + claims: serde_json::json!({ "sub": "stub-user", "roles": ["admin"] }), + }; + Auth::build(&cfg, Some(Arc::new(stub))).unwrap().unwrap() +} + +#[tokio::test] +async fn injected_verifier_decides_authentication() { + let auth = auth_with_stub(&[], Some(jwt_claims_only())); + + // The token the injected verifier accepts passes, and its claims are + // forwarded through the configured claims_headers mapping. + let resp = app(auth.clone()) + .oneshot( + HttpRequest::get("/secure") + .header("authorization", "Bearer good-token") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(body_string(resp).await, "stub-user"); + + // Anything else is rejected: a verifier returning None is a 401, never a + // pass-through. + let resp = app(auth) + .oneshot( + HttpRequest::get("/secure") + .header("authorization", "Bearer other-token") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn injected_verifier_claims_drive_role_policy() { + // Roles come from the injected verifier's claims, so the policy applies the + // same way it does to built-in verification. + let resp = app(auth_with_stub(&["admin"], Some(jwt_claims_only()))) + .oneshot( + HttpRequest::get("/secure") + .header("authorization", "Bearer good-token") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = app(auth_with_stub(&["superuser"], Some(jwt_claims_only()))) + .oneshot( + HttpRequest::get("/secure") + .header("authorization", "Bearer good-token") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn injected_verifier_works_without_a_jwt_block() { + // `auth.jwt` exists only to configure keys and claim forwarding; with an + // injected verifier and no claim headers, it can be omitted entirely. + let auth = auth_with_stub(&["admin"], None); + let resp = app(auth) + .oneshot( + HttpRequest::get("/secure") + .header("authorization", "Bearer good-token") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // The role policy still applies (default roles_claim), and no claim header + // is injected because none was configured. + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(body_string(resp).await, ""); +} + +#[test] +fn injected_verifier_supersedes_a_configured_key_source() { + // A config that also names a key source is accepted (the verifier wins); + // it must not fail the build, nor read the non-existent PEM file. + let cfg = AuthConfig { + mode: "jwt".into(), + jwt: Some(JwtConfig { + public_key_pem_file: Some("/nonexistent/key.pem".into()), + ..jwt_claims_only() + }), + forward_auth: None, + authz: None, + }; + let stub = StubVerifier { + accepts: "good-token", + claims: serde_json::json!({}), + }; + assert!(Auth::build(&cfg, Some(Arc::new(stub))).unwrap().is_some()); +} + +#[test] +fn no_auth_when_mode_is_not_jwt() { + let cfg = AuthConfig { + mode: "none".into(), + jwt: None, + forward_auth: None, + authz: None, + }; + assert!(Auth::build(&cfg, None).unwrap().is_none()); +} + +/// Without a crypto backend there is no built-in verifier, so a JWT config that +/// supplies none must fail loudly rather than silently accept every token. +#[cfg(not(feature = "builtin_jwt"))] +#[test] +fn jwt_mode_without_a_verifier_is_rejected() { + let cfg = AuthConfig { + mode: "jwt".into(), + jwt: Some(jwt_claims_only()), + forward_auth: None, + authz: None, + }; + let Err(err) = Auth::build(&cfg, None) else { + panic!("a jwt config with no verifier must not build"); + }; + assert!( + err.contains("with_token_verifier"), + "unexpected error: {err}" + ); +} + +// --- built-in verifier: end-to-end JWT validation + policy enforcement --- + +#[cfg(feature = "builtin_jwt")] +mod builtin { + use super::*; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use std::sync::atomic::{AtomicU32, Ordering}; + + // Ed25519 test keypair (generated for tests only; not a secret). + const TEST_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MC4CAQAwBQYDK2VwBCIEIEVVO7H+T5tERRn/dzukOc8i9iYEKKtPh//qcrES+dCt\n\ + -----END PRIVATE KEY-----\n"; + const TEST_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ + MCowBQYDK2VwAyEARCMxEnaM2/dblLuPNgBZpTvSUXO5ir+XQ1nyzJm4CFw=\n\ + -----END PUBLIC KEY-----\n"; + + fn temp_pub_pem() -> std::path::PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "sp_auth_{}_{}.pem", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&path, TEST_PUB_PEM).unwrap(); + path + } + + fn sign(claims: serde_json::Value) -> String { + let key = EncodingKey::from_ed_pem(TEST_PRIV_PEM.as_bytes()).unwrap(); + encode(&Header::new(Algorithm::EdDSA), &claims, &key).unwrap() + } + + fn future_exp() -> i64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + now + 3600 + } + + fn auth_with_policy(roles: &[&str]) -> Arc { + let cfg = AuthConfig { + mode: "jwt".into(), + jwt: Some(JwtConfig { + issuer: Some("test-iss".into()), + audience: Some("test-aud".into()), + public_key_pem_file: Some(temp_pub_pem()), + ..jwt_claims_only() + }), + forward_auth: Some(secure_policy(roles)), + authz: None, + }; + Auth::build(&cfg, None).unwrap().unwrap() + } + + #[tokio::test] + async fn strips_client_supplied_claim_headers() { + // A client forges x-user on an unprotected route with no token; the + // proxy must not forward the forged value to the upstream. + let app = app(auth_with_policy(&[])); + let resp = app + .oneshot( + HttpRequest::get("/open") + .header("x-user", "forged-admin") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(body_string(resp).await, ""); + } + + #[tokio::test] + async fn unauthenticated_role_check_is_401_not_403() { + // Policy requires a role but not auth; a request with no token is + // unauthenticated, so it must get 401, not 403. + let cfg = AuthConfig { + mode: "jwt".into(), + jwt: Some(JwtConfig { + jwks_uri: None, + issuer: None, + audience: None, + public_key_pem_file: Some(temp_pub_pem()), + claims_headers: HashMap::new(), + roles_claim: "roles".into(), + }), + forward_auth: Some(ForwardAuthConfig { + policies: vec![RoutePolicyConfig { + path: "/secure".into(), + methods: vec!["*".into()], + require_auth: false, + required_roles: vec!["admin".into()], + }], + ..secure_policy(&[]) + }), + authz: None, + }; + let auth = Auth::build(&cfg, None).unwrap().unwrap(); + let resp = app(auth) + .oneshot( + HttpRequest::get("/secure") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn rejects_missing_token_on_protected_route() { + let app = app(auth_with_policy(&[])); + let resp = app + .oneshot( + HttpRequest::get("/secure") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn accepts_valid_token_and_injects_claim_header() { + let app = app(auth_with_policy(&["admin"])); + let token = sign(serde_json::json!({ + "iss": "test-iss", "aud": "test-aud", "exp": future_exp(), + "sub": "user-42", "roles": ["admin"] + })); + let resp = app + .oneshot( + HttpRequest::get("/secure") + .header("authorization", format!("Bearer {token}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + // The sub claim was forwarded to the handler as x-user. + assert_eq!(&body[..], b"user-42"); + } + + #[tokio::test] + async fn forbids_when_required_role_missing() { + let app = app(auth_with_policy(&["admin"])); + let token = sign(serde_json::json!({ + "iss": "test-iss", "aud": "test-aud", "exp": future_exp(), + "sub": "user-42", "roles": ["viewer"] + })); + let resp = app + .oneshot( + HttpRequest::get("/secure") + .header("authorization", format!("Bearer {token}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn rejects_expired_and_wrong_issuer() { + let app = app(auth_with_policy(&[])); + let expired = sign(serde_json::json!({ + "iss": "test-iss", "aud": "test-aud", "exp": 1, "sub": "u", "roles": ["admin"] + })); + let resp = app + .clone() + .oneshot( + HttpRequest::get("/secure") + .header("authorization", format!("Bearer {expired}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let wrong_iss = sign(serde_json::json!({ + "iss": "evil", "aud": "test-aud", "exp": future_exp(), "sub": "u", "roles": ["admin"] + })); + let resp = app + .oneshot( + HttpRequest::get("/secure") + .header("authorization", format!("Bearer {wrong_iss}")) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn built_in_verifier_requires_a_key_source() { + // No jwks_uri and no PEM: the built-in verifier has nothing to verify + // against, which is a config error, not a permissive default. + let cfg = AuthConfig { + mode: "jwt".into(), + jwt: Some(jwt_claims_only()), + forward_auth: None, + authz: None, + }; + let Err(err) = Auth::build(&cfg, None) else { + panic!("a built-in verifier with no key source must not build"); + }; + assert!(err.contains("jwks_uri"), "unexpected error: {err}"); + } +} diff --git a/src/auth/verifier.rs b/src/auth/verifier.rs new file mode 100644 index 0000000..d4f98b0 --- /dev/null +++ b/src/auth/verifier.rs @@ -0,0 +1,91 @@ +//! The built-in [`TokenVerifier`]: keys from config, verification via +//! `jsonwebtoken`. +//! +//! Compiled only with the `builtin_jwt` feature (implied by `rust_crypto` / +//! `aws_lc_rs`). Without it the crate links no JWT crypto, and `auth.mode: +//! "jwt"` requires a verifier injected by the embedder instead. + +use std::sync::Arc; + +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde_json::Value; + +use super::jwks::JwksCache; +use crate::config::JwtConfig; + +/// Where verifying keys come from. +enum KeySource { + /// A single Ed25519 public key (EdDSA). + Pem(Arc), + /// Keys discovered from a JWKS endpoint, selected by `kid`. + Jwks(JwksCache), +} + +/// Config-driven verifier: a static PEM key or a JWKS endpoint, plus the +/// expected issuer and audience applied to every token. +pub(crate) struct ConfigVerifier { + keys: KeySource, + issuer: Option, + audience: Option, +} + +impl ConfigVerifier { + /// Build from the `auth.jwt` block. + /// + /// # Errors + /// Returns an error string when no key source is configured, the PEM file + /// cannot be read, or it is not a valid Ed25519 public key. + pub(crate) fn build(jwt: &JwtConfig) -> Result { + let keys = if let Some(uri) = &jwt.jwks_uri { + KeySource::Jwks(JwksCache::new(uri.clone())) + } else if let Some(pem_path) = &jwt.public_key_pem_file { + let pem = std::fs::read(pem_path) + .map_err(|e| format!("failed to read auth.jwt.public_key_pem_file: {e}"))?; + let key = DecodingKey::from_ed_pem(&pem) + .map_err(|e| format!("invalid Ed25519 public key PEM: {e}"))?; + KeySource::Pem(Arc::new(key)) + } else { + return Err("auth.jwt requires either jwks_uri or public_key_pem_file".to_string()); + }; + + Ok(Self { + keys, + issuer: jwt.issuer.clone(), + audience: jwt.audience.clone(), + }) + } + + /// Verify a token and return its claims, or `None` if invalid. + /// + /// Inherent rather than an impl of [`TokenVerifier`](crate::hooks::TokenVerifier): + /// this is the default path, and calling it directly keeps the boxed future + /// an `#[async_trait]` object would cost off every verification. + pub(crate) async fn verify(&self, token: &str) -> Option { + let header = decode_header(token).ok()?; + let (key, algorithm) = match &self.keys { + KeySource::Pem(k) => (k.clone(), Algorithm::EdDSA), + KeySource::Jwks(cache) => { + let kid = header.kid.as_deref()?; + let vk = cache.key_for(kid).await?; + (vk.key, vk.algorithm) + } + }; + // Reject algorithm confusion: the token must use the key's algorithm. + if header.alg != algorithm { + return None; + } + + let mut validation = Validation::new(algorithm); + if let Some(iss) = &self.issuer { + validation.set_issuer(&[iss]); + } + match &self.audience { + Some(aud) => validation.set_audience(&[aud]), + None => validation.validate_aud = false, + } + + decode::(token, &key, &validation) + .ok() + .map(|data| data.claims) + } +} diff --git a/src/config.rs b/src/config.rs index acd3d05..76e3b9d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -298,7 +298,9 @@ pub struct JwtConfig { pub roles_claim: String, } -fn default_roles_claim() -> String { +/// Default for [`JwtConfig::roles_claim`]. Also applied by the auth builder when +/// an injected verifier makes the whole `auth.jwt` block optional. +pub(crate) fn default_roles_claim() -> String { "roles".into() } diff --git a/src/hooks.rs b/src/hooks.rs index ef90934..be118b1 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -74,6 +74,39 @@ pub trait AuthDecider: Send + Sync { async fn decide(&self, req: &RequestParts<'_>) -> Decision; } +/// Verifies a bearer token and yields its claims. +/// +/// This is the seam the JWT middleware validates through. The built-in +/// implementation (`jsonwebtoken`, keys from `auth.jwt`) is what a plain +/// config-driven deployment gets; an embedder injects its own through +/// [`ProxyServer::with_token_verifier`](crate::ProxyServer::with_token_verifier) +/// when it needs a different signature backend — a validated / FIPS module, an +/// HSM, a shared verifier it already owns. +/// +/// Injecting one is what makes the crypto backend a property of the *binary* +/// rather than of the dependency graph: Cargo unifies features across the whole +/// resolution, so two consumers of this crate that want different built-in +/// backends cannot coexist, while two consumers that inject their own verifiers +/// can. A build that injects one needs no crypto backend feature at all +/// (`default-features = false`), and then links no JWT crypto. +/// +/// Everything around verification stays with the proxy: route policies +/// (`require_auth` / `required_roles`), the roles claim, and the claim→header +/// forwarding all operate on the returned claims. +#[async_trait] +pub trait TokenVerifier: Send + Sync { + /// Verify `token` and return its claims, or `None` to reject the request + /// with `401`. + /// + /// `token` is the raw JWT from the `Authorization: Bearer` header, already + /// stripped of the prefix and guaranteed non-empty. The implementation owns + /// the whole check — signature, `exp`/`nbf`, issuer, audience — since only + /// it knows which of those its keys and policy imply. Returning claims for + /// a token whose signature was not verified would hand a forged identity to + /// the upstream. + async fn verify(&self, token: &str) -> Option; +} + /// A static JSON document served at a fixed path (an OIDC metadata document or a /// JWKS document). #[derive(Debug, Clone)] diff --git a/src/lib.rs b/src/lib.rs index aef3685..e04307d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,20 +10,37 @@ //! structured-proxy --config sflow-proxy.yaml //! ``` //! -//! ## JWT crypto backend +//! ## JWT verification //! -//! Exactly one crypto backend feature must be enabled (they are mutually -//! exclusive): `rust_crypto` (default, pure Rust) or `aws_lc_rs` (opt-in, -//! constant-time / FIPS-capable, links aws-lc via C FFI). Enabling both or -//! neither is rejected at compile time by the guards below. +//! The bearer-token check sits behind [`hooks::TokenVerifier`]. A build gets one +//! of two: +//! +//! - the **built-in** verifier (keys from `auth.jwt`), whose crypto backend is +//! picked by a feature: `rust_crypto` (default, pure Rust) or `aws_lc_rs` +//! (opt-in, constant-time / FIPS-capable, links aws-lc via C FFI). They are +//! mutually exclusive; enabling both is rejected at compile time below. +//! - an **injected** one, supplied by the embedder through +//! [`ProxyServer::with_token_verifier`]. Since Cargo unifies features across a +//! whole dependency graph, a backend feature cannot be chosen per binary — +//! injection is how a consumer that needs a different one gets it without +//! deciding for everyone else who links this crate. Such a build takes +//! `default-features = false` and links no JWT crypto at all. // jsonwebtoken selects its provider from these features and would otherwise // panic at runtime on an invalid combination; turn that into a build error. #[cfg(all(feature = "rust_crypto", feature = "aws_lc_rs"))] -compile_error!("features `rust_crypto` and `aws_lc_rs` are mutually exclusive; enable exactly one"); - -#[cfg(not(any(feature = "rust_crypto", feature = "aws_lc_rs")))] -compile_error!("exactly one JWT crypto backend must be enabled: `rust_crypto` or `aws_lc_rs`"); +compile_error!("features `rust_crypto` and `aws_lc_rs` are mutually exclusive; enable at most one"); + +// `builtin_jwt` is implied by each backend and never meant to stand alone: on +// its own it would link jsonwebtoken with no provider, which panics at runtime. +#[cfg(all( + feature = "builtin_jwt", + not(any(feature = "rust_crypto", feature = "aws_lc_rs")) +))] +compile_error!( + "feature `builtin_jwt` needs a crypto backend: enable `rust_crypto` or `aws_lc_rs` \ + (or neither, and inject a verifier with `ProxyServer::with_token_verifier`)" +); pub mod auth; pub mod config; @@ -32,6 +49,7 @@ pub mod hooks; pub mod oidc; pub mod openapi; pub mod shield; +mod tls; pub mod transcode; use axum::extract::State; @@ -48,7 +66,7 @@ use tower_http::trace::TraceLayer; use std::sync::Arc; use config::{DescriptorSource, ProxyConfig}; -use hooks::{AuthDecider, ExtraRoute, OidcBackend}; +use hooks::{AuthDecider, ExtraRoute, OidcBackend, TokenVerifier}; /// Shared state for all proxy handlers. #[derive(Clone, Debug)] @@ -88,6 +106,8 @@ pub struct ProxyServer { extra_routes: Vec, /// Override for the `/verify` forward-auth path of an injected AuthDecider. verify_path: Option, + /// Embedder-supplied JWT verifier, replacing the built-in one. + token_verifier: Option>, } impl ProxyServer { @@ -100,6 +120,7 @@ impl ProxyServer { oidc_backend: None, extra_routes: Vec::new(), verify_path: None, + token_verifier: None, } } @@ -147,6 +168,25 @@ impl ProxyServer { self } + /// Verify bearer tokens with the embedder's own verifier instead of the + /// built-in one (embedded Tier-2 hook). + /// + /// The JWT middleware keeps everything around the signature check — route + /// policies, the roles claim, claim→header forwarding — and takes the + /// verdict from [`hooks::TokenVerifier`]. Use this when the built-in crypto + /// backend is not the one this binary needs: a validated / FIPS module, an + /// HSM, or a verifier the embedder already owns. It is also the way out of + /// Cargo's feature unification, which makes `rust_crypto` / `aws_lc_rs` a + /// property of the whole dependency graph rather than of one binary. + /// + /// `auth.mode` must still be `"jwt"` for the middleware to run; `auth.jwt` + /// then only configures claim forwarding, and any key source in it is + /// ignored (with a warning), since the verifier owns its own keys. + pub fn with_token_verifier(mut self, verifier: Arc) -> Self { + self.token_verifier = Some(verifier); + self + } + /// Load descriptor pool from configured sources. /// /// Multiple descriptor files are merged into a single pool, @@ -499,9 +539,8 @@ impl ProxyServer { // JWT auth, if configured (auth.mode == "jwt"). let auth = match &self.config.auth { - Some(cfg) => { - auth::Auth::build(cfg).map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))? - } + Some(cfg) => auth::Auth::build(cfg, self.token_verifier.clone()) + .map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))?, None => None, }; diff --git a/src/shield/resolve.rs b/src/shield/resolve.rs index 187f717..bb98155 100644 --- a/src/shield/resolve.rs +++ b/src/shield/resolve.rs @@ -189,7 +189,7 @@ impl LimitService { } let client = reqwest::Client::builder() .timeout(Duration::from_millis(cfg.timeout_ms.max(1))) - .tls_backend_preconfigured(crate::auth::jwks::build_tls_config()) + .tls_backend_preconfigured(crate::tls::client_config()) .build() .map_err(|e| format!("invalid limit_service client: {e}"))?; let ttl = Duration::from_secs(cfg.ttl_secs.max(1)); diff --git a/src/shield/tests.rs b/src/shield/tests.rs index a8caa71..3fae15e 100644 --- a/src/shield/tests.rs +++ b/src/shield/tests.rs @@ -315,6 +315,10 @@ fn build_rejects_unknown_default_profile() { /// End-to-end two-phase test: a rule keyed by a validated JWT claim, layered in /// the same order as the server (pre-auth → auth → post-auth), limits per /// principal using the claims the auth middleware verifies and attaches. +/// +/// Drives the built-in verifier (a PEM key from config), so it needs a crypto +/// backend; the post-auth phase itself is verifier-agnostic. +#[cfg(feature = "builtin_jwt")] mod two_phase { use super::*; use crate::auth::Auth; @@ -368,7 +372,7 @@ mod two_phase { forward_auth: None, authz: None, }; - Auth::build(&cfg).unwrap().unwrap() + Auth::build(&cfg, None).unwrap().unwrap() } fn stack(shield: std::sync::Arc, auth: std::sync::Arc) -> Router { diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..652ea72 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,19 @@ +//! The rustls client configuration for the proxy's own outbound HTTPS calls +//! (JWKS fetches, the rate-limit service). + +use std::sync::Arc; + +/// Build the rustls client config for an outbound HTTPS client. +/// +/// Uses the pure-Rust `ring` provider (installed per-config, not process-global) +/// and bundles Mozilla's root store via `webpki-roots`, so the binary needs no +/// system CA bundle and works in musl / scratch / distroless images. +pub(crate) fn client_config() -> rustls::ClientConfig { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .expect("ring provider supports the default TLS protocol versions") + .with_root_certificates(roots) + .with_no_client_auth() +} diff --git a/tests/hooks.rs b/tests/hooks.rs index e3256c9..8bcb7a0 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -14,7 +14,7 @@ use http::{HeaderMap, Method, StatusCode}; use structured_proxy::config::ProxyConfig; use structured_proxy::hooks::{ AuthDecider, Decision, ExtraRoute, ExtraRouteHandler, MetadataDocument, OidcBackend, - RequestParts, RouteRequest, RouteResponse, + RequestParts, RouteRequest, RouteResponse, TokenVerifier, }; use structured_proxy::ProxyServer; @@ -69,6 +69,19 @@ impl OidcBackend for DemoOidc { } } +/// Stands in for an embedder's own JWT verification (a validated crypto module, +/// an HSM, a verifier it already owns): the proxy never sees a key, only the +/// verdict and the claims. +struct EmbedderVerifier; + +#[async_trait] +impl TokenVerifier for EmbedderVerifier { + async fn verify(&self, token: &str) -> Option { + (token == "embedder-token") + .then(|| serde_json::json!({ "sub": "embedded-user", "roles": ["admin"] })) + } +} + struct PingHandler; #[async_trait] @@ -598,6 +611,65 @@ metrics: assert_eq!(blocked.status(), StatusCode::SERVICE_UNAVAILABLE); } +/// The JWT surface backed by an injected verifier, exercised through the +/// forward-auth endpoint. The config names no key source at all, which the +/// built-in verifier would reject: the verifier is the key source now, and the +/// build links no JWT crypto of its own when no backend feature is enabled. +#[tokio::test] +async fn injected_token_verifier_backs_the_jwt_surface() { + let config = ProxyConfig::from_yaml_str( + r#" +upstream: + default: "http://127.0.0.1:50051" +service: + name: "verifier-test" +auth: + mode: "jwt" + jwt: + claims_headers: + sub: "x-user-id" + forward_auth: + enabled: true + path: "/auth/verify" + policies: + - path: "/v1/admin/**" + methods: ["*"] + require_auth: true + required_roles: ["admin"] +"#, + ) + .unwrap(); + + let app = ProxyServer::from_config(config) + .with_token_verifier(Arc::new(EmbedderVerifier)) + .router() + .unwrap(); + + let verify = |token: Option<&str>| { + let mut req = axum::http::Request::get("/auth/verify") + .header("x-forwarded-method", "GET") + .header("x-forwarded-uri", "/v1/admin/things"); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + app.clone().oneshot(req.body(Body::empty()).unwrap()) + }; + + // The token the embedder's verifier accepts passes, and the claims it + // returned drive both the role policy and the forwarded identity header. + let ok = verify(Some("embedder-token")).await.unwrap(); + assert_eq!(ok.status(), StatusCode::OK); + assert_eq!(ok.headers()["x-user-id"], "embedded-user"); + + // A token it rejects is a 401, not a pass-through. + let bad = verify(Some("forged-token")).await.unwrap(); + assert_eq!(bad.status(), StatusCode::UNAUTHORIZED); + + // And the policy still requires authentication when no token is presented. + let anon = verify(None).await.unwrap(); + assert_eq!(anon.status(), StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn extra_route_is_mounted() { let app = server().router().unwrap();