From fd30af9628bd4ce3fa0e5a900c8a71cff787bd0c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 20 Sep 2026 20:37:35 +0300 Subject: [PATCH 1/2] feat(auth)!: verify tokens through an injectable TokenVerifier The JWT signature backend was chosen by mutually exclusive cargo features, and Cargo unifies features across a whole resolution, so the choice belonged to the dependency graph rather than to a binary. Two consumers of this crate that need different backends could not coexist: both features end up enabled and jsonwebtoken refuses that combination. Verification now sits behind the `TokenVerifier` hook, alongside the existing AuthDecider / OidcBackend / ExtraRoute seams: - `ProxyServer::with_token_verifier` injects the embedder's own verifier, so a binary that needs a validated module, an HSM, or a verifier it already owns supplies one without deciding for everyone else who links this crate. - `jsonwebtoken` is now an optional dependency behind `builtin_jwt`, implied by `rust_crypto` / `aws_lc_rs`. A consumer that injects a verifier builds with `default-features = false` and links no JWT crypto at all, which also keeps `rsa` (RUSTSEC-2023-0071) out of its graph rather than excusing it. - With no backend and no injected verifier, an `auth.mode: "jwt"` config is rejected at startup naming the way out, instead of accepting every token. - Enabling both backends stays a compile error; enabling neither is now a supported build rather than one. Everything around the signature check is unchanged and verifier-agnostic: route policies, the roles claim, and claim-to-header forwarding all operate on the returned claims. With an injected verifier `auth.jwt` becomes optional and any key source in it is ignored with a warning. On the way through the auth path: the built-in verifier is called directly instead of through the trait object, so the default deployment allocates no boxed future per verification, and the bearer token is now borrowed from the header instead of copied into a String per request. Closes #81 BREAKING CHANGE: `auth::Auth::build` takes the optional verifier as a second argument, and `auth::jwks` is compiled only with the `builtin_jwt` feature. --- .github/workflows/ci.yml | 7 + Cargo.toml | 31 ++- README.md | 66 +++++- deny.toml | 9 +- src/auth/forward.rs | 6 +- src/auth/jwks.rs | 17 +- src/auth/mod.rs | 441 ++++++++---------------------------- src/auth/tests.rs | 474 +++++++++++++++++++++++++++++++++++++++ src/auth/verifier.rs | 91 ++++++++ src/config.rs | 4 +- src/hooks.rs | 33 +++ src/lib.rs | 65 ++++-- src/shield/resolve.rs | 2 +- src/shield/tests.rs | 6 +- src/tls.rs | 19 ++ tests/hooks.rs | 74 +++++- 16 files changed, 957 insertions(+), 388 deletions(-) create mode 100644 src/auth/tests.rs create mode 100644 src/auth/verifier.rs create mode 100644 src/tls.rs 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..54bcbb5 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,76 @@ 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 +structured-proxy = { version = "3", default-features = false } +``` + +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..8f66b1d 100644 --- a/deny.toml +++ b/deny.toml @@ -16,6 +16,13 @@ # 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 (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. 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(); From fff15db13965a6343e583f0772a69cdcc33f3fad Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 20 Sep 2026 20:50:39 +0300 Subject: [PATCH 2/2] docs: state the advisory's affected releases and the example's own deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deny.toml: RUSTSEC-2023-0071 names both the latest stable rsa (0.9.10) and the latest pre-release (0.10.0-rc.18) as affected, so say that instead of "latest is 0.10.0-rc" — the point is that waiting for a release candidate is not a plan either. - README: the injected-verifier snippet is a dependency stanza a reader copies, so it declares async-trait and serde_json, which the verifier in the example above it is written with and this crate does not re-export. --- README.md | 5 +++++ deny.toml | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 54bcbb5..feb1408 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,12 @@ 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 diff --git a/deny.toml b/deny.toml index 8f66b1d..5d35dd4 100644 --- a/deny.toml +++ b/deny.toml @@ -9,8 +9,10 @@ # 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 @@ -24,5 +26,5 @@ # 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. 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" }, + { 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" }, ]