dwara_core/security/
authn.rs

1//! Request authentication (DW-019, feature analysis section 4.6; pepper
2//! and mTLS hardening per #124; HMAC request signing per DW-036).
3//!
4//! Five credential families behind one [`Authenticator`] trait:
5//!
6//! - **API keys**: `X-API-Key: <key>`. The lookup SELECTOR is
7//!   `hex(sha256(key))` — never the plaintext key — and the stored hash is
8//!   `hmac-sha256:<hex(HMAC-SHA256(pepper, key))>` when the deployment
9//!   configures a credential pepper (#124) or legacy
10//!   `sha256:<hex(sha256(key))>` otherwise, verified with a
11//!   constant-time comparison (`subtle`) in either case. Optional
12//!   memory-hard verification: a credential whose stored hash is a PHC
13//!   string (`$argon2id$...`, admin-supplied through the state store) is
14//!   verified with argon2id. Trade-off (documented choice):
15//!   sha256/HMAC+ct-compare is the pragmatic gateway standard — an
16//!   argon2 verify is memory-hard and tens of milliseconds, far too slow
17//!   for a per-request hot path, so config-declared keys are always
18//!   fast-path hashed at seed time and argon2id is opt-in per credential.
19//! - **Basic**: `Authorization: Basic base64(user:pass)`. The username is
20//!   the selector (`hex(sha256(user))`, the same selector space as API
21//!   keys) and the password is verified against the stored hash through
22//!   the SAME hashing path as API keys (peppered when a pepper is
23//!   configured). Basic credentials therefore live in the state store
24//!   (config declares API keys, not username/password pairs); the
25//!   resolved identity is reported with kind `api_key`. REQUIREMENT:
26//!   store-managed Basic credentials must store argon2id PHC strings
27//!   (`$argon2id$...`) — a human-chosen password hashed with plain
28//!   unsalted sha256 is offline-dictionary bait (see the hardening notes
29//!   below). Note also that usernames remain enumerable: the selector is
30//!   a deterministic hash of the username with no secret input, so an
31//!   attacker with the store can confirm username guesses offline.
32//! - **JWT**: `Authorization: Bearer <token>`, verified per
33//!   [`JwtProvider`][crate::config::JwtProvider] config: JWKS fetched from
34//!   the provider's URL, cached, refreshed after `refresh_secs` (the
35//!   refresh happens BEFORE a stale cached set is used, so retired issuer
36//!   keys cannot keep verifying forever; a failed refresh degrades to the
37//!   cached keys, and a RECENTLY failed refresh backs off for
38//!   `min(5s, refresh_secs)` — subsequent stale lookups skip the fetch
39//!   and answer from the cache immediately, so a down/slow endpoint
40//!   cannot chain every Bearer request through a serialized 5s fetch
41//!   timeout) AND on an unknown `kid` (rotation: a fresh key id
42//!   appearing mid-flight triggers a re-fetch and the token then verifies
43//!   — no restart, no failure). Refresh-triggered (unknown-kid) fetches
44//!   are throttled to one per `min(5s, refresh_secs)` — forged random-kid
45//!   tokens cannot drive a JWKS fetch storm. `iss`/`exp`/`nbf` are
46//!   validated with `leeway_secs` skew tolerance, and `aud` is validated
47//!   ONLY when the provider configures an audience (#124, maintainer
48//!   decision): a provider without `audience` accepts tokens that carry
49//!   any (or no) `aud` claim; the algorithm allowlist (default
50//!   RS256/ES256) is enforced BEFORE any signature work (`none` and
51//!   `HS*` are asymmetric-confusion bait and never allowed).
52//! - **mTLS** (#124): the client certificate of the connection itself.
53//!   On a TERMINATE listener with `client_ca_file` set, the TLS layer
54//!   verifies any presented certificate against that bundle (rustls;
55//!   an unverified certificate fails the handshake and never reaches
56//!   authn) and the listener forwards the verified certificate here as a
57//!   [`ClientCertificate`]. The credential record maps it to a consumer:
58//!   an `mtls` credential's SELECTOR is its match value — the subject
59//!   CommonName when the credential is configured `by subject`, or the
60//!   certificate's SHA-256 fingerprint when `by fingerprint` — and a
61//!   presented certificate matches when its subject CN or fingerprint
62//!   equals that selector. Passthrough listeners never speak HTTP, and
63//!   cleartext listeners have no certificates, so the family is inert
64//!   there by construction.
65//! - **HMAC request signing** (DW-036): a per-request signature over the
66//!   request line, payload digest, timestamp, and nonce, presented in
67//!   the `X-Dwara-*` header family — see the dedicated section below
68//!   for the canonical-string contract (interop depends on it).
69//!
70//! Accepted formats (composite dispatch on request shape): `X-API-Key`
71//! wins over `Authorization`; within `Authorization`, `Basic` and
72//! `Bearer` are distinguished by the scheme token; a presented
73//! `X-Dwara-Signature` engages the HMAC family after the
74//! `Authorization` schemes; the client certificate is the AMBIENT
75//! family — consulted only when NO header credential was presented (a
76//! header expresses explicit intent and wins; the certificate is
77//! connection-level context). A `Bearer` header with no JWT provider
78//! stays pass-through (not interpreted), so such a request still falls
79//! through to the HMAC headers and then the certificate family. A
80//! gateway with NO consumers and NO JWT providers has authentication
81//! disabled: the authenticator resolves `Anonymous` for everything and
82//! `Authorization` is forwarded upstream untouched (pass-through mode).
83//! Once ANY credential is configured, the gateway INTERPRETS
84//! `Authorization` — except that `Bearer` stays pass-through unless a
85//! JWT provider exists (a gateway fronting an OAuth-protected upstream
86//! without its own JWT config must keep forwarding tokens).
87//!
88//! # HMAC request signing (DW-036)
89//!
90//! A consumer with an `hmac` credential (`key_id` + `secret`, the secret
91//! inline or a `${...}` reference, DW-045) signs every request. The
92//! secret never becomes a stored hash: recomputing an HMAC needs the
93//! raw key bytes, so the RESOLVED secret lives only in this module's
94//! in-memory key map (zeroized on drop) and the state store never sees
95//! an `hmac` row. The pepper (#124) does not apply — it guards stored
96//! hashes, and there is none.
97//!
98//! ## Wire format
99//!
100//! Five request headers, all REQUIRED when `X-Dwara-Signature` is
101//! presented (any missing or malformed one is a 401, like any other
102//! presented-but-invalid credential):
103//!
104//! | Header | Content |
105//! |---|---|
106//! | `X-Dwara-Key-Id` | the credential's `key_id` (public selector; 1..=128 visible-ASCII bytes) |
107//! | `X-Dwara-Timestamp` | decimal Unix epoch seconds of signing (digits only) |
108//! | `X-Dwara-Nonce` | opaque client string, 16..=256 visible-ASCII bytes, unique per request within the replay window (use >= 128 bits of entropy) |
109//! | `X-Dwara-Body-Sha256` | lowercase hex SHA-256 of the request body (the empty body signs `e3b0c4...b855`, SHA-256 of the empty string) |
110//! | `X-Dwara-Signature` | lowercase hex HMAC-SHA256(secret, canonical string) |
111//!
112//! The custom `X-Dwara-*` family (not an `Authorization` scheme)
113//! follows the codebase's `X-API-Key` precedent: discrete headers make
114//! each signed element explicit and keep the signature material
115//! inspectable without parsing a parameterized scheme. Like the other
116//! credential headers they are forwarded upstream untouched (only
117//! `X-Consumer-*` are stripped).
118//!
119//! ## Canonical string (v1) — the interop contract
120//!
121//! A version line followed by the seven signed elements, each pair
122//! joined by exactly one `\n` byte (no trailing newline). No element
123//! may itself contain `\n` — the
124//! grammar guarantees it (visible ASCII excludes control characters,
125//! the timestamp is digits, the digest is hex, and hyper's parser
126//! rejects raw control bytes in a request target):
127//!
128//! ```text
129//! dwara-hmac-v1
130//! <key id>            the X-Dwara-Key-Id value, exactly as presented
131//! <method>            the HTTP method, uppercased (GET, POST, ...)
132//! <path>              the request path EXACTLY as received: percent-encoding preserved, no normalization
133//! <query>             the raw query string as received (no leading '?'), or an EMPTY line when absent
134//! <timestamp>         the X-Dwara-Timestamp value, exactly as presented
135//! <nonce>             the X-Dwara-Nonce value, exactly as presented
136//! <body digest>       the X-Dwara-Body-Sha256 value, exactly as presented
137//! ```
138//!
139//! Design decisions, each load-bearing:
140//!
141//! - **Path/query exactly as received.** The signer cannot know what
142//!   normalization a proxy chain might apply, so the only lossless
143//!   contract is the raw bytes the client put on the wire; the gateway
144//!   re-reads them from its parsed (but un-normalized) request target.
145//!   Query ORDER is signed: `?a=1&b=2` and `?b=2&a=1` are different
146//!   canonical strings.
147//! - **Body by digest, carried in a signed header.** Signing the body
148//!   inline would require buffering it (the gateway hashes nothing
149//!   until it streams); the digest header lets the gateway verify the
150//!   MAC over headers only and then enforce the digest while STREAMING
151//!   the body to the upstream — zero buffering, any body size, and the
152//!   stream is aborted (401 to the client, truncated request to the
153//!   upstream) the moment the final byte's hash disagrees. A tampered
154//!   body therefore never completes upstream. The route's
155//!   `max_body_bytes` (DW-027) composes with this: the digesting
156//!   wrapper sits inside the route's limit wrapper, so an over-cap
157//!   body is still rejected 413 first.
158//! - **No other headers in v1.** The signed set binds the request
159//!   line (method/path/query), the payload (digest), freshness
160//!   (timestamp), uniqueness (nonce), and identity (key id). The
161//!   `Host` header is NOT signed: it is a routing input, so a party
162//!   able to tamper headers between signer and gateway (a non-TLS
163//!   listener or an untrusted proxy hop) could retarget a validly
164//!   signed request to a different host-matched route while MAC and
165//!   digest still verify — TLS termination makes that party mostly
166//!   hypothetical, and the gateway rebuilds Host from the upstream
167//!   pick, so no cross-host forwarding occurs. `Content-Type` and
168//!   friends are not authn inputs here, and signing arbitrary header
169//!   lists drags in header-selection and canonicalization ambiguity
170//!   every signer must replicate exactly. The versioned first line
171//!   leaves room for a v2 with opt-in header (including Host)
172//!   coverage without breaking v1 signers.
173//!
174//! ## Verification order and failure posture
175//!
176//! 1. Header presence/format parse (401 on any malformed element).
177//! 2. Timestamp inside `±max_clock_skew_secs` (default 300s, the §4.6
178//!    recommendation; gateway `hmac_auth` block, validated 1..=3600).
179//!    Checked BEFORE any HMAC work — an expired window is not a MAC
180//!    problem, and refusing early keeps the hot path cheap for
181//!    stale traffic. Outside the window: 401.
182//! 3. Key lookup, then `HMAC-SHA256(secret, canonical)` compared to
183//!    the presented signature with `subtle::ConstantTimeEq` over the
184//!    full 32-byte digests — no early return on a byte mismatch. A
185//!    key-miss computes a dummy HMAC first (fixed zero key) so the
186//!    timing shape of "unknown key" matches "wrong signature" and
187//!    key-existence is not readable from latency; both answer the
188//!    same 401 shape as every other family.
189//! 4. Nonce replay check, AFTER a successful MAC: the nonce is
190//!    remembered under `key_id + '\n' + nonce` for twice the skew
191//!    window (a timestamp stays acceptable for at most one full
192//!    window after its request was first seen, so the doubled TTL
193//!    covers the boundary with margin). A remembered nonce inside
194//!    its TTL: 401. Burned only on VALID signatures — junk traffic
195//!    cannot flood legitimate nonces out of the cache.
196//!
197//! ## Replay window boundary (single instance, M2)
198//!
199//! The nonce cache is in-memory, sharded (`NONCE_CACHE_SHARDS` locks,
200//! the GCRA store's pattern), TTL-expired, and capped at
201//! [`crate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARD`]
202//! entries per shard with soonest-expiry-first eviction — under a
203//! nonce flood the cache degrades fail-open to eviction (the
204//! documented GCRA trade: an availability DoS must not become a
205//! gateway outage), and replay protection is only as strong as the
206//! cache's retention. It is also PER-INSTANCE: dwara M2 is a
207//! single-process deployment, and a multi-instance fleet behind one
208//! VIP would let a replayed request hit a cold instance. A shared
209//! nonce store is the enterprise/Redis seam (DW-031's world), not
210//! this module's; the boundary is documented here so operators do not
211//! mistake per-instance replay protection for fleet-wide.
212//!
213//! Identity-to-consumer mapping: API keys and Basic map via the credential
214//! record; JWTs map via the provider's `consumer` binding, or by matching
215//! a consumer's `jwt` credential `issuer` (with audience containment when
216//! the credential lists audiences) against the token's `iss`; client
217//! certificates map via the `mtls` credential match value. Every resolved
218//! identity carries the consumer's GROUPS (config consumers from the
219//! config record, store-managed consumers from the store's
220//! `consumers.groups`, #124) so group-based authorization applies
221//! uniformly.
222//!
223//! Consumer-identity headers (spoof prevention): the proxy strips every
224//! client-supplied `X-Consumer-*` header and injects a trusted
225//! `X-Consumer-Name` upstream when authentication resolved a consumer —
226//! see `proxy` (the strip/inject lives on the forward path).
227//!
228//! # Hardening notes (residual risks and the road to closing them)
229//!
230//! The stored-hash fast path is UNSALTED sha256 when no pepper is
231//! configured, and the selector is always unsalted sha256 of the
232//! presented material. That is deliberate for machine-generated config
233//! keys (constant-time, index-friendly, and a search over the stored
234//! hashes is not a dictionary attack when the key is 256 bits of
235//! entropy), but it leaves a residual offline-dictionary risk for WEAK
236//! secrets: an attacker who exfiltrates the store can brute-force
237//! low-entropy keys or passwords against the unsalted selectors/hashes
238//! at sha256 speed, offline, and confirm username guesses via the
239//! deterministic Basic selector. Mitigations in place: the DB file is
240//! mode 0600, hashes/selectors are redacted from `Debug`, store-managed
241//! credentials may use argon2id PHC strings (REQUIRED for Basic
242//! passwords), and — #124 — a per-deployment PEPPER turns every NEW
243//! stored hash into `hmac-sha256:<hex>` so a DB leak alone cannot verify
244//! guesses (the search also needs the pepper, held outside the DB).
245//! The pepper is resolved through the `SecretSource` extension seam
246//! ABOVE this domain (the binary resolves it and threads raw bytes
247//! down; `security` must not import `extensions`), is never logged,
248//! never appears in `Debug` output, and never appears in error text.
249//! Transition semantics: legacy `sha256:<hex>` entries keep verifying;
250//! on SUCCESSFUL legacy verification with a pepper configured, the
251//! store row is re-hashed to the peppered format in place (no
252//! credential re-issue needed); `hmac-sha256:` entries with NO pepper
253//! configured fail closed (legacy-only mode) with a clear log line.
254
255use std::collections::{BTreeMap, HashMap};
256use std::future::Future;
257use std::hash::BuildHasher;
258use std::pin::Pin;
259use std::sync::atomic::{AtomicBool, Ordering};
260use std::sync::{Arc, RwLock};
261use std::task::{Context, Poll};
262use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
263
264use async_trait::async_trait;
265use base64::engine::general_purpose::STANDARD as BASE64;
266use base64::Engine as _;
267use hmac::{Hmac, Mac};
268use hyper::header::HeaderMap;
269use hyper::{Method, Uri};
270use hyper_util::client::legacy::Client;
271use hyper_util::rt::{TokioExecutor, TokioTimer};
272use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet};
273use jsonwebtoken::{Algorithm, DecodingKey, Header, Validation};
274use rustls::pki_types::CertificateDer;
275use sha2::Sha256;
276use subtle::ConstantTimeEq;
277use tower_service::Service;
278use zeroize::Zeroizing;
279
280use crate::config::credentials::{
281    credential_selector, hmac_stored_hash, sha256_hex, sha256_stored_hash,
282};
283use crate::config::{
284    ConsumerType, Credential, Gateway, JwtProvider as JwtProviderConfig,
285    DEFAULT_HMAC_CLOCK_SKEW_SECS,
286};
287use crate::observability::Observability;
288use crate::state::store::{CredentialKind, CredentialRecord, StateStore};
289
290const X_API_KEY: hyper::header::HeaderName = hyper::header::HeaderName::from_static("x-api-key");
291
292/// Upper bound on a JWKS response body (1 MiB): a key set is a few KiB;
293/// anything larger is a misbehaving or hostile endpoint.
294const JWKS_BODY_CAP: u64 = 1024 * 1024;
295
296/// Connect timeout for JWKS fetches: key refresh is on the request path
297/// (first token / rotation), so it must fail fast rather than hang a
298/// client request for minutes.
299const JWKS_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
300
301/// The resolved caller identity of one request. `None` (from
302/// [`Authenticator::authenticate`]) is Anonymous — no credential family
303/// applied.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct Identity {
306    pub consumer_name: String,
307    pub credential_kind: CredentialKind,
308    /// The principal kind (DW-113): `User` (the default) or `Agent`.
309    /// From the CONFIG consumer record when config-declared; `User`
310    /// for store-managed consumers (the state store has no type column
311    /// in this edition). Threads into analytics for typed attribution.
312    pub consumer_type: ConsumerType,
313    /// Group memberships of the authenticated consumer (#124): from the
314    /// CONFIG consumer record when the consumer is config-declared, from
315    /// the state store's `consumers.groups` for store-managed consumers.
316    /// Consumed by group-based authorization (`allowed_groups` /
317    /// `denied_groups` at any attachment level).
318    pub groups: Vec<String>,
319    /// JWT only: a subset of the token's claims (string- and
320    /// number-valued top-level claims, plus arrays of strings flattened
321    /// to their space-separated form — the OAuth `scope` convention,
322    /// DW-020 — capped at 32 entries). Never contains the raw token.
323    pub claims: BTreeMap<String, String>,
324    /// HMAC only (DW-036): the signed body digest (the decoded
325    /// `X-Dwara-Body-Sha256` value) the FORWARD path must enforce while
326    /// streaming the request body to the upstream — the MAC was verified
327    /// over this digest at authn time; the dataplane's digesting wrapper
328    /// (`dataplane::hardening`) compares the streamed body's SHA-256
329    /// against it and aborts the request on mismatch (see the module
330    /// docs' canonical-string section). Not secret: it is a public hash
331    /// that already traveled in a header. Every other family sets `None`.
332    pub body_digest: Option<[u8; 32]>,
333    /// AI-16 (#204): agent principal metadata when the consumer is an
334    /// agent. None for user consumers or when no agent metadata is
335    /// configured. Carries display name, description, owner, and
336    /// permission level for first-class agent attribution.
337    pub agent: Option<crate::config::AgentPrincipal>,
338}
339
340/// The VERIFIED client certificate of a connection, as the authenticator
341/// consumes it (#124, DW-035). Built by the TLS frontend from the
342/// certificate rustls already verified against the listener's
343/// `client_ca_file`; the match VALUES (subject CN, fingerprint) double
344/// as credential selectors, so `Debug` redacts them (the
345/// selector-redaction precedent). DW-035 extends it with the issuer CN,
346/// not-after timestamp, and colon-separated fingerprint needed for the
347/// `X-Client-Cert-*` forwarding headers and the gateway-level mTLS
348/// consumer mapping.
349#[derive(Clone)]
350pub struct ClientCertificate {
351    /// Subject CommonName of the verified leaf certificate (first CN in
352    /// the subject RDN sequence; `None` when the subject carries no
353    /// decodable CN — such a certificate can only match a
354    /// by-fingerprint credential).
355    pub(crate) subject_cn: Option<String>,
356    /// Lowercase hex of SHA-256 over the certificate DER (the credential
357    /// SELECTOR the registry indexes — no colons).
358    pub(crate) fingerprint: String,
359    /// Lowercase colon-separated hex of SHA-256 over the certificate DER
360    /// (DW-035): the OPERATOR-facing format the
361    /// `mtls_consumer_mapping.consumers[].fingerprint` config field and
362    /// the `X-Client-Cert-Fingerprint` header use.
363    pub(crate) fingerprint_colon: String,
364    /// Issuer CommonName of the verified leaf certificate (DW-035): the
365    /// first CN in the issuer RDN sequence; `None` when the issuer
366    /// carries no decodable CN. Forwarded as `X-Client-Cert-Issuer-CN`.
367    pub(crate) issuer_cn: Option<String>,
368    /// `notAfter` of the verified leaf certificate as Unix epoch seconds
369    /// (DW-035); `None` when the validity could not be decoded.
370    /// Forwarded as `X-Client-Cert-Not-After` (RFC 3339 timestamp).
371    pub(crate) not_after: Option<i64>,
372}
373
374impl ClientCertificate {
375    /// Extract the match values and forwarding metadata of a verified
376    /// leaf certificate. Never fails: an undecodable subject yields
377    /// `subject_cn: None` and the credential lookup falls back to the
378    /// fingerprint selector alone.
379    pub fn from_cert(cert: &CertificateDer<'_>) -> Self {
380        ClientCertificate {
381            subject_cn: crate::security::tls::subject_cn_of_leaf(cert),
382            fingerprint: sha256_hex(cert.as_ref()),
383            fingerprint_colon: crate::security::tls::fingerprint_colon_hex(cert),
384            issuer_cn: crate::security::tls::issuer_cn_of_leaf(cert),
385            not_after: crate::security::tls::not_after_unix_secs(cert),
386        }
387    }
388
389    /// The colon-separated SHA-256 fingerprint (DW-035): the
390    /// operator-facing format used in `X-Client-Cert-Fingerprint` and
391    /// matched against `mtls_consumer_mapping.consumers[].fingerprint`.
392    pub fn fingerprint_colon(&self) -> &str {
393        &self.fingerprint_colon
394    }
395
396    /// The subject CommonName (for `X-Client-Cert-Subject-CN`, DW-035).
397    pub fn subject_cn(&self) -> Option<&str> {
398        self.subject_cn.as_deref()
399    }
400
401    /// The issuer CommonName (DW-035), for `X-Client-Cert-Issuer-CN`.
402    pub fn issuer_cn(&self) -> Option<&str> {
403        self.issuer_cn.as_deref()
404    }
405
406    /// The `notAfter` as Unix epoch seconds (DW-035), for
407    /// `X-Client-Cert-Not-After`.
408    pub fn not_after(&self) -> Option<i64> {
409        self.not_after
410    }
411}
412
413impl std::fmt::Debug for ClientCertificate {
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        // Match values are credential selectors: redacted (an accidental
416        // Debug print must not leak lookup keys).
417        write!(f, "ClientCertificate([match values redacted])")
418    }
419}
420
421/// Authentication failure. `Invalid` is a caller-side problem (401);
422/// `Unavailable` is a gateway-side problem (500).
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum AuthError {
425    Invalid(&'static str),
426    Unavailable(String),
427}
428
429impl std::fmt::Display for AuthError {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        match self {
432            AuthError::Invalid(m) => write!(f, "invalid credentials: {m}"),
433            AuthError::Unavailable(m) => write!(f, "authentication unavailable: {m}"),
434        }
435    }
436}
437
438impl std::error::Error for AuthError {}
439
440/// The request material an [`Authenticator`] may consume (DW-036):
441/// headers plus the request TARGET — method, path, query — the HMAC
442/// family signs. Borrowed for one authenticate call; the request body
443/// is deliberately NOT here (authn never buffers; the HMAC body digest
444/// is enforced on the forward path, see [`Identity::body_digest`]).
445#[derive(Debug, Clone, Copy)]
446pub struct AuthnRequest<'a> {
447    pub method: &'a Method,
448    pub uri: &'a Uri,
449    pub headers: &'a HeaderMap,
450    /// The VERIFIED client certificate of the connection when the
451    /// accepting TLS listener requested one (#124) — absent on
452    /// cleartext listeners and connections that presented none.
453    pub client_cert: Option<&'a ClientCertificate>,
454}
455
456/// One pluggable authenticator (dyn-compatible seam). `Ok(None)` is
457/// Anonymous; `Err(AuthError::Invalid(..))` means a credential was
458/// PRESENTED and rejected.
459#[async_trait]
460pub trait Authenticator: Send + Sync {
461    async fn authenticate(&self, req: &AuthnRequest<'_>) -> Result<Option<Identity>, AuthError>;
462
463    /// The `WWW-Authenticate` challenge value for 401 responses, built
464    /// from the schemes this authenticator actually interprets. Client
465    /// certificates are deliberately NOT challenged here: TLS-level
466    /// client-auth has no WWW-Authenticate representation.
467    fn challenge(&self) -> String;
468}
469
470// --- hashing ---------------------------------------------------------------
471//
472// The selector/stored-hash FORMATS live in `config::credentials` (part of
473// the credential schema contract shared with the state store); this
474// module re-imports them and owns the VERIFICATION path below.
475
476/// The per-deployment credential pepper as this module consumes it: raw
477/// bytes handed down from ABOVE the security domain (the binary resolves
478/// it through the `SecretSource` extension seam; `security` must not
479/// import `extensions`). SECRET material: never logged, never in
480/// `Debug`, never in error text.
481type Pepper = [u8];
482
483/// Verify a presented secret against a stored hash, in constant time for
484/// the fast paths (`subtle::ConstantTimeEq` over the encoded digests —
485/// comparing hex strings byte-wise is length-equal and timing-uniform).
486/// Supported formats: `hmac-sha256:<hex>` (#124; verified ONLY when a
487/// `pepper` is configured — a missing pepper fails closed for peppered
488/// entries), legacy `sha256:<hex>` (always verified, pepper or not: the
489/// transition keeps old rows valid), and PHC argon2id strings
490/// (`$argon2id$...`, pepper-independent). Unknown formats verify false
491/// (never accept).
492///
493/// Public for testing the stored-hash verification contract.
494pub fn verify_secret(stored_hash: &str, presented: &str, pepper: Option<&Pepper>) -> bool {
495    if let Some(hexdigest) = stored_hash.strip_prefix("hmac-sha256:") {
496        // Peppered entries fail closed without a pepper (legacy-only
497        // mode): the gateway cannot verify what it lacks the key for.
498        let Some(pepper) = pepper else {
499            return false;
500        };
501        if hexdigest.len() != 64 || !hexdigest.bytes().all(|b| b.is_ascii_hexdigit()) {
502            return false;
503        }
504        // Same total length as `stored_hash` (12 + 64 bytes), so the
505        // full-encoding comparison below is length-matched and
506        // timing-uniform like the sha256 path.
507        let computed = hmac_stored_hash(pepper, presented);
508        return computed.as_bytes().ct_eq(stored_hash.as_bytes()).into();
509    }
510    if let Some(hexdigest) = stored_hash.strip_prefix("sha256:") {
511        if hexdigest.len() != 64 || !hexdigest.bytes().all(|b| b.is_ascii_hexdigit()) {
512            return false;
513        }
514        let computed = sha256_hex(presented.as_bytes());
515        // Compare the ASCII encodings: both are fixed 64-byte buffers of
516        // hex digits, so ct_eq is well-defined and length-matched.
517        return computed.as_bytes().ct_eq(hexdigest.as_bytes()).into();
518    }
519    if stored_hash.starts_with("$argon2") {
520        let parsed = argon2::PasswordHash::new(stored_hash);
521        let Ok(parsed) = parsed else { return false };
522        let alg = argon2::Argon2::default();
523        use argon2::PasswordVerifier as _;
524        return alg.verify_password(presented.as_bytes(), &parsed).is_ok();
525    }
526    false
527}
528
529// --- HMAC request signing (DW-036) -----------------------------------------
530
531/// The `X-Dwara-*` header family (DW-036). See the module docs for the
532/// canonical-string contract; these consts are the single source of the
533/// header names so the verifier and the challenge cannot drift.
534pub const X_DWARA_SIGNATURE: hyper::header::HeaderName =
535    hyper::header::HeaderName::from_static("x-dwara-signature");
536pub const X_DWARA_KEY_ID: hyper::header::HeaderName =
537    hyper::header::HeaderName::from_static("x-dwara-key-id");
538pub const X_DWARA_TIMESTAMP: hyper::header::HeaderName =
539    hyper::header::HeaderName::from_static("x-dwara-timestamp");
540pub const X_DWARA_NONCE: hyper::header::HeaderName =
541    hyper::header::HeaderName::from_static("x-dwara-nonce");
542pub const X_DWARA_BODY_SHA256: hyper::header::HeaderName =
543    hyper::header::HeaderName::from_static("x-dwara-body-sha256");
544
545/// Version tag opening the canonical string (see the module docs). Bumped
546/// only for an incompatible grammar change; v1 signers keep verifying.
547const CANONICAL_VERSION_LINE: &str = "dwara-hmac-v1";
548
549/// The `WWW-Authenticate` scheme token the HMAC family offers (a custom
550/// token per RFC 9110's scheme grammar; the family is presented through
551/// headers, but the challenge still needs a name clients can recognize).
552/// `pub(crate)`: the dataplane's mid-stream digest-mismatch 401 (the one
553/// HMAC-originated failure the proxy itself answers) reuses the same
554/// token so both 401 shapes carry an identical challenge.
555pub(crate) const HMAC_CHALLENGE: &str = "Dwara-HMAC-SHA256 realm=\"dwara\"";
556
557/// Bounds on the PRESENTED credential material (401 on violation). The
558/// key id is a public label; the nonce must carry real entropy — 16
559/// bytes is the floor for a random nonce, 256 keeps the nonce-cache key
560/// and the canonical string cheap under hostile headers.
561const MAX_KEY_ID_BYTES: usize = 128;
562const MIN_NONCE_BYTES: usize = 16;
563const MAX_NONCE_BYTES: usize = 256;
564
565/// `true` for the visible-ASCII range a presented header value may carry
566/// without control characters (`\n` exclusion keeps the canonical string
567/// line grammar unambiguous; the space is excluded too — header values
568/// here are opaque tokens, and hyper trims leading/trailing spaces a
569/// signer cannot re-derive byte-for-byte).
570fn is_visible_ascii(bytes: &[u8]) -> bool {
571    !bytes.is_empty() && bytes.iter().all(|b| (0x21..=0x7e).contains(b))
572}
573
574/// Decode exactly 32 bytes from a 64-character hex string (the signature
575/// and body-digest header values). Case-insensitive on input — the spec
576/// says signers emit lowercase, but accepting upper-case hex costs
577/// nothing and rejects nothing the MAC would have accepted.
578fn decode_hex32(value: &str) -> Option<[u8; 32]> {
579    if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
580        return None;
581    }
582    let mut out = [0u8; 32];
583    for (i, chunk) in value.as_bytes().chunks(2).enumerate() {
584        let hi = (chunk[0] as char).to_digit(16)?;
585        let lo = (chunk[1] as char).to_digit(16)?;
586        out[i] = ((hi << 4) | lo) as u8;
587    }
588    Some(out)
589}
590
591/// Build the canonical string (DW-036 v1). The elements are the
592/// PRESENTED header values and the request target EXACTLY as received —
593/// the signer's bytes and the verifier's bytes must agree without any
594/// normalization step (see the module docs for the grammar and the
595/// rationale). Public because the grammar is the interop contract:
596/// tests re-implement it independently to pin the documentation, and
597/// operators may build signing tooling against the same function the
598/// gateway verifies with.
599pub fn canonical_string(
600    key_id: &str,
601    method: &Method,
602    path: &str,
603    query: Option<&str>,
604    timestamp: &str,
605    nonce: &str,
606    body_sha256_hex: &str,
607) -> String {
608    // Methods are already uppercase on the wire (hyper parses them as
609    // sent); uppercasing again is the explicit grammar guarantee for
610    // signers behind a case-mangling chain.
611    let method = method.as_str().to_ascii_uppercase();
612    let mut out = String::with_capacity(
613        CANONICAL_VERSION_LINE.len()
614            + key_id.len()
615            + method.len()
616            + path.len()
617            + query.map_or(0, str::len)
618            + timestamp.len()
619            + nonce.len()
620            + body_sha256_hex.len()
621            + 7,
622    );
623    out.push_str(CANONICAL_VERSION_LINE);
624    for element in [
625        key_id,
626        method.as_str(),
627        path,
628        query.unwrap_or(""),
629        timestamp,
630        nonce,
631        body_sha256_hex,
632    ] {
633        out.push('\n');
634        out.push_str(element);
635    }
636    out
637}
638
639/// Compute HMAC-SHA256 over the canonical string. Shared by the verify
640/// path and the key-miss dummy (timing uniformity; see
641/// [`CompositeAuthenticator::authenticate_hmac`]).
642fn request_mac(secret: &[u8], canonical: &str) -> [u8; 32] {
643    let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC accepts any key length");
644    mac.update(canonical.as_bytes());
645    mac.finalize().into_bytes().into()
646}
647
648/// Number of independent shard locks in the nonce cache. Fixed (not
649/// CPU-count-derived) so the worst-case entry bound
650/// (`NONCE_CACHE_SHARDS * MAX_NONCE_CACHE_ENTRIES_PER_SHARD`) is the
651/// same number on every machine — the GCRA store's rationale.
652pub const NONCE_CACHE_SHARDS: usize = 16;
653
654/// One HMAC signing key as the authenticator holds it (DW-036). The
655/// secret is the RESOLVED config value (inline or `${...}` reference,
656/// resolved at build time): raw key bytes, never a hash, never logged,
657/// zeroized when the last holder drops. Manual `Debug` redacts it.
658pub struct HmacCredential {
659    pub consumer_name: String,
660    secret: Arc<Zeroizing<Vec<u8>>>,
661}
662
663impl std::fmt::Debug for HmacCredential {
664    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665        f.debug_struct("HmacCredential")
666            .field("consumer_name", &self.consumer_name)
667            .field("secret", &"[redacted]")
668            .finish()
669    }
670}
671
672/// Per-instance replay-nonce store (DW-036): sharded keyed TTL map, the
673/// GCRA shard store's shape (`GcraShardStore`). [`Self::check_and_insert`]
674/// is the single critical section — one shard lock covers the
675/// expired-read and the insert, so two concurrent presentations of one
676/// nonce linearize (exactly one wins). Entries expire after the
677/// caller-supplied TTL (twice the clock-skew window, set per insert so a
678/// skew change on reload applies to new nonces without rebuilding the
679/// cache); a shard at
680/// [`crate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARD`] entries
681/// first drops expired entries, then evicts soonest-expiry-first (the
682/// documented fail-open-under-flood trade; see the module docs).
683pub struct NonceCache {
684    shards: Vec<std::sync::Mutex<HashMap<String, Instant>>>,
685    hasher: std::collections::hash_map::RandomState,
686    /// Per-shard entry cap (see [`Self::with_shard_capacity`]).
687    cap: usize,
688}
689
690impl NonceCache {
691    /// The production cache: [`NONCE_CACHE_SHARDS`] shards, each capped
692    /// at [`crate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARD`].
693    pub fn new() -> Self {
694        Self::with_shard_capacity(crate::config::limits::MAX_NONCE_CACHE_ENTRIES_PER_SHARD)
695    }
696
697    /// Test constructor with a small per-shard cap (exercises the
698    /// eviction cascade without inserting thousands of keys).
699    pub fn with_shard_capacity(cap: usize) -> Self {
700        NonceCache {
701            shards: (0..NONCE_CACHE_SHARDS)
702                .map(|_| std::sync::Mutex::new(HashMap::new()))
703                .collect(),
704            hasher: std::collections::hash_map::RandomState::new(),
705            cap,
706        }
707    }
708
709    fn shard_for(&self, key: &str) -> &std::sync::Mutex<HashMap<String, Instant>> {
710        &self.shards[(self.hasher.hash_one(key) as usize) & (NONCE_CACHE_SHARDS - 1)]
711    }
712
713    /// Remember `key` for `ttl` and report whether it was FRESH (not
714    /// remembered, or remembered-but-expired): `true` = this caller is
715    /// the first presentation inside the window; `false` = replay.
716    pub fn check_and_insert(&self, key: &str, ttl: Duration) -> bool {
717        let now = Instant::now();
718        let expires = now + ttl;
719        let shard = self.shard_for(key);
720        let mut guard = shard.lock().expect("nonce cache shard poisoned");
721        match guard.get(key) {
722            Some(&seen_expiry) if seen_expiry > now => false,
723            _ => {
724                // Expired-or-absent: same path. Sweep only when crowded
725                // (the inline cascade; see the module docs) so the
726                // common case is one hash lookup plus one insert.
727                if guard.len() >= self.cap {
728                    guard.retain(|_, expiry| *expiry > now);
729                    while guard.len() >= self.cap {
730                        // Soonest-expiry-first: the entries closest to
731                        // leaving the window cost the least protection
732                        // to drop.
733                        let victim = guard
734                            .iter()
735                            .min_by_key(|(_, expiry)| **expiry)
736                            .map(|(k, _)| k.clone());
737                        match victim {
738                            Some(k) => {
739                                guard.remove(&k);
740                            }
741                            None => break,
742                        }
743                    }
744                }
745                guard.insert(key.to_string(), expires);
746                true
747            }
748        }
749    }
750}
751
752impl Default for NonceCache {
753    fn default() -> Self {
754        Self::new()
755    }
756}
757
758impl std::fmt::Debug for NonceCache {
759    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760        f.debug_struct("NonceCache")
761            .field("shards", &NONCE_CACHE_SHARDS)
762            .field("cap", &self.cap)
763            .finish_non_exhaustive()
764    }
765}
766
767// --- credential registry ---------------------------------------------------
768
769/// One verifiable credential as the authenticator sees it.
770///
771/// Manual `Debug`: `hash` and `selector` are credential material (the
772/// selector is the lookup key — for `mtls` rows the match value itself —
773/// and the hash embeds a binding marker or a peppered digest), so both
774/// are redacted, matching the store's `CredentialRecord` precedent.
775#[derive(Clone)]
776pub struct KnownCredential {
777    pub consumer_name: String,
778    pub kind: CredentialKind,
779    pub hash: String,
780    /// Scheduled retirement instant (DW-046, epoch seconds): the
781    /// credential stops verifying once this passes. The STORE lookup
782    /// filters retired rows in SQL; this field exists for the CACHED
783    /// list, which was filled before the boundary and must go stale
784    /// exactly on time — the registry lookup below re-checks it.
785    pub retire_at: Option<i64>,
786    /// Store row id when the credential came from the state store (used
787    /// by the #124 pepper transition to re-hash a legacy-verified row in
788    /// place); `None` for config-seeded credentials.
789    pub id: Option<i64>,
790    /// The selector this credential was found under (the credential's
791    /// lookup key — its own match value for `mtls` bindings).
792    pub selector: String,
793}
794
795impl std::fmt::Debug for KnownCredential {
796    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
797        f.debug_struct("KnownCredential")
798            .field("consumer_name", &self.consumer_name)
799            .field("kind", &self.kind)
800            .field("hash", &"[redacted]")
801            .field("retire_at", &self.retire_at)
802            .field("id", &self.id)
803            .field("selector", &"[redacted]")
804            .finish()
805    }
806}
807
808impl From<&CredentialRecord> for KnownCredential {
809    fn from(r: &CredentialRecord) -> Self {
810        KnownCredential {
811            consumer_name: r.consumer_name.clone(),
812            kind: r.kind,
813            hash: r.hash.clone(),
814            retire_at: r.retire_at,
815            id: Some(r.id),
816            selector: r.selector.clone(),
817        }
818    }
819}
820
821/// Where credential records come from: the state store (hot-cached; the
822/// `DWARA_STATE_DB` deployment) or config consumers hashed in-memory at
823/// startup. Both paths unify behind one lookup API (a private
824/// selector-keyed search shared by the API-key, Basic, and mTLS paths).
825pub enum CredentialRegistry {
826    Store(Arc<StateStore>),
827    Config(HashMap<String, Arc<Vec<KnownCredential>>>),
828}
829
830impl CredentialRegistry {
831    /// Build the config-only registry: every consumer's API-key credential
832    /// is hashed at startup (the config value is then dropped; the
833    /// registry holds only selectors and hashes) — with the PEPPERED
834    /// format when a pepper is configured (#124), the legacy sha256
835    /// format otherwise. `${...}` secret references (DW-045) are
836    /// resolved HERE, at build time, and the RESOLVED bytes hashed; the
837    /// plaintext never outlives the call. `mtls` credentials are indexed
838    /// by their match value (subject CN or fingerprint). JWT credentials
839    /// are bindings consumed via the composite's issuer index, not this
840    /// map.
841    pub fn from_config(gateway: &Gateway, pepper: Option<&Pepper>) -> Self {
842        let mut map: HashMap<String, Arc<Vec<KnownCredential>>> = HashMap::new();
843        for consumer in &gateway.consumers {
844            for credential in &consumer.credentials {
845                let (kind, selector, hash) = match credential {
846                    Credential::ApiKey { key } => {
847                        if key.is_empty() {
848                            continue;
849                        }
850                        // DW-045: the config value may be a ${...} reference
851                        // (env/file); resolve it and hash the RESOLVED bytes.
852                        // Validation already rejected unresolvable references
853                        // for this generation, so an error here is the
854                        // validate-vs-build microsecond race — fail CLOSED
855                        // (skip the credential: that key stops authenticating)
856                        // and say so loudly, never echoing the value. The
857                        // next successful publish re-resolves.
858                        let key = match crate::config::credentials::resolve_configured_secret(key) {
859                            Ok(resolved) => resolved,
860                            Err(err) => {
861                                tracing::error!(
862                                    code = "config_api_key_unresolvable",
863                                    consumer = %consumer.name,
864                                    "skipping api key credential (secret reference \
865                                     unresolvable at authenticator build): {err}"
866                                );
867                                continue;
868                            }
869                        };
870                        let hash = match pepper {
871                            Some(pepper) => hmac_stored_hash(pepper, &key),
872                            None => sha256_stored_hash(&key),
873                        };
874                        (CredentialKind::ApiKey, credential_selector(&key), hash)
875                    }
876                    Credential::Mtls {
877                        subject,
878                        fingerprint,
879                    } => {
880                        let match_value = subject
881                            .clone()
882                            .or_else(|| fingerprint.clone())
883                            .unwrap_or_default();
884                        if match_value.is_empty() {
885                            continue;
886                        }
887                        (
888                            CredentialKind::Mtls,
889                            match_value.clone(),
890                            format!("config:mtls:{match_value}"),
891                        )
892                    }
893                    // JWT credentials resolve through jwt_consumer_index.
894                    Credential::Jwt { .. } => continue,
895                    // HMAC credentials (DW-036) live in the composite's
896                    // hmac_keys map (raw key material, config-served
897                    // only), not this hash-keyed registry.
898                    Credential::Hmac { .. } => continue,
899                };
900                let entry = Arc::make_mut(map.entry(selector.clone()).or_default());
901                entry.push(KnownCredential {
902                    consumer_name: consumer.name.clone(),
903                    kind,
904                    hash,
905                    retire_at: None,
906                    id: None,
907                    selector,
908                });
909            }
910        }
911        CredentialRegistry::Config(map)
912    }
913
914    /// Look up the active credentials for a selector (hash of the
915    /// presented material — never plaintext — or an mTLS match value).
916    async fn lookup(&self, selector: &str) -> Result<Vec<KnownCredential>, AuthError> {
917        // DW-046: the dual-validity window's far edge is enforced HERE
918        // (in addition to the store's SQL filter) so a CACHED list
919        // filled before a scheduled retirement stops serving the row
920        // exactly on time, with no background sweeper.
921        let now = std::time::SystemTime::now()
922            .duration_since(std::time::UNIX_EPOCH)
923            .map(|d| d.as_secs() as i64)
924            .unwrap_or(i64::MAX);
925        let retired = |c: &KnownCredential| c.retire_at.is_some_and(|t| t <= now);
926        match self {
927            CredentialRegistry::Store(store) => {
928                let entry = store
929                    .lookup_credential(selector)
930                    .map_err(|e| AuthError::Unavailable(e.to_string()))?;
931                let entry = entry.unwrap_or_default();
932                Ok(entry
933                    .iter()
934                    .map(|r| KnownCredential::from(r.as_ref()))
935                    .filter(|c| !retired(c))
936                    .collect())
937            }
938            CredentialRegistry::Config(map) => Ok(map
939                .get(selector)
940                .map(|v| v.as_ref().clone())
941                .unwrap_or_default()
942                .into_iter()
943                .filter(|c| !retired(c))
944                .collect()),
945        }
946    }
947}
948
949// --- JWKS fetching ---------------------------------------------------------
950
951/// A connection that is either plaintext TCP or TLS: the single response
952/// type of [`JwksConnector`] for `http://` and `https://` JWKS URLs.
953/// `WithHyperIo` adapts a tokio stream to BOTH hyper's runtime IO traits
954/// and tokio's, which is exactly what the tokio-rustls 0.26 connect bound
955/// and the hyper legacy client's pool each ask for.
956enum MaybeTls {
957    Plain(hyper_util::rt::tokio::WithHyperIo<tokio::net::TcpStream>),
958    Tls(
959        Box<
960            tokio_rustls::client::TlsStream<
961                hyper_util::rt::tokio::WithHyperIo<tokio::net::TcpStream>,
962            >,
963        >,
964    ),
965}
966
967impl tokio::io::AsyncRead for MaybeTls {
968    fn poll_read(
969        mut self: Pin<&mut Self>,
970        cx: &mut Context<'_>,
971        buf: &mut tokio::io::ReadBuf<'_>,
972    ) -> Poll<std::io::Result<()>> {
973        match &mut *self {
974            MaybeTls::Plain(s) => Pin::new(s).poll_read(cx, buf),
975            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
976        }
977    }
978}
979
980impl tokio::io::AsyncWrite for MaybeTls {
981    fn poll_write(
982        mut self: Pin<&mut Self>,
983        cx: &mut Context<'_>,
984        buf: &[u8],
985    ) -> Poll<std::io::Result<usize>> {
986        match &mut *self {
987            MaybeTls::Plain(s) => Pin::new(s).poll_write(cx, buf),
988            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
989        }
990    }
991
992    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
993        match &mut *self {
994            MaybeTls::Plain(s) => Pin::new(s).poll_flush(cx),
995            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
996        }
997    }
998
999    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1000        match &mut *self {
1001            MaybeTls::Plain(s) => Pin::new(s).poll_shutdown(cx),
1002            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
1003        }
1004    }
1005}
1006
1007impl hyper_util::client::legacy::connect::Connection for MaybeTls {
1008    fn connected(&self) -> hyper_util::client::legacy::connect::Connected {
1009        hyper_util::client::legacy::connect::Connected::new()
1010    }
1011}
1012
1013/// Plain-or-TLS connector for one-shot JWKS GETs (hyper-util legacy
1014/// client plumbing; reuses the workspace rustls stack, no new HTTP
1015/// dependency). Trust defaults to the webpki public roots; a provider's
1016/// `trusted_ca_file` (#121) replaces them so an https JWKS endpoint
1017/// behind a private CA is fetchable — the same trust model the upstream
1018/// connector gives `trusted_ca_file` upstreams.
1019#[derive(Clone)]
1020struct JwksConnector {
1021    http: hyper_util::client::legacy::connect::HttpConnector,
1022    tls: tokio_rustls::TlsConnector,
1023}
1024
1025impl JwksConnector {
1026    /// `trusted_ca` is the provider's `trusted_ca_file` path when set.
1027    /// Fails when the bundle cannot be loaded/parsed, so the provider is
1028    /// disabled at build time (see `CompositeAuthenticator::build`)
1029    /// instead of failing every fetch at request time.
1030    fn new(trusted_ca: Option<&str>) -> Result<Self, AuthError> {
1031        let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
1032        http.enforce_http(false); // scheme routing happens here
1033        let roots = match trusted_ca {
1034            Some(path) => crate::security::tls::root_store_from_pem_file(path).map_err(|e| {
1035                AuthError::Unavailable(format!("trusted_ca_file '{path}' could not be loaded: {e}"))
1036            })?,
1037            None => crate::security::tls::webpki_root_store(),
1038        };
1039        let cfg = crate::security::tls::https_h1_client_config(roots);
1040        Ok(JwksConnector {
1041            http,
1042            tls: tokio_rustls::TlsConnector::from(Arc::new(cfg)),
1043        })
1044    }
1045}
1046
1047impl Service<Uri> for JwksConnector {
1048    type Response = hyper_util::rt::TokioIo<MaybeTls>;
1049    type Error = std::io::Error;
1050    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
1051
1052    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1053        self.http.poll_ready(cx).map_err(std::io::Error::other)
1054    }
1055
1056    fn call(&mut self, uri: Uri) -> Self::Future {
1057        let mut http = self.http.clone();
1058        let tls = self.tls.clone();
1059        Box::pin(async move {
1060            let host = uri
1061                .host()
1062                .ok_or_else(|| std::io::Error::other("jwks url has no host"))?
1063                .to_string();
1064            let stream = tokio::time::timeout(JWKS_CONNECT_TIMEOUT, http.call(uri.clone()))
1065                .await
1066                .map_err(|_| {
1067                    std::io::Error::new(std::io::ErrorKind::TimedOut, "jwks connect timed out")
1068                })?
1069                .map_err(std::io::Error::other)?
1070                // The HttpConnector hands back a TokioIo<TcpStream>; unwrap to
1071                // the raw stream and re-adapt with WithHyperIo (which satisfies
1072                // BOTH tokio's and hyper's IO traits) so the tls branch and the
1073                // plaintext branch share one adaptable stream type.
1074                .into_inner();
1075            match uri.scheme_str() {
1076                Some("https") => {
1077                    // rustls requires a DNS ServerName; IPs and anything
1078                    // else fail the handshake (documented: JWKS endpoints
1079                    // are named services).
1080                    let name = tokio_rustls::rustls::pki_types::ServerName::try_from(host.clone())
1081                        .map_err(|e| {
1082                            std::io::Error::other(format!("jwks host is not a valid tls name: {e}"))
1083                        })?;
1084                    let tls_stream = tokio::time::timeout(
1085                        JWKS_CONNECT_TIMEOUT,
1086                        tls.connect(name, hyper_util::rt::tokio::WithHyperIo::new(stream)),
1087                    )
1088                    .await
1089                    .map_err(|_| {
1090                        std::io::Error::new(
1091                            std::io::ErrorKind::TimedOut,
1092                            "jwks tls handshake timed out",
1093                        )
1094                    })??;
1095                    Ok(hyper_util::rt::TokioIo::new(MaybeTls::Tls(Box::new(
1096                        tls_stream,
1097                    ))))
1098                }
1099                // http:// JWKS: plaintext.
1100                _ => Ok(hyper_util::rt::TokioIo::new(MaybeTls::Plain(
1101                    hyper_util::rt::tokio::WithHyperIo::new(stream),
1102                ))),
1103            }
1104        })
1105    }
1106}
1107
1108/// Shared JWKS cache for one provider URL: the cached key set plus the
1109/// refresh bookkeeping. Entries live on the dataplane keyed by URL, so a
1110/// config reload reuses the cache (rotation state survives reloads).
1111pub struct JwksCacheEntry {
1112    keys: RwLock<Arc<JwkSet>>,
1113    /// The SUPERSEDED key set and when it was retired (DW-046): when a
1114    /// successful fetch delivers a set that no longer contains a key
1115    /// the previous set had, the old set is kept here for
1116    /// `retired_key_grace_secs` — the JWKS half of the dual-validity
1117    /// window. Tokens signed by an issuer key dropped from the fresh
1118    /// set (the rotation race: keys are removed from JWKS while
1119    /// previously-issued tokens still carry the old kid) keep
1120    /// verifying during the grace; after it they fail closed. Only the
1121    /// immediately-previous set is retained (rotation is one step at a
1122    /// time); a set identical in kids does not retire anything.
1123    retired: RwLock<Option<(Arc<JwkSet>, Instant)>>,
1124    last_refresh: RwLock<Instant>,
1125    /// When the last refresh-triggered (unknown-kid) fetch started.
1126    /// Unknown-kid fetches are refused for a minimum spacing afterwards
1127    /// (see [`JwtVerifier::key_for`]) so forged random-kid tokens cannot
1128    /// drive a fetch-per-request storm.
1129    last_forced_refresh: RwLock<Option<Instant>>,
1130    /// When the last refresh FAILED. While this is within
1131    /// `min(5s, refresh_secs)`, stale-path refresh attempts skip the
1132    /// fetch entirely and fall through to the cached keys (see
1133    /// [`JwtVerifier::key_for`]): with a down/slow endpoint, every Bearer
1134    /// request would otherwise pay a fetch attempt serialized on
1135    /// `refresh_lock` (5s connect timeout each), chaining concurrent
1136    /// traffic through queued timeouts. A successful refresh clears it.
1137    last_failed_refresh: RwLock<Option<Instant>>,
1138    /// Serializes refreshes; concurrent unknown-kid misses coalesce into
1139    /// one fetch.
1140    refresh_lock: tokio::sync::Mutex<()>,
1141}
1142
1143impl JwksCacheEntry {
1144    fn new() -> Self {
1145        JwksCacheEntry {
1146            keys: RwLock::new(Arc::new(JwkSet { keys: Vec::new() })),
1147            retired: RwLock::new(None),
1148            last_refresh: RwLock::new(
1149                Instant::now()
1150                    .checked_sub(Duration::from_secs(86400))
1151                    .unwrap_or(Instant::now()),
1152            ),
1153            last_forced_refresh: RwLock::new(None),
1154            last_failed_refresh: RwLock::new(None),
1155            refresh_lock: tokio::sync::Mutex::new(()),
1156        }
1157    }
1158}
1159
1160/// One configured JWT provider at runtime: config + HTTP client + cache.
1161pub struct JwtVerifier {
1162    cfg: JwtProviderConfig,
1163    algorithms: Vec<Algorithm>,
1164    http: Client<JwksConnector, http_body_util::Full<bytes::Bytes>>,
1165    cache: Arc<JwksCacheEntry>,
1166    /// jwks_refresh_total{provider} child (DW-021), incremented on every
1167    /// JWKS fetch attempt (stale-path and rotation-triggered alike).
1168    jwks_refresh: Option<prometheus::IntCounter>,
1169}
1170
1171impl JwtVerifier {
1172    fn build(
1173        cfg: JwtProviderConfig,
1174        cache: Arc<JwksCacheEntry>,
1175        obs: Option<&Observability>,
1176    ) -> Result<Self, AuthError> {
1177        let algorithms = parse_algorithms(&cfg.algorithms).ok_or_else(|| {
1178            AuthError::Unavailable(format!(
1179                "jwt provider '{}' lists an unsupported or disallowed algorithm",
1180                cfg.name
1181            ))
1182        })?;
1183        let jwks_refresh = obs.map(|o| o.jwks_refresh_counter(cfg.name.as_str()));
1184        let mut builder = Client::builder(TokioExecutor::new());
1185        builder.pool_timer(TokioTimer::new());
1186        // #121: the provider's trusted_ca_file (private-CA JWKS
1187        // endpoints) feeds the fetcher's TLS trust; a broken bundle
1188        // disables this provider (see CompositeAuthenticator::build)
1189        // rather than breaking every Bearer request with a fetch
1190        // failure. Built before the struct so `cfg` is not yet moved.
1191        let connector = JwksConnector::new(cfg.trusted_ca_file.as_deref())?;
1192        Ok(JwtVerifier {
1193            cfg,
1194            algorithms,
1195            http: builder.build(connector),
1196            cache,
1197            jwks_refresh,
1198        })
1199    }
1200
1201    async fn fetch(&self) -> Result<Arc<JwkSet>, AuthError> {
1202        // Every fetch (stale-path or rotation-triggered) counts in the
1203        // jwks_refresh_total metric (DW-021) — attempts, not successes, so
1204        // a flapping endpoint is visible.
1205        if let Some(counter) = &self.jwks_refresh {
1206            counter.inc();
1207        }
1208        // Record the outcome for the stale-path failed-refresh backoff: a
1209        // failed fetch arms it, a successful one clears it (see
1210        // [`JwksCacheEntry::last_failed_refresh`]).
1211        let result = self.fetch_once().await;
1212        match &result {
1213            Ok(_) => {
1214                *self
1215                    .cache
1216                    .last_failed_refresh
1217                    .write()
1218                    .expect("jwks cache poisoned") = None;
1219            }
1220            Err(_) => {
1221                *self
1222                    .cache
1223                    .last_failed_refresh
1224                    .write()
1225                    .expect("jwks cache poisoned") = Some(Instant::now());
1226            }
1227        }
1228        result
1229    }
1230
1231    async fn fetch_once(&self) -> Result<Arc<JwkSet>, AuthError> {
1232        let uri: Uri = self
1233            .cfg
1234            .jwks_url
1235            .parse()
1236            .map_err(|e| AuthError::Unavailable(format!("jwks url is invalid: {e}")))?;
1237        let req = hyper::Request::builder()
1238            .uri(uri)
1239            .method(hyper::Method::GET)
1240            .body(http_body_util::Full::new(bytes::Bytes::new()))
1241            .map_err(|e| AuthError::Unavailable(format!("jwks request build failed: {e}")))?;
1242        let resp = self
1243            .http
1244            .request(req)
1245            .await
1246            .map_err(|e| AuthError::Unavailable(format!("jwks fetch failed: {e:#}")))?;
1247        if !resp.status().is_success() {
1248            return Err(AuthError::Unavailable(format!(
1249                "jwks endpoint answered {}",
1250                resp.status()
1251            )));
1252        }
1253        // `Limited` enforces the body cap DURING streaming, so an
1254        // oversized (hostile) key set fails mid-read instead of being
1255        // buffered whole first.
1256        let body = http_body_util::BodyExt::collect(http_body_util::Limited::new(
1257            resp.into_body(),
1258            JWKS_BODY_CAP as usize,
1259        ))
1260        .await
1261        .map_err(|e| AuthError::Unavailable(format!("jwks body read failed: {e}")))?;
1262        let body = body.to_bytes();
1263        let set: JwkSet = serde_json::from_slice(&body)
1264            .map_err(|e| AuthError::Unavailable(format!("jwks body is not a jwk set: {e}")))?;
1265        let set = Arc::new(set);
1266        // DW-046: swap the cached set under ONE write guard, retiring
1267        // the superseded set only when its kid set differs (the issuer
1268        // rotated); identical kids keep the old retirement stamp (an
1269        // unrelated re-fetch must not extend any grace). The retired
1270        // stamp happens AFTER the keys guard drops — std RwLock is not
1271        // reentrant, and a second keys.write on this thread would
1272        // self-deadlock.
1273        let superseded = {
1274            let mut keys = self.cache.keys.write().expect("jwks cache poisoned");
1275            if jwk_sets_same_kids(&keys, &set) {
1276                *keys = Arc::clone(&set);
1277                None
1278            } else {
1279                let old = Arc::clone(&keys);
1280                *keys = Arc::clone(&set);
1281                Some(old)
1282            }
1283        };
1284        if let Some(old) = superseded {
1285            *self.cache.retired.write().expect("jwks cache poisoned") = Some((old, Instant::now()));
1286        }
1287        *self
1288            .cache
1289            .last_refresh
1290            .write()
1291            .expect("jwks cache poisoned") = Instant::now();
1292        Ok(set)
1293    }
1294
1295    /// Find the JWK for `kid`/`alg`, refreshing when the cache is stale or
1296    /// the kid is unknown (rotation). Refreshes are serialized; a caller
1297    /// that lost the refresh race re-checks the freshly cached set.
1298    ///
1299    /// ## Stale caches refresh BEFORE use
1300    ///
1301    /// Once the cache age exceeds `refresh_secs`, the next key lookup
1302    /// refreshes under the lock BEFORE serving a cached key: a stale set
1303    /// must not keep verifying a retired issuer key indefinitely. If that
1304    /// refresh FAILS, the cached keys keep serving (documented degradation
1305    /// — an endpoint disturbance is not an immediate outage), except when
1306    /// the cache is empty (nothing to degrade to: `Unavailable`).
1307    ///
1308    /// ## Failed stale refreshes back off
1309    ///
1310    /// A FAILED stale-path refresh arms a backoff: for the next
1311    /// `min(5s, refresh_secs)`, stale-path lookups skip the fetch entirely
1312    /// and answer from the cached keys immediately. Without this, a
1313    /// down/slow JWKS endpoint would make EVERY Bearer request pay a fetch
1314    /// attempt serialized on the refresh lock (a 5s connect timeout each),
1315    /// chaining concurrent traffic through queued timeouts. A successful
1316    /// refresh clears the backoff; an EMPTY cache never backs off (there
1317    /// is nothing to degrade to, so the `Unavailable` error surfaces).
1318    ///
1319    /// ## Unknown-kid refreshes are throttled
1320    ///
1321    /// A fresh cache with an unknown `kid` triggers one deliberate
1322    /// rotation fetch — and then NO further refresh-triggered fetches for
1323    /// `min(5s, refresh_secs)`: subsequent unknown-kid requests answer 401
1324    /// from the (freshly fetched) cached set. Forged random-kid tokens
1325    /// therefore buy at most one JWKS fetch per window and cannot drive a
1326    /// fetch storm through the serialized refresh lock (which would stall
1327    /// all Bearer traffic). One genuine rotation flip still pays at most
1328    /// one fetch — the flip's own request performs it. The AGE-based
1329    /// refresh above is deliberately NOT throttled: it fires at most once
1330    /// per `refresh_secs` by construction.
1331    async fn key_for(&self, kid: Option<&str>, alg: Algorithm) -> Result<Jwk, AuthError> {
1332        let stale_after = Duration::from_secs(self.cfg.refresh_secs.max(1));
1333        // SEC-12 (#215): the failed-refresh backoff window is now
1334        // configurable via `stale_on_error_secs` (default
1335        // `min(refresh_secs, 300)`). The unknown-kid throttle still
1336        // uses the tighter `min(5s, stale_after)` so forged random-kid
1337        // tokens cannot drive a fetch storm.
1338        let failure_backoff = Duration::from_secs(self.cfg.stale_on_error_secs().max(1));
1339        let forced_min_interval = JWKS_FORCED_REFRESH_MIN_INTERVAL.min(stale_after);
1340        let fresh = {
1341            let keys = self.cache.keys.read().expect("jwks cache poisoned");
1342            let last = *self.cache.last_refresh.read().expect("jwks cache poisoned");
1343            if last.elapsed() < stale_after {
1344                if let Some(jwk) = find_jwk(&keys, kid, alg) {
1345                    return Ok(jwk.clone());
1346                }
1347            }
1348            last.elapsed() < stale_after
1349        };
1350        let _guard = self.cache.refresh_lock.lock().await;
1351        if !fresh {
1352            // Cache age exceeded refresh_secs: refresh BEFORE serving the
1353            // cached key (a stale set must not keep verifying retired
1354            // issuer keys forever). Failure degrades to the cached set
1355            // unless there is nothing cached to serve.
1356            let still_stale = {
1357                let last = *self.cache.last_refresh.read().expect("jwks cache poisoned");
1358                last.elapsed() >= stale_after
1359            };
1360            if still_stale {
1361                let cached_nonempty = !self
1362                    .cache
1363                    .keys
1364                    .read()
1365                    .expect("jwks cache poisoned")
1366                    .keys
1367                    .is_empty();
1368                // Failed-refresh backoff: when the previous refresh failed
1369                // recently and there ARE cached keys to degrade to, skip
1370                // the fetch — answer from the cache immediately instead of
1371                // chaining every Bearer request through a doomed fetch
1372                // attempt (5s connect timeout each) on the refresh lock.
1373                let in_failure_backoff = cached_nonempty && {
1374                    let failed = *self
1375                        .cache
1376                        .last_failed_refresh
1377                        .read()
1378                        .expect("jwks cache poisoned");
1379                    failed.is_some_and(|t| t.elapsed() < failure_backoff)
1380                };
1381                if !in_failure_backoff {
1382                    match self.fetch().await {
1383                        Ok(set) => {
1384                            if let Some(jwk) = find_jwk(&set, kid, alg) {
1385                                return Ok(jwk.clone());
1386                            }
1387                            // DW-046: missing from the FRESH set — the
1388                            // retired set's grace decides.
1389                            return self
1390                                .find_retired(kid, alg)
1391                                .ok_or(AuthError::Invalid("token key id is unknown"));
1392                        }
1393                        Err(e) => {
1394                            if !cached_nonempty {
1395                                return Err(e);
1396                            }
1397                            // Degraded mode: fall through to the cached set.
1398                        }
1399                    }
1400                }
1401            }
1402            let keys = self.cache.keys.read().expect("jwks cache poisoned");
1403            if let Some(jwk) = find_jwk(&keys, kid, alg) {
1404                return Ok(jwk.clone());
1405            }
1406            if let Some(jwk) = self.find_retired(kid, alg) {
1407                return Ok(jwk);
1408            }
1409            return Err(AuthError::Invalid("token key id is unknown"));
1410        }
1411        // Fresh cache, unknown kid: the rotation path. Re-check the cached
1412        // set under the lock first — a concurrent request's fetch may have
1413        // already delivered our kid — and consult the RETIRED set (DW-046):
1414        // a kid the issuer already dropped needs no fetch at all, so it
1415        // must not spend (or be refused by) the forced-refresh throttle.
1416        {
1417            let keys = self.cache.keys.read().expect("jwks cache poisoned");
1418            if let Some(jwk) = find_jwk(&keys, kid, alg) {
1419                return Ok(jwk.clone());
1420            }
1421        }
1422        if let Some(jwk) = self.find_retired(kid, alg) {
1423            return Ok(jwk);
1424        }
1425        // Claim the throttle
1426        // window BEFORE fetching so a failed fetch also spends it (the
1427        // endpoint saw the traffic either way).
1428        let eligible = {
1429            let mut last = self
1430                .cache
1431                .last_forced_refresh
1432                .write()
1433                .expect("jwks cache poisoned");
1434            match *last {
1435                Some(t) if t.elapsed() < forced_min_interval => false,
1436                _ => {
1437                    *last = Some(Instant::now());
1438                    true
1439                }
1440            }
1441        };
1442        if !eligible {
1443            // Refused for the throttle window: answer 401 from the cached
1444            // set without touching the JWKS endpoint.
1445            return Err(AuthError::Invalid("token key id is unknown"));
1446        }
1447        // SEC-12 (#215): on fetch failure, degrade gracefully — the
1448        // kid is unknown in the cached set, but a transient JWKS
1449        // outage should surface as 401 (invalid token) not 500
1450        // (unavailable) when there ARE cached keys. The retired set
1451        // (DW-046) is the last resort.
1452        match self.fetch().await {
1453            Ok(set) => {
1454                if let Some(jwk) = find_jwk(&set, kid, alg) {
1455                    return Ok(jwk.clone());
1456                }
1457                // DW-046: the forced rotation fetch delivered a set
1458                // without the kid; the retired set's grace decides.
1459                self.find_retired(kid, alg)
1460                    .ok_or(AuthError::Invalid("token key id is unknown"))
1461            }
1462            Err(e) => {
1463                // Fetch failed: try the retired set (DW-046) before
1464                // surfacing the error. When the cache is empty there
1465                // is nothing to degrade to.
1466                if let Some(jwk) = self.find_retired(kid, alg) {
1467                    return Ok(jwk);
1468                }
1469                let cached_nonempty = !self
1470                    .cache
1471                    .keys
1472                    .read()
1473                    .expect("jwks cache poisoned")
1474                    .keys
1475                    .is_empty();
1476                if cached_nonempty {
1477                    Err(AuthError::Invalid("token key id is unknown"))
1478                } else {
1479                    Err(e)
1480                }
1481            }
1482        }
1483    }
1484
1485    /// The retired-set fallback (DW-046): the immediately-previous key
1486    /// set, honored while younger than the provider's
1487    /// `retired_key_grace_secs` (0 disables the fallback entirely).
1488    fn find_retired(&self, kid: Option<&str>, alg: Algorithm) -> Option<Jwk> {
1489        let grace = Duration::from_secs(self.cfg.retired_key_grace_secs());
1490        if grace.is_zero() {
1491            return None;
1492        }
1493        let retired = self
1494            .cache
1495            .retired
1496            .read()
1497            .expect("jwks cache poisoned")
1498            .clone();
1499        let (set, at) = retired?;
1500        if at.elapsed() >= grace {
1501            return None;
1502        }
1503        find_jwk(&set, kid, alg).cloned()
1504    }
1505}
1506
1507/// Whether two JWK sets carry the SAME key ids (the retire-on-change
1508/// discriminator; algorithms may re-order freely).
1509fn jwk_sets_same_kids(a: &JwkSet, b: &JwkSet) -> bool {
1510    fn kids(s: &JwkSet) -> Vec<String> {
1511        let mut v: Vec<String> = s
1512            .keys
1513            .iter()
1514            .filter_map(|k| k.common.key_id.clone())
1515            .collect();
1516        v.sort_unstable();
1517        v
1518    }
1519    kids(a) == kids(b)
1520}
1521
1522fn find_jwk<'a>(set: &'a JwkSet, kid: Option<&str>, alg: Algorithm) -> Option<&'a Jwk> {
1523    let family = algorithm_family(alg);
1524    set.keys
1525        .iter()
1526        .filter(|k| match kid {
1527            Some(id) => k.common.key_id.as_deref() == Some(id),
1528            None => true,
1529        })
1530        // Family filter (defense against a mixed set): RSA keys answer
1531        // RS*/PS*, EC keys answer ES*, OKP answers Ed*.
1532        .find(|k| {
1533            matches!(
1534                (&k.algorithm, family.as_str()),
1535                (AlgorithmParameters::RSA(_), "RS" | "PS")
1536                    | (AlgorithmParameters::EllipticCurve(_), "ES")
1537                    | (AlgorithmParameters::OctetKeyPair(_), "Ed")
1538            )
1539        })
1540}
1541
1542/// The JWK key family an algorithm verifies with ("RS", "ES", "Ed", ...).
1543fn algorithm_family(alg: Algorithm) -> String {
1544    match alg {
1545        Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => "HS".into(),
1546        Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 => "RS".into(),
1547        Algorithm::ES256 | Algorithm::ES384 => "ES".into(),
1548        Algorithm::EdDSA => "Ed".into(),
1549        Algorithm::PS256 | Algorithm::PS384 | Algorithm::PS512 => "PS".into(),
1550    }
1551}
1552
1553/// Public for testing the JWT algorithm-allowlist contract.
1554pub fn parse_algorithms(names: &[String]) -> Option<Vec<Algorithm>> {
1555    use std::str::FromStr as _;
1556    if names.is_empty() {
1557        return None;
1558    }
1559    let mut out = Vec::with_capacity(names.len());
1560    for name in names {
1561        let alg = match name.to_ascii_uppercase().as_str() {
1562            "HS256" | "HS384" | "HS512" | "NONE" => {
1563                // Asymmetric verification only: symmetric algorithms would
1564                // make the gateway a shared-secret holder and enable
1565                // alg-confusion downgrade; "none" is never a choice.
1566                return None;
1567            }
1568            other => Algorithm::from_str(other).ok()?,
1569        };
1570        out.push(alg);
1571    }
1572    Some(out)
1573}
1574
1575// --- composite -------------------------------------------------------------
1576
1577/// Upper bound on the spacing between refresh-triggered (unknown-kid)
1578/// JWKS fetches, before rate limiting or any other control: a forged
1579/// token with a random `kid` must never buy the attacker a JWKS fetch.
1580/// The effective window is `min(5s, refresh_secs)` so a tight refresh
1581/// cadence tightens the throttle too.
1582const JWKS_FORCED_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(5);
1583
1584/// Gateway-level mTLS consumer mapping (DW-035): resolves a VERIFIED
1585/// client certificate to a consumer name by fingerprint (colon-
1586/// separated hex) or subject CommonName, independent of the
1587/// per-consumer `mtls` credential registry. Built from
1588/// [`crate::config::MtlsConsumerMapping`] at authenticator build time;
1589/// the fingerprint map is keyed by the colon-separated hex form (the
1590/// operator-facing config format, matching
1591/// [`ClientCertificate::fingerprint_colon`]).
1592struct MtlsConsumerMap {
1593    /// Colon-separated fingerprint hex -> consumer name.
1594    by_fingerprint: HashMap<String, String>,
1595    /// Subject CommonName -> consumer name.
1596    by_subject_cn: HashMap<String, String>,
1597}
1598
1599impl MtlsConsumerMap {
1600    /// Build the runtime map from config. Fingerprints are normalized
1601    /// to lowercase (the config format is lowercase colon-separated
1602    /// hex; validation already checked the grammar).
1603    fn from_config(cfg: &crate::config::MtlsConsumerMapping) -> Self {
1604        let mut by_fingerprint = HashMap::new();
1605        for entry in &cfg.consumers {
1606            by_fingerprint.insert(
1607                entry.fingerprint.to_ascii_lowercase(),
1608                entry.consumer.clone(),
1609            );
1610        }
1611        let by_subject_cn = cfg
1612            .subject_cn_mapping
1613            .iter()
1614            .map(|(k, v)| (k.clone(), v.clone()))
1615            .collect();
1616        MtlsConsumerMap {
1617            by_fingerprint,
1618            by_subject_cn,
1619        }
1620    }
1621
1622    /// Resolve a verified client certificate to a consumer name.
1623    /// Checks the subject-CN map first (survives re-issue), then the
1624    /// fingerprint map (exact DER match). Returns `None` when the
1625    /// certificate matches no entry.
1626    fn resolve(&self, cert: &ClientCertificate) -> Option<&str> {
1627        if let Some(cn) = cert.subject_cn.as_deref() {
1628            if let Some(consumer) = self.by_subject_cn.get(cn) {
1629                return Some(consumer.as_str());
1630            }
1631        }
1632        self.by_fingerprint
1633            .get(cert.fingerprint_colon.as_str())
1634            .map(|s| s.as_str())
1635    }
1636
1637    /// Whether the map has any entries (fingerprints or subject CNs).
1638    fn is_empty(&self) -> bool {
1639        self.by_fingerprint.is_empty() && self.by_subject_cn.is_empty()
1640    }
1641}
1642
1643/// The gateway's single authenticator: dispatches on the request's
1644/// credential shape (X-API-Key / Basic / Bearer / verified client
1645/// certificate) and consults the credential registry plus the configured
1646/// JWT providers.
1647pub struct CompositeAuthenticator {
1648    registry: CredentialRegistry,
1649    jwt: Vec<Arc<JwtVerifier>>,
1650    /// Whether the config declares ANY jwt provider (#131):
1651    /// distinguishes "no provider configured" (Bearer is not the
1652    /// gateway's credential to interpret — deliberate pass-through)
1653    /// from "providers configured but every verifier failed to build"
1654    /// (the gateway promised to verify Bearer tokens and cannot —
1655    /// fail closed with `Unavailable`).
1656    jwt_configured: bool,
1657    /// OIDC providers (DW-034): runtime clients for token introspection.
1658    /// Built from `gateway.oidc_providers`; a build failure for one
1659    /// provider logs and skips (the provider is disabled, like a broken
1660    /// JWT provider). Empty when no OIDC providers are configured.
1661    oidc: Vec<Arc<crate::security::oidc::OidcClient>>,
1662    /// Whether the config declares ANY oidc provider (DW-034):
1663    /// distinguishes "no provider configured" (Bearer introspection is
1664    /// not the gateway's credential to interpret) from "providers
1665    /// configured but every client failed to build" (fail closed).
1666    oidc_configured: bool,
1667    /// The shared introspection cache (DW-034), carried ACROSS
1668    /// generation swaps so a reload never discards a cached `active:
1669    /// true` result (the `jwks_caches` / `oauth2_token_cache`
1670    /// precedent). Owned by the dataplane; the authenticator holds an
1671    /// Arc clone.
1672    oidc_cache: Arc<crate::security::oidc::OidcIntrospectionCache>,
1673    /// issuer -> (consumer name, audiences) from consumers' jwt
1674    /// credentials: the claims-based consumer mapping for tokens whose
1675    /// provider has no explicit `consumer` binding.
1676    jwt_consumer_index: HashMap<String, (String, Vec<String>)>,
1677    /// consumer name -> groups from the CONFIG consumers (#124): the
1678    /// fast lookup for identity group resolution; store-managed
1679    /// consumers fall through to a (cached) store lookup.
1680    consumer_groups_index: HashMap<String, Vec<String>>,
1681    /// consumer name -> consumer type from the CONFIG consumers
1682    /// (DW-113): the fast lookup for identity type resolution;
1683    /// store-managed consumers default to `User` (the state store has
1684    /// no type column in this edition).
1685    consumer_type_index: HashMap<String, ConsumerType>,
1686    /// The per-deployment credential pepper (#124): raw bytes resolved
1687    /// ABOVE this domain through the SecretSource seam, held Arc-shared
1688    /// with the dataplane's Zeroizing holder so the buffer is zeroized
1689    /// when the LAST holder drops and no plain copy exists on the way
1690    /// down. SECRET: never logged, never in Debug, never in error text.
1691    /// `None` = legacy-only mode (peppered entries fail closed).
1692    pepper: Option<Arc<Zeroizing<Vec<u8>>>>,
1693    /// Guards the one-shot "peppered credential failed closed without a
1694    /// pepper" warning (#124): the clear log line fires once per
1695    /// authenticator build, not per request.
1696    pepper_missing_logged: AtomicBool,
1697    /// HMAC signing keys (DW-036): key_id -> credential. Config-declared
1698    /// only (the state store cannot hold raw MAC key material; see the
1699    /// module docs) — built from `consumers[].credentials[type=hmac]`
1700    /// regardless of whether the registry variant is store or config.
1701    /// The map is present whenever any hmac credential resolved; the
1702    /// PRESENCE of `X-Dwara-Signature` with an empty map is a 401 (a
1703    /// presented credential the gateway cannot verify).
1704    hmac_keys: HashMap<String, Arc<HmacCredential>>,
1705    /// Gateway-wide HMAC verification policy (DW-036): the accepted
1706    /// timestamp skew in seconds. Defaults to
1707    /// [`DEFAULT_HMAC_CLOCK_SKEW_SECS`] when the config sets no
1708    /// `hmac_auth` block; validation bounds the configured value.
1709    hmac_skew_secs: u64,
1710    /// Replay-nonce store (DW-036), SHARED ACROSS rebuilds: the dataplane
1711    /// owns it and hands every generation the same Arc, so a config
1712    /// reload never wipes remembered nonces (the jwks_caches precedent).
1713    /// Per-instance by design in M2 (see the module docs' replay
1714    /// boundary note).
1715    nonce_cache: Arc<NonceCache>,
1716    /// Gateway-level mTLS consumer mapping (DW-035): maps verified
1717    /// client certificates to consumers by fingerprint or subject CN,
1718    /// independent of the per-consumer `mtls` credential registry.
1719    /// `None` when `mtls_consumer_mapping` is absent or disabled —
1720    /// certificates are matched only through consumers' `mtls`
1721    /// credentials (the DW-019 path).
1722    mtls_consumer_map: Option<MtlsConsumerMap>,
1723    /// Whether ANY credential family is active; when false the composite
1724    /// is a no-op (pass-through mode).
1725    enabled: bool,
1726}
1727
1728impl CompositeAuthenticator {
1729    /// The disabled authenticator: no registry, no providers, anonymous
1730    /// for everything (pass-through mode). The dataplane's placeholder
1731    /// before [`Self::build`] runs.
1732    pub fn disabled() -> Self {
1733        CompositeAuthenticator {
1734            registry: CredentialRegistry::Config(HashMap::new()),
1735            jwt: Vec::new(),
1736            jwt_configured: false,
1737            oidc: Vec::new(),
1738            oidc_configured: false,
1739            oidc_cache: Arc::new(crate::security::oidc::OidcIntrospectionCache::new()),
1740            jwt_consumer_index: HashMap::new(),
1741            consumer_groups_index: HashMap::new(),
1742            consumer_type_index: HashMap::new(),
1743            pepper: None,
1744            pepper_missing_logged: AtomicBool::new(false),
1745            hmac_keys: HashMap::new(),
1746            hmac_skew_secs: DEFAULT_HMAC_CLOCK_SKEW_SECS,
1747            nonce_cache: Arc::new(NonceCache::new()),
1748            mtls_consumer_map: None,
1749            enabled: false,
1750        }
1751    }
1752    /// Build from one config generation. `store` is the DWARA_STATE_DB
1753    /// store when deployed; without it credentials come from config,
1754    /// hashed in-memory. `jwks_caches` carries JWKS cache entries ACROSS
1755    /// rebuilds (keyed by URL) so reloads keep rotation state, and
1756    /// `nonce_cache` carries the HMAC replay-nonce store across rebuilds
1757    /// the same way (DW-036). `pepper` (#124) is the per-deployment
1758    /// credential pepper — an Arc CLONE of the dataplane's Zeroizing
1759    /// holder (no byte copy; the bytes zeroize when the last holder
1760    /// drops), resolved by the CALLER through the SecretSource extension
1761    /// seam (this domain must not import extensions) — or `None` for
1762    /// legacy-only mode. `oidc_cache` (DW-034) is the shared
1763    /// introspection cache carried across rebuilds (the
1764    /// `jwks_caches`/`oauth2_token_cache` precedent).
1765    pub fn build(
1766        gateway: &Gateway,
1767        store: Option<Arc<StateStore>>,
1768        jwks_caches: &mut HashMap<String, Arc<JwksCacheEntry>>,
1769        obs: Option<&Observability>,
1770        pepper: Option<&Arc<Zeroizing<Vec<u8>>>>,
1771        nonce_cache: Arc<NonceCache>,
1772        oidc_cache: Arc<crate::security::oidc::OidcIntrospectionCache>,
1773    ) -> Arc<Self> {
1774        let registry = match store {
1775            Some(store) => CredentialRegistry::Store(store),
1776            None => CredentialRegistry::from_config(gateway, pepper.map(|p| p.as_slice())),
1777        };
1778        let mut jwt = Vec::new();
1779        for cfg in &gateway.jwt_providers {
1780            let cache = jwks_caches
1781                .entry(cfg.jwks_url.clone())
1782                .or_insert_with(|| Arc::new(JwksCacheEntry::new()))
1783                .clone();
1784            match JwtVerifier::build(cfg.clone(), cache, obs) {
1785                Ok(v) => jwt.push(Arc::new(v)),
1786                Err(e) => {
1787                    tracing::error!(code = "jwt_provider_disabled", "jwt provider disabled: {e}")
1788                }
1789            }
1790        }
1791        // DW-034: OIDC providers. Each provider's `trusted_ca_file` is
1792        // loaded at build time so a broken bundle disables that provider
1793        // at build instead of failing every request (the JWT provider
1794        // precedent). A build failure logs and skips — the provider is
1795        // disabled, like a broken JWT provider.
1796        let mut oidc = Vec::new();
1797        for cfg in &gateway.oidc_providers {
1798            match crate::security::oidc::OidcClient::build(cfg.clone()) {
1799                Ok(client) => oidc.push(client),
1800                Err(e) => {
1801                    tracing::error!(
1802                        code = "oidc_provider_disabled",
1803                        provider = %cfg.name,
1804                        "oidc provider disabled: {e}"
1805                    );
1806                }
1807            }
1808        }
1809        let mut jwt_consumer_index = HashMap::new();
1810        let mut consumer_groups_index = HashMap::new();
1811        let mut consumer_type_index = HashMap::new();
1812        // DW-036: HMAC keys are config-served ONLY (raw key bytes in
1813        // memory; see the module docs), so they are collected here
1814        // regardless of the registry variant. `${...}` secret references
1815        // (DW-045) resolve HERE and the plaintext is dropped into the
1816        // Zeroizing holder; validation already rejected unresolvable
1817        // references for this generation, so an error here is the
1818        // validate-vs-build microsecond race — fail CLOSED (skip the
1819        // credential: that key stops authenticating) with a loud log,
1820        // the same pattern as api keys.
1821        let mut hmac_keys = HashMap::new();
1822        for consumer in &gateway.consumers {
1823            consumer_groups_index.insert(consumer.name.clone(), consumer.groups.clone());
1824            consumer_type_index.insert(consumer.name.clone(), consumer.consumer_type);
1825            for credential in &consumer.credentials {
1826                if let Credential::Jwt { issuer, audiences } = credential {
1827                    jwt_consumer_index
1828                        .insert(issuer.clone(), (consumer.name.clone(), audiences.clone()));
1829                }
1830                if let Credential::Hmac { key_id, secret } = credential {
1831                    let secret = match crate::config::credentials::resolve_configured_secret(secret)
1832                    {
1833                        Ok(resolved) => resolved,
1834                        Err(err) => {
1835                            tracing::error!(
1836                                code = "config_hmac_secret_unresolvable",
1837                                consumer = %consumer.name,
1838                                "skipping hmac credential (secret reference unresolvable \
1839                                 at authenticator build): {err}"
1840                            );
1841                            continue;
1842                        }
1843                    };
1844                    // Validation rejects duplicate key ids; a generation
1845                    // tear that slips one through keeps the LAST entry.
1846                    hmac_keys.insert(
1847                        key_id.clone(),
1848                        Arc::new(HmacCredential {
1849                            consumer_name: consumer.name.clone(),
1850                            secret: Arc::new(Zeroizing::new(secret.into_bytes())),
1851                        }),
1852                    );
1853                }
1854            }
1855        }
1856        let enabled = !gateway.consumers.is_empty() || !jwt.is_empty() || !oidc.is_empty();
1857        // DW-035: gateway-level mTLS consumer mapping. Built when
1858        // `mtls_consumer_mapping` is present and enabled; `None`
1859        // otherwise (certificates matched only through per-consumer
1860        // `mtls` credentials, the DW-019 path).
1861        let mtls_consumer_map = gateway
1862            .mtls_consumer_mapping
1863            .as_ref()
1864            .filter(|m| m.enabled)
1865            .map(MtlsConsumerMap::from_config);
1866        Arc::new(CompositeAuthenticator {
1867            registry,
1868            jwt,
1869            jwt_configured: !gateway.jwt_providers.is_empty(),
1870            oidc,
1871            oidc_configured: !gateway.oidc_providers.is_empty(),
1872            oidc_cache,
1873            jwt_consumer_index,
1874            consumer_groups_index,
1875            consumer_type_index,
1876            pepper: pepper.cloned(),
1877            pepper_missing_logged: AtomicBool::new(false),
1878            hmac_keys,
1879            hmac_skew_secs: gateway
1880                .hmac_auth
1881                .as_ref()
1882                .map_or(DEFAULT_HMAC_CLOCK_SKEW_SECS, |h| h.max_clock_skew_secs),
1883            nonce_cache,
1884            mtls_consumer_map,
1885            enabled,
1886        })
1887    }
1888
1889    /// Resolve the group memberships of a consumer by name (#124):
1890    /// config consumers from the config-built index, store-managed
1891    /// consumers through a (hot-cached) store lookup. Unknown name = no
1892    /// groups (group rules then deny, which fails closed).
1893    fn consumer_groups_of(&self, consumer_name: &str) -> Vec<String> {
1894        if let Some(groups) = self.consumer_groups_index.get(consumer_name) {
1895            return groups.clone();
1896        }
1897        if let CredentialRegistry::Store(store) = &self.registry {
1898            if let Ok(Some(record)) = store.lookup_consumer(consumer_name) {
1899                return record.groups.clone();
1900            }
1901        }
1902        Vec::new()
1903    }
1904
1905    /// Resolve the consumer type of a consumer by name (DW-113):
1906    /// config consumers from the config-built index; store-managed
1907    /// consumers default to `User` (the state store has no type column
1908    /// in this edition). Unknown name = `User` (fail-open, the
1909    /// default posture).
1910    fn consumer_type_of(&self, consumer_name: &str) -> ConsumerType {
1911        self.consumer_type_index
1912            .get(consumer_name)
1913            .copied()
1914            .unwrap_or(ConsumerType::User)
1915    }
1916
1917    /// A peppered stored hash failed closed because no pepper is
1918    /// configured. The clear log line fires ONCE per authenticator build
1919    /// (the condition cannot change without a rebuild), never per
1920    /// request; the message names no credential material.
1921    fn log_pepper_missing_once(&self) {
1922        if !self.pepper_missing_logged.swap(true, Ordering::Relaxed) {
1923            tracing::error!(
1924                code = "credential_pepper_absent",
1925                "a peppered (hmac-sha256) stored credential failed verification because no \
1926                 credential pepper is configured; peppered credentials cannot verify in \
1927                 legacy-only mode (legacy sha256 entries keep working)"
1928            );
1929        }
1930    }
1931
1932    async fn authenticate_api_key_or_basic(
1933        &self,
1934        selector: &str,
1935        presented_secret: &str,
1936    ) -> Result<Option<Identity>, AuthError> {
1937        let pepper = self.pepper.as_deref().map(|z| z.as_slice());
1938        let candidates = self.registry.lookup(selector).await?;
1939        for cred in &candidates {
1940            if cred.kind != CredentialKind::ApiKey {
1941                continue;
1942            }
1943            if cred.hash.starts_with("hmac-sha256:") && pepper.is_none() {
1944                self.log_pepper_missing_once();
1945            }
1946            if verify_secret(&cred.hash, presented_secret, pepper) {
1947                // #124 pepper transition: a SUCCESSFUL legacy (sha256)
1948                // verification with a pepper configured upgrades the store
1949                // row to the peppered format in place, so the transition
1950                // completes lazily without credential re-issue. Config-
1951                // seeded credentials (no row id) have no legacy residue by
1952                // construction. A re-hash failure never fails the request
1953                // (verification already succeeded) — it logs and leaves
1954                // the legacy row to retry on the next presentation.
1955                if let (Some(id), Some(pepper)) = (cred.id, pepper) {
1956                    if cred.hash.starts_with("sha256:") {
1957                        let peppered = hmac_stored_hash(pepper, presented_secret);
1958                        if let CredentialRegistry::Store(store) = &self.registry {
1959                            if let Err(e) = store.rehash_credential(id, selector, &peppered) {
1960                                tracing::warn!(
1961                                    code = "credential_rehash_failed",
1962                                    "legacy credential re-hash to the peppered format failed: {e}"
1963                                );
1964                            }
1965                        }
1966                    }
1967                }
1968                let consumer_name = cred.consumer_name.clone();
1969                let groups = self.consumer_groups_of(&consumer_name);
1970                let consumer_type = self.consumer_type_of(&consumer_name);
1971                return Ok(Some(Identity {
1972                    consumer_name,
1973                    credential_kind: CredentialKind::ApiKey,
1974                    consumer_type,
1975                    groups,
1976                    claims: BTreeMap::new(),
1977                    body_digest: None,
1978                    agent: None,
1979                }));
1980            }
1981        }
1982        Err(AuthError::Invalid("unknown api key or basic credentials"))
1983    }
1984
1985    /// mTLS family (#124, DW-035): map a VERIFIED client certificate to
1986    /// a consumer. Two resolution paths, in priority order:
1987    ///
1988    /// 1. **Gateway-level mapping** (DW-035): when
1989    ///    `mtls_consumer_mapping` is enabled, the certificate's subject
1990    ///    CN and colon-separated fingerprint are checked against the
1991    ///    gateway-level table FIRST. A match resolves directly to the
1992    ///    configured consumer (no credential registry lookup needed).
1993    ///    When the mapping is enabled but the certificate matches NO
1994    ///    entry, the request is rejected 401
1995    ///    `mtls_consumer_not_mapped` — the certificate was verified but
1996    ///    is not a known caller. This path does NOT fall through to the
1997    ///    credential registry: the gateway-level mapping is authoritative
1998    ///    when enabled.
1999    /// 2. **Per-consumer credential** (#124): the credential registry
2000    ///    is consulted by subject CN and fingerprint (the DW-019 path).
2001    ///    An `mtls` credential found under either selector is a match
2002    ///    because the credential's selector IS its match value. A
2003    ///    verified certificate that matches no credential is a
2004    ///    PRESENTED-but-rejected credential (401), exactly like an
2005    ///    unknown API key.
2006    async fn authenticate_client_cert(
2007        &self,
2008        cert: &ClientCertificate,
2009    ) -> Result<Option<Identity>, AuthError> {
2010        // DW-035: gateway-level mTLS consumer mapping takes priority
2011        // when enabled. The mapping is authoritative — a non-matching
2012        // certificate is rejected here, NOT falling through to the
2013        // credential registry (the operator explicitly enabled the
2014        // gateway-level table, so it is the source of truth).
2015        if let Some(map) = &self.mtls_consumer_map {
2016            if !map.is_empty() {
2017                if let Some(consumer_name) = map.resolve(cert) {
2018                    let groups = self.consumer_groups_of(consumer_name);
2019                    let consumer_type = self.consumer_type_of(consumer_name);
2020                    return Ok(Some(Identity {
2021                        consumer_name: consumer_name.to_string(),
2022                        credential_kind: CredentialKind::Mtls,
2023                        consumer_type,
2024                        groups,
2025                        claims: BTreeMap::new(),
2026                        body_digest: None,
2027                        agent: None,
2028                    }));
2029                }
2030                // Mapping enabled with entries but no match: the
2031                // certificate was verified but is not a known caller.
2032                return Err(AuthError::Invalid(
2033                    "client certificate matches no mtls consumer mapping entry",
2034                ));
2035            }
2036        }
2037        // DW-019 path: per-consumer `mtls` credential registry.
2038        let mut selectors: Vec<&str> = Vec::with_capacity(2);
2039        if let Some(cn) = cert.subject_cn.as_deref() {
2040            selectors.push(cn);
2041        }
2042        selectors.push(cert.fingerprint.as_str());
2043        for selector in selectors {
2044            let candidates = self.registry.lookup(selector).await?;
2045            for cred in &candidates {
2046                if cred.kind != CredentialKind::Mtls {
2047                    continue;
2048                }
2049                let consumer_name = cred.consumer_name.clone();
2050                let groups = self.consumer_groups_of(&consumer_name);
2051                let consumer_type = self.consumer_type_of(&consumer_name);
2052                return Ok(Some(Identity {
2053                    consumer_name,
2054                    credential_kind: CredentialKind::Mtls,
2055                    consumer_type,
2056                    groups,
2057                    claims: BTreeMap::new(),
2058                    body_digest: None,
2059                    agent: None,
2060                }));
2061            }
2062        }
2063        Err(AuthError::Invalid(
2064            "client certificate matches no credential",
2065        ))
2066    }
2067
2068    async fn authenticate_jwt(&self, token: &str) -> Result<Option<Identity>, AuthError> {
2069        if self.jwt.is_empty() {
2070            if !self.jwt_configured {
2071                // No provider configured: Bearer stays pass-through —
2072                // the header may be intended for the upstream (the
2073                // documented optional-authn shape; `challenge()` offers
2074                // no Bearer scheme in this state either).
2075                return Ok(None);
2076            }
2077            // Providers are configured but every verifier failed to
2078            // build (#131): the gateway promised to verify Bearer tokens
2079            // and cannot. Failing closed with `Unavailable` (500-class
2080            // authentication_unavailable) instead of proxying the token
2081            // unverified with no consumer identity. Reachable only via
2082            // the validate-vs-build race (#121 rejects broken bundles at
2083            // validation); the residual must still fail loud, not
2084            // silently.
2085            return Err(AuthError::Unavailable(
2086                "jwt providers are configured but disabled (verifier build failed); \
2087                 failing closed instead of proxying Bearer tokens unverified"
2088                    .to_string(),
2089            ));
2090        }
2091        let header = jsonwebtoken::decode_header(token)
2092            .map_err(|_| AuthError::Invalid("token header is malformed"))?;
2093        let kid = header.kid.as_deref();
2094        let mut last_invalid = AuthError::Invalid("token did not verify");
2095        let mut unavailable: Option<AuthError> = None;
2096        for verifier in &self.jwt {
2097            if !verifier.algorithms.contains(&header.alg) {
2098                last_invalid = AuthError::Invalid("token algorithm is not allowed");
2099                continue;
2100            }
2101            match self.verify_with(verifier, token, &header, kid).await {
2102                Ok(identity) => return Ok(Some(identity)),
2103                Err(e @ AuthError::Invalid(_)) => last_invalid = e,
2104                Err(e @ AuthError::Unavailable(_)) => unavailable = Some(e),
2105            }
2106        }
2107        // Error masking: when EVERY provider failed, a 401 ("invalid
2108        // token") would hide that one of them was down. If any provider
2109        // was Unavailable, surface the gateway-side failure (500-class)
2110        // so operators see it instead of a wall of caller-side 401s.
2111        if let Some(e) = unavailable {
2112            return Err(e);
2113        }
2114        Err(last_invalid)
2115    }
2116
2117    /// OIDC token introspection (DW-034): the second Bearer family. A
2118    /// presented Bearer token that did not verify against any JWT
2119    /// provider (DW-019) is introspected against each configured OIDC
2120    /// provider in order; the first `active: true` result resolves an
2121    /// [`Identity`]. Returns `Ok(None)` when NO OIDC provider is
2122    /// configured (pass-through, like JWT with no providers). When
2123    /// providers ARE configured but every introspection fails, the
2124    /// fail-open/fail-closed posture is governed by each provider's
2125    /// `fail_open` config: a fail-open provider yields `Ok(None)`
2126    /// (anonymous) on an IdP failure; a fail-closed provider yields
2127    /// `Err(Invalid)` (401) — the story's "fail-closed = 401" applies
2128    /// to ALL IdP failures (network, non-2xx, malformed, inactive), so
2129    /// a down IdP does not surface as a 500-class gateway error (the
2130    /// operator chose fail-closed, meaning "reject the token"). A
2131    /// cached `active: true` result short-circuits the IdP call (see
2132    /// `OidcIntrospectionCache`).
2133    async fn authenticate_oidc(&self, token: &str) -> Result<Option<Identity>, AuthError> {
2134        if self.oidc.is_empty() {
2135            if !self.oidc_configured {
2136                // No provider configured: Bearer stays pass-through
2137                // (not interpreted), so the certificate family may
2138                // still authenticate the connection.
2139                return Ok(None);
2140            }
2141            // Providers configured but every client failed to build:
2142            // the gateway promised to introspect Bearer tokens and
2143            // cannot. Fail closed (the JWT-disabled precedent, #131).
2144            return Err(AuthError::Unavailable(
2145                "oidc providers are configured but disabled (client build failed); \
2146                 failing closed instead of proxying Bearer tokens unverified"
2147                    .to_string(),
2148            ));
2149        }
2150        let mut last_invalid = AuthError::Invalid("token did not introspect as active");
2151        for client in &self.oidc {
2152            match client.introspect(token, &self.oidc_cache).await {
2153                Ok(resp) => {
2154                    // `active: true` (introspect returns Ok only for
2155                    // active results; Inactive is an Err). Map the
2156                    // introspection result to an Identity.
2157                    let consumer_name = match &client.config().consumer {
2158                        Some(name) => name.clone(),
2159                        None => resp
2160                            .sub
2161                            .clone()
2162                            .unwrap_or_else(|| "oidc-anonymous".to_string()),
2163                    };
2164                    let groups = self.consumer_groups_of(&consumer_name);
2165                    let consumer_type = self.consumer_type_of(&consumer_name);
2166                    // Build the identity claims map from the
2167                    // introspection response (the JWT claims-map
2168                    // precedent: string and number scalars, arrays of
2169                    // strings flattened to space-joined form).
2170                    let mut claims = BTreeMap::new();
2171                    if let Some(obj) = resp.raw.as_object() {
2172                        for (k, v) in obj {
2173                            if claims.len() >= 32 {
2174                                break;
2175                            }
2176                            match v {
2177                                serde_json::Value::String(s) => {
2178                                    claims.insert(k.clone(), s.clone());
2179                                }
2180                                serde_json::Value::Number(n) => {
2181                                    claims.insert(k.clone(), n.to_string());
2182                                }
2183                                serde_json::Value::Array(a)
2184                                    if !a.is_empty() && a.iter().all(|v| v.is_string()) =>
2185                                {
2186                                    claims.insert(
2187                                        k.clone(),
2188                                        a.iter()
2189                                            .filter_map(|v| v.as_str())
2190                                            .collect::<Vec<_>>()
2191                                            .join(" "),
2192                                    );
2193                                }
2194                                _ => {}
2195                            }
2196                        }
2197                    }
2198                    return Ok(Some(Identity {
2199                        consumer_name,
2200                        credential_kind: CredentialKind::Oidc,
2201                        consumer_type,
2202                        groups,
2203                        claims,
2204                        body_digest: None,
2205                        agent: None,
2206                    }));
2207                }
2208                Err(crate::security::oidc::OidcError::Inactive) => {
2209                    // The token was explicitly rejected (`active:
2210                    // false`). This is a 401 regardless of fail_open
2211                    // (the token is not valid, not a gateway problem).
2212                    last_invalid = AuthError::Invalid("token introspected as inactive");
2213                }
2214                Err(e) => {
2215                    // An IdP failure (network, non-2xx, malformed).
2216                    // Fail-open providers yield anonymous; fail-closed
2217                    // providers record the error as 401 (the story's
2218                    // "fail-closed = 401" applies to ALL IdP failures,
2219                    // so a down IdP does not surface as 500 — the
2220                    // operator chose to reject). The error text is
2221                    // logged via the OidcError's Display, not sent to
2222                    // the client (the envelope never leaks IdP
2223                    // internals).
2224                    if client.config().fail_open {
2225                        // Fail open: this provider declines to
2226                        // authenticate but does not reject. Continue
2227                        // to the next provider; if none succeed, the
2228                        // caller sees `Ok(None)` (pass-through).
2229                        continue;
2230                    }
2231                    // Fail closed: 401 for every IdP failure shape.
2232                    last_invalid = AuthError::Invalid("oidc introspection failed");
2233                    tracing::warn!(
2234                        code = "oidc_introspection_failed",
2235                        provider = %client.config().name,
2236                        "oidc introspection failed (fail-closed): {e}"
2237                    );
2238                }
2239            }
2240        }
2241        // If every fail-closed provider rejected the token (or every
2242        // provider was fail-open and none succeeded), return the last
2243        // invalid. When every provider was fail-open and none
2244        // succeeded, `last_invalid` is the default — but fail-open
2245        // means the operator chose pass-through, so return `Ok(None)`
2246        // when NO fail-closed provider was consulted.
2247        if self.oidc.iter().all(|c| c.config().fail_open) {
2248            return Ok(None);
2249        }
2250        Err(last_invalid)
2251    }
2252
2253    async fn verify_with(
2254        &self,
2255        verifier: &JwtVerifier,
2256        token: &str,
2257        header: &Header,
2258        kid: Option<&str>,
2259    ) -> Result<Identity, AuthError> {
2260        let jwk = verifier.key_for(kid, header.alg).await?;
2261        let decoding_key = DecodingKey::from_jwk(&jwk)
2262            .map_err(|_| AuthError::Invalid("jwk is not usable for this token"))?;
2263        // Asymmetric sanity: the JWK must actually be an asymmetric key
2264        // (RSA/EC/OKP) — a symmetric key in the set paired with an
2265        // allowed algorithm is a configuration error, not a secret.
2266        if !matches!(
2267            jwk.algorithm,
2268            AlgorithmParameters::RSA(_)
2269                | AlgorithmParameters::EllipticCurve(_)
2270                | AlgorithmParameters::OctetKeyPair(_)
2271        ) {
2272            return Err(AuthError::Invalid("jwk is not an asymmetric key"));
2273        }
2274        let mut validation = Validation::new(header.alg);
2275        validation.leeway = verifier.cfg.leeway_secs;
2276        if let Some(iss) = &verifier.cfg.issuer {
2277            validation.iss = Some(std::iter::once(iss.clone()).collect());
2278        }
2279        // #124 (maintainer decision): audience is validated ONLY when the
2280        // provider configures one. jsonwebtoken validates `aud` whenever
2281        // the token CARRIES the claim and the Validation expects it — so
2282        // with no configured audience we switch aud validation off
2283        // entirely (a token with any, or no, `aud` is accepted) instead of
2284        // jsonwebtoken's reject-on-presence. Every other validation (exp,
2285        // nbf, iss when configured, the algorithm allowlist above) is
2286        // identical either way.
2287        match &verifier.cfg.audience {
2288            Some(aud) => validation.aud = Some(std::iter::once(aud.clone()).collect()),
2289            None => validation.validate_aud = false,
2290        }
2291        validation.validate_exp = true;
2292        validation.validate_nbf = true;
2293        let data = jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)
2294            .map_err(|_| AuthError::Invalid("token failed verification"))?;
2295        let claims = &data.claims;
2296        // Consumer mapping: explicit provider binding first, then the
2297        // consumers' jwt-credential issuer index (with audience
2298        // containment when the credential lists audiences).
2299        let consumer_name = match &verifier.cfg.consumer {
2300            Some(name) => name.clone(),
2301            None => {
2302                let iss = claims
2303                    .get("iss")
2304                    .and_then(|v| v.as_str())
2305                    .ok_or(AuthError::Invalid("token has no string iss claim"))?;
2306                let (name, audiences) = self.jwt_consumer_index.get(iss).ok_or(
2307                    AuthError::Invalid("token issuer is not bound to a consumer"),
2308                )?;
2309                if !audiences.is_empty() {
2310                    let aud_matches = match claims.get("aud") {
2311                        Some(serde_json::Value::String(a)) => audiences.iter().any(|w| w == a),
2312                        Some(serde_json::Value::Array(a)) => a
2313                            .iter()
2314                            .filter_map(|v| v.as_str())
2315                            .any(|v| audiences.iter().any(|w| w == v)),
2316                        _ => false,
2317                    };
2318                    if !aud_matches {
2319                        return Err(AuthError::Invalid(
2320                            "token audience does not match the consumer binding",
2321                        ));
2322                    }
2323                }
2324                name.clone()
2325            }
2326        };
2327        let groups = self.consumer_groups_of(&consumer_name);
2328        let consumer_type = self.consumer_type_of(&consumer_name);
2329        let mut identity_claims = BTreeMap::new();
2330        if let Some(map) = claims.as_object() {
2331            for (k, v) in map {
2332                if identity_claims.len() >= 32 {
2333                    break;
2334                }
2335                match v {
2336                    serde_json::Value::String(s) => {
2337                        identity_claims.insert(k.clone(), s.clone());
2338                    }
2339                    serde_json::Value::Number(n) => {
2340                        identity_claims.insert(k.clone(), n.to_string());
2341                    }
2342                    // Array-of-strings claims are flattened to their
2343                    // space-separated form (the OAuth `scope` convention,
2344                    // which authorization's required_scopes matches either
2345                    // way — DW-020). NOTE: the map stores the flattened
2346                    // string, so ANY future consumer of identity claims
2347                    // sees arrays as space-joined strings, never as lists.
2348                    // Arrays holding non-strings are dropped, like every
2349                    // other non-scalar claim.
2350                    serde_json::Value::Array(a)
2351                        if !a.is_empty() && a.iter().all(|v| v.is_string()) =>
2352                    {
2353                        identity_claims.insert(
2354                            k.clone(),
2355                            a.iter()
2356                                .filter_map(|v| v.as_str())
2357                                .collect::<Vec<_>>()
2358                                .join(" "),
2359                        );
2360                    }
2361                    _ => {}
2362                }
2363            }
2364        }
2365        Ok(Identity {
2366            consumer_name,
2367            credential_kind: CredentialKind::Jwt,
2368            consumer_type,
2369            groups,
2370            claims: identity_claims,
2371            body_digest: None,
2372            agent: None,
2373        })
2374    }
2375
2376    /// HMAC request-signature family (DW-036). Verification order and
2377    /// the failure posture are documented in the module docs (parse ->
2378    /// timestamp window -> constant-time MAC -> nonce burn); the
2379    /// returned identity carries the signed body digest for the forward
2380    /// path to enforce while streaming (`Identity::body_digest`).
2381    async fn authenticate_hmac(
2382        &self,
2383        req: &AuthnRequest<'_>,
2384    ) -> Result<Option<Identity>, AuthError> {
2385        let header =
2386            |name: &hyper::header::HeaderName| req.headers.get(name).and_then(|v| v.to_str().ok());
2387        // All five headers are required; a partial set is a malformed
2388        // presented credential (401), never a fall-through to anonymous.
2389        let Some(key_id) = header(&X_DWARA_KEY_ID) else {
2390            return Err(AuthError::Invalid("signed request lacks x-dwara-key-id"));
2391        };
2392        let Some(timestamp) = header(&X_DWARA_TIMESTAMP) else {
2393            return Err(AuthError::Invalid("signed request lacks x-dwara-timestamp"));
2394        };
2395        let Some(nonce) = header(&X_DWARA_NONCE) else {
2396            return Err(AuthError::Invalid("signed request lacks x-dwara-nonce"));
2397        };
2398        let Some(body_sha256) = header(&X_DWARA_BODY_SHA256) else {
2399            return Err(AuthError::Invalid(
2400                "signed request lacks x-dwara-body-sha256",
2401            ));
2402        };
2403        let Some(signature) = header(&X_DWARA_SIGNATURE) else {
2404            // Unreachable from the dispatcher (presence of this header is
2405            // what engages the family) but keeps the family self-contained.
2406            return Err(AuthError::Invalid("signed request lacks x-dwara-signature"));
2407        };
2408        // Format bounds: the canonical grammar is only unambiguous over
2409        // these shapes (module docs). Checked before the timestamp so a
2410        // hostile header never reaches the clock or the cache.
2411        if !is_visible_ascii(key_id.as_bytes()) || key_id.len() > MAX_KEY_ID_BYTES {
2412            return Err(AuthError::Invalid(
2413                "key id is not 1..=128 visible ascii bytes",
2414            ));
2415        }
2416        if !is_visible_ascii(nonce.as_bytes())
2417            || !(MIN_NONCE_BYTES..=MAX_NONCE_BYTES).contains(&nonce.len())
2418        {
2419            return Err(AuthError::Invalid(
2420                "nonce is not 16..=256 visible ascii bytes",
2421            ));
2422        }
2423        let Some(presented_mac) = decode_hex32(signature) else {
2424            return Err(AuthError::Invalid("signature is not 64 hex digits"));
2425        };
2426        let Some(body_digest) = decode_hex32(body_sha256) else {
2427            return Err(AuthError::Invalid("body digest is not 64 hex digits"));
2428        };
2429        if !timestamp.bytes().all(|b| b.is_ascii_digit()) || timestamp.len() > 20 {
2430            return Err(AuthError::Invalid("timestamp is not decimal unix seconds"));
2431        }
2432        // The shape check above does not bound the VALUE: 20 digits
2433        // reach above u64::MAX (18446744073709551616 and friends), and
2434        // `u64::from_str` rejects them. Parse fail-closed — a panic
2435        // here would kill the connection task instead of answering
2436        // the 401 envelope (remotely triggerable, unauthenticated).
2437        let Ok(presented_ts) = timestamp.parse::<u64>() else {
2438            return Err(AuthError::Invalid("timestamp is not decimal unix seconds"));
2439        };
2440        // Clock-skew window (§4.6): reject BEFORE any HMAC work. The
2441        // window is symmetric (past and future) — signers' clocks may
2442        // drift either way. u64 arithmetic end to end: no signed cast
2443        // of a hostile 20-digit value can wrap the comparison.
2444        let now = SystemTime::now()
2445            .duration_since(UNIX_EPOCH)
2446            .map(|d| d.as_secs())
2447            .unwrap_or(0);
2448        let drift = presented_ts.abs_diff(now);
2449        if drift > self.hmac_skew_secs {
2450            return Err(AuthError::Invalid(
2451                "signature timestamp is outside the clock-skew window",
2452            ));
2453        }
2454        // Canonical string from the PRESENTED bytes (module docs): no
2455        // normalization of path or query, the digest header value as
2456        // sent. The signature header itself is deliberately NOT signed
2457        // material (it cannot sign itself).
2458        let canonical = canonical_string(
2459            key_id,
2460            req.method,
2461            req.uri.path(),
2462            req.uri.query(),
2463            timestamp,
2464            nonce,
2465            body_sha256,
2466        );
2467        let Some(credential) = self.hmac_keys.get(key_id) else {
2468            // Key-existence timing oracle: compute a dummy MAC over the
2469            // same canonical string with a fixed zero key so "unknown
2470            // key" and "wrong signature" spend the same work. The dummy
2471            // is discarded; the answer is the same 401 either way.
2472            let _ = request_mac(&[0u8; 32], &canonical);
2473            return Err(AuthError::Invalid("unknown hmac credentials"));
2474        };
2475        let computed = request_mac(credential.secret.as_slice(), &canonical);
2476        // Constant-time over the full 32-byte digests: no early return
2477        // on a byte mismatch (DW-036 done-when).
2478        if !bool::from(computed.ct_eq(&presented_mac)) {
2479            return Err(AuthError::Invalid("request signature did not verify"));
2480        }
2481        // Replay window (module docs): burn the nonce only AFTER a
2482        // successful MAC, scoped to the key (nonce collisions across
2483        // consumers must not cross-burn). TTL is twice the skew window
2484        // so a timestamp stays replay-guarded for its entire acceptance
2485        // lifetime with margin.
2486        let cache_key = format!("{key_id}\n{nonce}");
2487        if !self
2488            .nonce_cache
2489            .check_and_insert(&cache_key, Duration::from_secs(self.hmac_skew_secs * 2))
2490        {
2491            return Err(AuthError::Invalid("nonce already used inside the window"));
2492        }
2493        let consumer_name = credential.consumer_name.clone();
2494        let groups = self.consumer_groups_of(&consumer_name);
2495        let consumer_type = self.consumer_type_of(&consumer_name);
2496        Ok(Some(Identity {
2497            consumer_name,
2498            credential_kind: CredentialKind::Hmac,
2499            consumer_type,
2500            groups,
2501            claims: BTreeMap::new(),
2502            body_digest: Some(body_digest),
2503            agent: None,
2504        }))
2505    }
2506}
2507
2508#[async_trait]
2509impl Authenticator for CompositeAuthenticator {
2510    async fn authenticate(&self, req: &AuthnRequest<'_>) -> Result<Option<Identity>, AuthError> {
2511        if !self.enabled {
2512            return Ok(None);
2513        }
2514        let headers = req.headers;
2515        // Family precedence (documented): header-presented credentials
2516        // express explicit intent and win — X-API-Key over anything in
2517        // `Authorization`, `Basic` over `Bearer` by scheme token, the
2518        // X-Dwara signature family after the Authorization schemes —
2519        // and the VERIFIED client certificate is the ambient,
2520        // connection-level family consulted only when no header
2521        // credential was presented. A Bearer header with no configured
2522        // provider is NOT interpreted (pass-through), so it falls
2523        // through to the remaining families rather than masking them.
2524        if let Some(key) = headers.get(&X_API_KEY).and_then(|v| v.to_str().ok()) {
2525            if key.is_empty() {
2526                return Err(AuthError::Invalid("empty api key"));
2527            }
2528            return self
2529                .authenticate_api_key_or_basic(&credential_selector(key), key)
2530                .await;
2531        }
2532        if let Some(value) = headers
2533            .get(hyper::header::AUTHORIZATION)
2534            .and_then(|v| v.to_str().ok())
2535        {
2536            let (scheme, rest) = value.split_once(' ').unwrap_or((value, ""));
2537            if scheme.eq_ignore_ascii_case("basic") {
2538                let decoded = BASE64
2539                    .decode(rest.trim())
2540                    .map_err(|_| AuthError::Invalid("basic credentials are not valid base64"))?;
2541                let decoded = String::from_utf8(decoded)
2542                    .map_err(|_| AuthError::Invalid("basic credentials are not utf-8"))?;
2543                let (user, pass) = decoded
2544                    .split_once(':')
2545                    .ok_or(AuthError::Invalid("basic credentials lack a ':' separator"))?;
2546                if user.is_empty() || pass.is_empty() {
2547                    return Err(AuthError::Invalid("basic credentials are empty"));
2548                }
2549                return self
2550                    .authenticate_api_key_or_basic(&credential_selector(user), pass)
2551                    .await;
2552            }
2553            if scheme.eq_ignore_ascii_case("bearer") {
2554                if rest.trim().is_empty() {
2555                    return Err(AuthError::Invalid("empty bearer token"));
2556                }
2557                let token = rest.trim();
2558                // JWT verification (DW-019): the first Bearer family.
2559                // No provider configured leaves `Ok(None)`: Bearer stays
2560                // pass-through (not interpreted) for JWT, so the OIDC
2561                // family (DW-034) and then the certificate family may
2562                // still authenticate the connection. Providers
2563                // configured but disabled (#131) instead answers
2564                // `Err(Unavailable)` — the presented credential was the
2565                // gateway's to verify and it cannot, so it never
2566                // proxies unverified.
2567                match self.authenticate_jwt(token).await {
2568                    Ok(Some(id)) => return Ok(Some(id)),
2569                    Ok(None) => {
2570                        // No JWT provider: fall through to OIDC
2571                        // introspection (DW-034). If OIDC also has no
2572                        // providers, it returns `Ok(None)` and the
2573                        // certificate family may still authenticate.
2574                        if let oidc @ (Ok(Some(_)) | Err(_)) = self.authenticate_oidc(token).await {
2575                            return oidc;
2576                        }
2577                    }
2578                    Err(AuthError::Unavailable(e)) => {
2579                        return Err(AuthError::Unavailable(e));
2580                    }
2581                    Err(AuthError::Invalid(jwt_msg)) => {
2582                        // JWT rejected the token. Try OIDC
2583                        // introspection (the token may be an opaque
2584                        // token, not a JWT). If OIDC resolves an
2585                        // identity, return it; otherwise fail closed
2586                        // with the JWT rejection (the presented
2587                        // credential was the gateway's to verify and
2588                        // neither family accepted it).
2589                        match self.authenticate_oidc(token).await {
2590                            Ok(Some(id)) => return Ok(Some(id)),
2591                            Ok(None) => return Err(AuthError::Invalid(jwt_msg)),
2592                            Err(e) => return Err(e),
2593                        }
2594                    }
2595                }
2596            }
2597        }
2598        // HMAC request-signature family (DW-036): engaged by the
2599        // PRESENCE of the signature header — an explicit-intent header
2600        // credential like the two above, so a signed request never
2601        // falls through to the ambient certificate family.
2602        if headers.contains_key(&X_DWARA_SIGNATURE) {
2603            return self.authenticate_hmac(req).await;
2604        }
2605        // Ambient family: the verified client certificate (#124).
2606        if let Some(cert) = req.client_cert {
2607            return self.authenticate_client_cert(cert).await;
2608        }
2609        Ok(None)
2610    }
2611
2612    fn challenge(&self) -> String {
2613        let mut parts = Vec::new();
2614        match &self.registry {
2615            CredentialRegistry::Config(m) if m.is_empty() => {}
2616            // The store path may hold credentials regardless of config,
2617            // so it conservatively offers the Basic challenge.
2618            CredentialRegistry::Store(_) | CredentialRegistry::Config(_) => {
2619                parts.push("Basic realm=\"dwara\"".to_string());
2620            }
2621        }
2622        if !self.jwt.is_empty() {
2623            parts.push("Bearer".to_string());
2624        }
2625        // DW-034: OIDC providers also interpret Bearer tokens (via
2626        // introspection), so the challenge offers Bearer when OIDC
2627        // providers are configured even if no JWT provider is.
2628        if !self.oidc.is_empty() {
2629            parts.push("Bearer".to_string());
2630        }
2631        if !self.hmac_keys.is_empty() {
2632            parts.push(HMAC_CHALLENGE.to_string());
2633        }
2634        if parts.is_empty() {
2635            "Bearer".to_string()
2636        } else {
2637            parts.join(", ")
2638        }
2639    }
2640}
2641
2642/// Whether the gateway treats `X-Consumer-*` request headers as spoof
2643/// material: always — the proxy strips them and injects its own.
2644pub const CONSUMER_HEADER_PREFIX: &str = "x-consumer-";
2645
2646/// The trusted consumer-identity header the gateway injects upstream.
2647pub const X_CONSUMER_NAME: hyper::header::HeaderName =
2648    hyper::header::HeaderName::from_static("x-consumer-name");