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");