dwara_core/security/
oidc.rs

1//! OpenID Connect: discovery, token introspection, revocation, token
2//! exchange, and the authorization-code + PKCE relying-party flow
3//! (DW-034).
4//!
5//! The gateway acts as an OIDC client (relying party) for two distinct
6//! purposes:
7//!
8//! 1. **Token introspection (RFC 7662)** — on the request path, a
9//!    presented `Authorization: Bearer` token that did not verify
10//!    against any JWT provider (DW-019) is introspected against the
11//!    configured OIDC provider's introspection endpoint. An `active:
12//!    true` result resolves an `Identity`; `active: false` (or a
13//!    non-2xx) is a 401. Introspection results are cached keyed by the
14//!    token's SHA-256 hash with a configurable TTL — a cached `active:
15//!    true` short-circuits the IdP call, while a cached `active: false`
16//!    is NOT cached (re-checked on the next request so a revoked token
17//!    is noticed promptly).
18//! 2. **Authorization-code + PKCE (RFC 6749 section 4.1 + RFC 7636)** —
19//!    the user-facing login flow: the gateway redirects an
20//!    unauthenticated request to the IdP's authorization endpoint with
21//!    a PKCE code challenge, handles the callback, and exchanges the
22//!    code for access/refresh/ID tokens. This is the relying-party
23//!    role; the resulting tokens are stored in a session cookie.
24//!
25//! Two management operations round out the flow:
26//!
27//! - **Token revocation (RFC 7009)** — POST to the revocation endpoint
28//!   to revoke a token. An admin/CLI operation, not on the hot request
29//!   path.
30//! - **Token exchange (RFC 8693)** — exchange a subject token (the
31//!   client's bearer token) for an actor token for an upstream, using
32//!   the `urn:ietf:params:oauth:grant-type:token-exchange` grant type.
33//!   This extends the OAuth2 client-credentials proxying pattern
34//!   (DW-035).
35//!
36//! ## Discovery
37//!
38//! The provider's discovery document is fetched from
39//! `{issuer}/.well-known/openid-configuration` (RFC 8414 / OIDC
40//! Discovery 1.0) and cached for the provider's lifetime (it rarely
41//! changes). The document supplies the introspection, revocation,
42//! authorization, and token endpoints; config-level overrides
43//! (`introspection_endpoint`, `revocation_endpoint`) take precedence
44//! over the discovered values.
45//!
46//! ## HTTP
47//!
48//! All IdP HTTP calls use the `OidcConnector` (plain-or-TLS, the
49//! `JwksConnector`/`OAuth2Connector` pattern), reusing the workspace
50//! rustls stack with no new HTTP dependencies. A `trusted_ca_file`
51//! replaces the webpki public roots for an IdP behind a private CA
52//! (the same trust model as `JwtProvider::trusted_ca_file`).
53//!
54//! ## Error posture
55//!
56//! An introspection failure (network error, non-2xx, malformed
57//! response) is governed by the provider's `fail_open` config: default
58//! false (fail closed = 401, the gateway refuses to authenticate a
59//! token it cannot introspect); true treats the failure as anonymous
60//! (pass-through). Fail-open trades security for availability. The
61//! error envelope never leaks the IdP's response body or headers.
62//!
63//! ## Caching
64//!
65//! The introspection cache ([`OidcIntrospectionCache`]) lives on the
66//! dataplane (carried across generation swaps, the `jwks_caches` /
67//! `oauth2_token_cache` precedent) so a config reload never discards a
68//! cached `active: true` result. Entries are keyed by
69//! `{provider_name}:{sha256_hex(token)}` and expire after
70//! `introspection_cache_ttl_s`. The discovery document is cached
71//! per-provider on the [`OidcClient`] (rebuilt per generation, like
72//! `OAuth2Client`).
73
74use std::collections::HashMap;
75use std::future::Future;
76use std::pin::Pin;
77use std::sync::Arc;
78use std::task::{Context, Poll};
79use std::time::{Duration, Instant};
80
81use base64::engine::general_purpose::STANDARD as BASE64;
82use base64::Engine as _;
83use http_body_util::{BodyExt as _, Full, Limited};
84use hyper::{Method, Request, Uri};
85use hyper_util::client::legacy::connect::{Connected, Connection as HyperConnection};
86use hyper_util::client::legacy::Client;
87use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
88use rustls::pki_types::ServerName;
89use sha2::{Digest, Sha256};
90use tower_service::Service;
91
92use crate::config::OidcProvider;
93
94/// Connect timeout for IdP HTTP calls: discovery, introspection, and
95/// token exchange are on the request path (first request / cache miss),
96/// so they must fail fast rather than hang a client request.
97const OIDC_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
98
99/// Upper bound on an IdP response body (1 MiB): a discovery document,
100/// introspection response, or token response is a few KiB; anything
101/// larger is a misbehaving or hostile endpoint.
102const OIDC_BODY_CAP: usize = 1024 * 1024;
103
104/// How long a cached discovery document is considered fresh before a
105/// re-fetch is forced (1 hour): the document rarely changes, and a
106/// re-fetch on every request would be wasteful. A generation swap
107/// (config reload) rebuilds the client, which re-fetches discovery on
108/// the next request anyway.
109const DISCOVERY_FRESH_SECS: u64 = 3600;
110
111/// Error contacting an OIDC provider (DW-034). The `Display` text is
112/// logged, not sent to the client (the envelope never leaks IdP
113/// internals). All variants surface to the client as 401 (fail closed)
114/// or pass-through (fail open) per the provider's `fail_open` config.
115#[derive(Debug)]
116pub enum OidcError {
117    /// The issuer or endpoint URL is invalid.
118    InvalidEndpoint(String),
119    /// The IdP returned a non-2xx status.
120    EndpointStatus(u16),
121    /// The IdP's response body could not be parsed (missing required
122    /// field, non-string, malformed JSON).
123    MalformedResponse(String),
124    /// A network or HTTP error contacting the IdP.
125    Fetch(String),
126    /// The `trusted_ca_file` bundle could not be loaded.
127    TlsConfig(String),
128    /// The introspection result was `active: false` (the token is not
129    /// valid / has been revoked). Distinct from a network error so the
130    /// caller can map it to 401 (fail closed) regardless of `fail_open`.
131    Inactive,
132}
133
134impl std::fmt::Display for OidcError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            OidcError::InvalidEndpoint(m) => {
138                write!(f, "oidc endpoint is invalid: {m}")
139            }
140            OidcError::EndpointStatus(s) => {
141                write!(f, "oidc endpoint returned status {s}")
142            }
143            OidcError::MalformedResponse(m) => {
144                write!(f, "oidc response is malformed: {m}")
145            }
146            OidcError::Fetch(m) => write!(f, "oidc fetch failed: {m}"),
147            OidcError::TlsConfig(m) => write!(f, "oidc tls config error: {m}"),
148            OidcError::Inactive => write!(f, "oidc introspection returned active: false"),
149        }
150    }
151}
152
153impl std::error::Error for OidcError {}
154
155/// A connection that is either plaintext TCP or TLS: the single
156/// response type of `OidcConnector` for `http://` and `https://` IdP
157/// endpoints. Mirrors the JWKS connector's `MaybeTls` (authn.rs) and
158/// the OAuth2 connector's (oauth2.rs) — kept separate because the OIDC
159/// connector carries a `trusted_ca_file` (private-CA IdP) the OAuth2
160/// connector does not.
161enum MaybeTls {
162    Plain(hyper_util::rt::tokio::WithHyperIo<tokio::net::TcpStream>),
163    Tls(
164        Box<
165            tokio_rustls::client::TlsStream<
166                hyper_util::rt::tokio::WithHyperIo<tokio::net::TcpStream>,
167            >,
168        >,
169    ),
170}
171
172impl tokio::io::AsyncRead for MaybeTls {
173    fn poll_read(
174        mut self: Pin<&mut Self>,
175        cx: &mut Context<'_>,
176        buf: &mut tokio::io::ReadBuf<'_>,
177    ) -> Poll<std::io::Result<()>> {
178        match &mut *self {
179            MaybeTls::Plain(s) => Pin::new(s).poll_read(cx, buf),
180            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
181        }
182    }
183}
184
185impl tokio::io::AsyncWrite for MaybeTls {
186    fn poll_write(
187        mut self: Pin<&mut Self>,
188        cx: &mut Context<'_>,
189        buf: &[u8],
190    ) -> Poll<std::io::Result<usize>> {
191        match &mut *self {
192            MaybeTls::Plain(s) => Pin::new(s).poll_write(cx, buf),
193            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
194        }
195    }
196
197    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
198        match &mut *self {
199            MaybeTls::Plain(s) => Pin::new(s).poll_flush(cx),
200            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
201        }
202    }
203
204    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
205        match &mut *self {
206            MaybeTls::Plain(s) => Pin::new(s).poll_shutdown(cx),
207            MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
208        }
209    }
210}
211
212impl HyperConnection for MaybeTls {
213    fn connected(&self) -> Connected {
214        Connected::new()
215    }
216}
217
218/// Plain-or-TLS connector for one-shot OIDC HTTP calls (hyper-util
219/// legacy client plumbing; reuses the workspace rustls stack, no new
220/// HTTP dependency). Trust defaults to the webpki public roots; a
221/// provider's `trusted_ca_file` replaces them so an https IdP behind a
222/// private CA is reachable — the same trust model the JWKS connector
223/// gives `trusted_ca_file` JWT providers.
224#[derive(Clone)]
225struct OidcConnector {
226    http: hyper_util::client::legacy::connect::HttpConnector,
227    tls: tokio_rustls::TlsConnector,
228}
229
230impl OidcConnector {
231    /// `trusted_ca` is the provider's `trusted_ca_file` path when set.
232    /// Fails when the bundle cannot be loaded/parsed, so the provider is
233    /// disabled at build time (see `build_oidc_clients`) instead of
234    /// failing every request.
235    fn new(trusted_ca: Option<&str>) -> Result<Self, OidcError> {
236        let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
237        http.enforce_http(false); // scheme routing happens here
238        let roots = match trusted_ca {
239            Some(path) => crate::security::tls::root_store_from_pem_file(path).map_err(|e| {
240                OidcError::TlsConfig(format!("trusted_ca_file '{path}' could not be loaded: {e}"))
241            })?,
242            None => crate::security::tls::webpki_root_store(),
243        };
244        let cfg = crate::security::tls::https_h1_client_config(roots);
245        Ok(OidcConnector {
246            http,
247            tls: tokio_rustls::TlsConnector::from(Arc::new(cfg)),
248        })
249    }
250}
251
252impl Service<Uri> for OidcConnector {
253    type Response = TokioIo<MaybeTls>;
254    type Error = std::io::Error;
255    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
256
257    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
258        self.http.poll_ready(cx).map_err(std::io::Error::other)
259    }
260
261    fn call(&mut self, uri: Uri) -> Self::Future {
262        let mut http = self.http.clone();
263        let tls = self.tls.clone();
264        Box::pin(async move {
265            let host = uri
266                .host()
267                .ok_or_else(|| std::io::Error::other("oidc url has no host"))?
268                .to_string();
269            let stream = tokio::time::timeout(OIDC_CONNECT_TIMEOUT, http.call(uri.clone()))
270                .await
271                .map_err(|_| {
272                    std::io::Error::new(std::io::ErrorKind::TimedOut, "oidc connect timed out")
273                })?
274                .map_err(std::io::Error::other)?
275                .into_inner();
276            match uri.scheme_str() {
277                Some("https") => {
278                    let name = ServerName::try_from(host.clone()).map_err(|e| {
279                        std::io::Error::other(format!("oidc host is not a valid tls name: {e}"))
280                    })?;
281                    let tls_stream = tokio::time::timeout(
282                        OIDC_CONNECT_TIMEOUT,
283                        tls.connect(name, hyper_util::rt::tokio::WithHyperIo::new(stream)),
284                    )
285                    .await
286                    .map_err(|_| {
287                        std::io::Error::new(
288                            std::io::ErrorKind::TimedOut,
289                            "oidc tls handshake timed out",
290                        )
291                    })??;
292                    Ok(TokioIo::new(MaybeTls::Tls(Box::new(tls_stream))))
293                }
294                _ => Ok(TokioIo::new(MaybeTls::Plain(
295                    hyper_util::rt::tokio::WithHyperIo::new(stream),
296                ))),
297            }
298        })
299    }
300}
301
302/// The parsed OIDC discovery document (RFC 8414 / OIDC Discovery 1.0).
303/// Only the fields the gateway consumes are extracted; unknown fields
304/// are ignored (the document is forward-compatible by design).
305#[derive(Debug, Clone)]
306pub struct DiscoveryDocument {
307    /// The issuer identifier (`iss` claim of tokens from this provider).
308    pub issuer: String,
309    /// The JWKS URI (not used for introspection, but available for
310    /// future local JWT verification of ID tokens).
311    pub jwks_uri: Option<String>,
312    /// The introspection endpoint (RFC 7662).
313    pub introspection_endpoint: Option<String>,
314    /// The revocation endpoint (RFC 7009).
315    pub revocation_endpoint: Option<String>,
316    /// The authorization endpoint (RFC 6749 section 3.1).
317    pub authorization_endpoint: Option<String>,
318    /// The token endpoint (RFC 6749 section 3.2).
319    pub token_endpoint: Option<String>,
320}
321
322impl DiscoveryDocument {
323    /// Parse the discovery JSON, extracting the fields the gateway
324    /// consumes. Missing optional fields yield `None` (the provider may
325    /// not advertise every endpoint).
326    fn parse(body: &[u8]) -> Result<Self, OidcError> {
327        let json: serde_json::Value = serde_json::from_slice(body)
328            .map_err(|e| OidcError::MalformedResponse(format!("discovery is not JSON: {e}")))?;
329        let get = |k: &str| json.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
330        let issuer = get("issuer").ok_or_else(|| {
331            OidcError::MalformedResponse("discovery document has no string 'issuer'".into())
332        })?;
333        Ok(DiscoveryDocument {
334            issuer,
335            jwks_uri: get("jwks_uri"),
336            introspection_endpoint: get("introspection_endpoint"),
337            revocation_endpoint: get("revocation_endpoint"),
338            authorization_endpoint: get("authorization_endpoint"),
339            token_endpoint: get("token_endpoint"),
340        })
341    }
342}
343
344/// The parsed token introspection response (RFC 7662 section 2.2).
345/// `active` is the only REQUIRED field; the rest are optional and
346/// extracted as strings for the identity map.
347#[derive(Debug, Clone)]
348pub struct IntrospectionResponse {
349    /// Whether the token is currently active (RFC 7662 section 2.2).
350    pub active: bool,
351    /// The subject identifier (`sub` claim).
352    pub sub: Option<String>,
353    /// The human-readable username (OIDC `preferred_username` or
354    /// OAuth2 `username`).
355    pub username: Option<String>,
356    /// The scope string (space-delimited).
357    pub scope: Option<String>,
358    /// The issuer (`iss`) — checked against the provider's configured
359    /// issuer for defense against token confusion.
360    pub iss: Option<String>,
361    /// The client ID the token was issued to.
362    pub client_id: Option<String>,
363    /// Token expiration (Unix epoch seconds).
364    pub exp: Option<i64>,
365    /// Raw JSON (for extracting arbitrary claims into the identity map).
366    pub raw: serde_json::Value,
367}
368
369impl IntrospectionResponse {
370    /// Parse the introspection JSON. `active` defaults to false when
371    /// absent (RFC 7662 section 2.2: "If the introspection call is
372    /// properly authorized but the token is not active, does not exist
373    /// on this server, or is not authorized for introspection, then
374    /// the authorization server returns a response with `active`
375    /// false").
376    fn parse(body: &[u8]) -> Result<Self, OidcError> {
377        let raw: serde_json::Value = serde_json::from_slice(body)
378            .map_err(|e| OidcError::MalformedResponse(format!("introspection is not JSON: {e}")))?;
379        let active = raw.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
380        let get = |k: &str| raw.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
381        Ok(IntrospectionResponse {
382            active,
383            sub: get("sub"),
384            username: get("username").or_else(|| get("preferred_username")),
385            scope: get("scope"),
386            iss: get("iss"),
387            client_id: get("client_id"),
388            exp: raw.get("exp").and_then(|v| v.as_i64()),
389            raw,
390        })
391    }
392}
393
394/// One cached introspection result (DW-034): the parsed response and
395/// when it expires (the gateway's `Instant` clock).
396#[derive(Clone)]
397struct CachedIntrospection {
398    result: IntrospectionResponse,
399    expires_at: Instant,
400}
401
402impl CachedIntrospection {
403    fn is_fresh(&self) -> bool {
404        Instant::now() < self.expires_at
405    }
406}
407
408/// Per-provider introspection cache (DW-034): lives on the dataplane so
409/// a config reload reuses cached `active: true` results (the
410/// `jwks_caches` / `oauth2_token_cache` precedent). Entries are keyed
411/// by `{provider_name}:{sha256_hex(token)}`. Only `active: true`
412/// results are cached; `active: false` is re-checked on every request
413/// so a revoked token is noticed promptly.
414pub struct OidcIntrospectionCache {
415    inner: std::sync::Mutex<HashMap<String, CachedIntrospection>>,
416}
417
418impl OidcIntrospectionCache {
419    /// The empty cache.
420    pub fn new() -> Self {
421        OidcIntrospectionCache {
422            inner: std::sync::Mutex::new(HashMap::new()),
423        }
424    }
425
426    /// The cached `active: true` introspection for `provider` + `token`,
427    /// if any and still fresh. Returns `None` for expired or absent
428    /// entries (the caller re-introspects).
429    fn get(&self, provider: &str, token: &str) -> Option<IntrospectionResponse> {
430        let key = cache_key(provider, token);
431        let inner = self.inner.lock().expect("oidc cache poisoned");
432        let entry = inner.get(&key)?;
433        if entry.is_fresh() {
434            Some(entry.result.clone())
435        } else {
436            None
437        }
438    }
439
440    /// Store a freshly introspected `active: true` result. `active:
441    /// false` results are NOT stored (re-checked on the next request).
442    fn put(&self, provider: &str, token: &str, result: IntrospectionResponse, ttl: Duration) {
443        if !result.active {
444            return;
445        }
446        let key = cache_key(provider, token);
447        let entry = CachedIntrospection {
448            result,
449            expires_at: Instant::now() + ttl,
450        };
451        self.inner
452            .lock()
453            .expect("oidc cache poisoned")
454            .insert(key, entry);
455    }
456
457    /// Invalidate the cached entry for `provider` + `token` (called
458    /// after a revocation so the next request re-introspects).
459    pub fn invalidate(&self, provider: &str, token: &str) {
460        let key = cache_key(provider, token);
461        self.inner.lock().expect("oidc cache poisoned").remove(&key);
462    }
463}
464
465impl Default for OidcIntrospectionCache {
466    fn default() -> Self {
467        Self::new()
468    }
469}
470
471impl std::fmt::Debug for OidcIntrospectionCache {
472    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473        f.debug_struct("OidcIntrospectionCache")
474            .field("entries", &self.inner.lock().map(|m| m.len()).unwrap_or(0))
475            .finish_non_exhaustive()
476    }
477}
478
479/// The cache key: `{provider_name}:{sha256_hex(token)}`. The token is
480/// hashed so the plaintext never lives in the cache key (the
481/// selector-redaction precedent — a debug print of the cache must not
482/// leak tokens).
483fn cache_key(provider: &str, token: &str) -> String {
484    let mut hasher = Sha256::new();
485    hasher.update(token.as_bytes());
486    let digest = hasher.finalize();
487    format!("{}:{}", provider, hex_encode(&digest))
488}
489
490/// Lowercase hex encoding of a byte slice (the `sha256_hex` shape from
491/// `config::credentials`, inlined to avoid a cross-domain import for a
492/// trivial helper).
493fn hex_encode(bytes: &[u8]) -> String {
494    const HEX: &[u8; 16] = b"0123456789abcdef";
495    let mut out = String::with_capacity(bytes.len() * 2);
496    for b in bytes {
497        out.push(HEX[(b >> 4) as usize] as char);
498        out.push(HEX[(b & 0x0f) as usize] as char);
499    }
500    out
501}
502
503/// One configured OIDC provider at runtime: config + HTTP client +
504/// cached discovery document. The introspection cache lives on the
505/// dataplane ([`OidcIntrospectionCache`]) so a config reload reuses
506/// cached results.
507pub struct OidcClient {
508    cfg: OidcProvider,
509    http: Client<OidcConnector, Full<bytes::Bytes>>,
510    /// Cached discovery document (fetched lazily on the first request,
511    /// re-fetched after `DISCOVERY_FRESH_SECS`).
512    discovery: std::sync::Mutex<Option<(Arc<DiscoveryDocument>, Instant)>>,
513    /// Serializes discovery fetches so concurrent requests coalesce
514    /// into one discovery GET (the JWKS refresh-lock precedent).
515    discovery_lock: tokio::sync::Mutex<()>,
516}
517
518impl OidcClient {
519    /// Build the runtime client from config. The `trusted_ca_file` is
520    /// loaded here (at build time) so a broken bundle disables this
521    /// provider at build instead of failing every request.
522    pub fn build(cfg: OidcProvider) -> Result<Arc<Self>, OidcError> {
523        let connector = OidcConnector::new(cfg.trusted_ca_file.as_deref())?;
524        let mut builder = Client::builder(TokioExecutor::new());
525        builder.pool_timer(TokioTimer::new());
526        Ok(Arc::new(OidcClient {
527            cfg,
528            http: builder.build(connector),
529            discovery: std::sync::Mutex::new(None),
530            discovery_lock: tokio::sync::Mutex::new(()),
531        }))
532    }
533
534    /// The resolved config (for validation / introspection).
535    pub fn config(&self) -> &OidcProvider {
536        &self.cfg
537    }
538
539    /// The provider's name (a stable key for the introspection cache).
540    pub fn name(&self) -> &str {
541        &self.cfg.name
542    }
543
544    /// Fetch the discovery document from
545    /// `{issuer}/.well-known/openid-configuration`. The caller MUST
546    /// hold the discovery lock so concurrent fetches coalesce.
547    async fn fetch_discovery(&self) -> Result<Arc<DiscoveryDocument>, OidcError> {
548        let url = format!(
549            "{}/.well-known/openid-configuration",
550            self.cfg.issuer.trim_end_matches('/')
551        );
552        let uri: Uri = url.parse().map_err(|e| {
553            OidcError::InvalidEndpoint(format!("discovery url '{url}' is invalid: {e}"))
554        })?;
555        let req = Request::builder()
556            .method(Method::GET)
557            .uri(uri)
558            .body(Full::new(bytes::Bytes::new()))
559            .map_err(|e| OidcError::Fetch(format!("discovery request build failed: {e}")))?;
560        let resp = self
561            .http
562            .request(req)
563            .await
564            .map_err(|e| OidcError::Fetch(format!("discovery request failed: {e:#}")))?;
565        if !resp.status().is_success() {
566            return Err(OidcError::EndpointStatus(resp.status().as_u16()));
567        }
568        let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
569            .collect()
570            .await
571            .map_err(|e| OidcError::Fetch(format!("discovery body read failed: {e}")))?
572            .to_bytes();
573        let doc = DiscoveryDocument::parse(&body)?;
574        // Defense against a misconfigured or hostile IdP: the discovery
575        // document's `issuer` MUST match the configured issuer (OIDC
576        // Discovery 1.0 section 3). A mismatch is token-confusion bait.
577        if doc.issuer != self.cfg.issuer {
578            return Err(OidcError::MalformedResponse(format!(
579                "discovery issuer '{}' does not match configured issuer '{}'",
580                doc.issuer, self.cfg.issuer
581            )));
582        }
583        let doc = Arc::new(doc);
584        *self
585            .discovery
586            .lock()
587            .expect("oidc discovery cache poisoned") = Some((Arc::clone(&doc), Instant::now()));
588        Ok(doc)
589    }
590
591    /// The discovery document, fetching it on the first call and
592    /// re-fetching after [`DISCOVERY_FRESH_SECS`]. Concurrent callers
593    /// coalesce into one fetch (the discovery lock).
594    async fn discovery(&self) -> Result<Arc<DiscoveryDocument>, OidcError> {
595        // Fast path: a fresh cached document.
596        {
597            let guard = self
598                .discovery
599                .lock()
600                .expect("oidc discovery cache poisoned");
601            if let Some((doc, fetched_at)) = guard.as_ref() {
602                if fetched_at.elapsed().as_secs() < DISCOVERY_FRESH_SECS {
603                    return Ok(Arc::clone(doc));
604                }
605            }
606        }
607        // Slow path: serialize fetches. A concurrent caller that lost
608        // the race re-checks the cache after the lock (the winner's
609        // document is now cached).
610        let _guard = self.discovery_lock.lock().await;
611        {
612            let guard = self
613                .discovery
614                .lock()
615                .expect("oidc discovery cache poisoned");
616            if let Some((doc, fetched_at)) = guard.as_ref() {
617                if fetched_at.elapsed().as_secs() < DISCOVERY_FRESH_SECS {
618                    return Ok(Arc::clone(doc));
619                }
620            }
621        }
622        self.fetch_discovery().await
623    }
624
625    /// The introspection endpoint URL: the config override takes
626    /// precedence over the discovery-discovered value.
627    async fn introspection_endpoint(&self) -> Result<String, OidcError> {
628        if let Some(ep) = &self.cfg.introspection_endpoint {
629            return Ok(ep.clone());
630        }
631        let doc = self.discovery().await?;
632        doc.introspection_endpoint.clone().ok_or_else(|| {
633            OidcError::InvalidEndpoint(
634                "provider advertises no introspection endpoint and no override is configured"
635                    .into(),
636            )
637        })
638    }
639
640    /// The revocation endpoint URL: the config override takes
641    /// precedence over the discovery-discovered value.
642    async fn revocation_endpoint(&self) -> Result<String, OidcError> {
643        if let Some(ep) = &self.cfg.revocation_endpoint {
644            return Ok(ep.clone());
645        }
646        let doc = self.discovery().await?;
647        doc.revocation_endpoint.clone().ok_or_else(|| {
648            OidcError::InvalidEndpoint(
649                "provider advertises no revocation endpoint and no override is configured".into(),
650            )
651        })
652    }
653
654    /// The token endpoint URL (for token exchange and auth-code
655    /// exchange): from the discovery document.
656    async fn token_endpoint(&self) -> Result<String, OidcError> {
657        let doc = self.discovery().await?;
658        doc.token_endpoint.clone().ok_or_else(|| {
659            OidcError::InvalidEndpoint("provider advertises no token endpoint".into())
660        })
661    }
662
663    /// The authorization endpoint URL (for the auth-code flow): from
664    /// the discovery document.
665    pub async fn authorization_endpoint(&self) -> Result<String, OidcError> {
666        let doc = self.discovery().await?;
667        doc.authorization_endpoint.clone().ok_or_else(|| {
668            OidcError::InvalidEndpoint("provider advertises no authorization endpoint".into())
669        })
670    }
671
672    /// Resolve the client secret (a `${...}` reference or inline value).
673    /// The plaintext never leaves this call.
674    fn secret(&self) -> Result<String, OidcError> {
675        crate::config::credentials::resolve_configured_secret(&self.cfg.client_secret)
676            .map_err(|e| OidcError::Fetch(format!("client_secret unresolvable: {e}")))
677    }
678
679    /// HTTP Basic auth header value (`Basic base64(client_id:secret)`).
680    fn basic_auth(&self) -> Result<String, OidcError> {
681        let secret = self.secret()?;
682        Ok(format!(
683            "Basic {}",
684            BASE64.encode(format!("{}:{}", self.cfg.client_id, secret))
685        ))
686    }
687
688    /// Introspect a bearer token (RFC 7662). Returns the parsed
689    /// introspection response. The caller is responsible for caching
690    /// (via [`OidcIntrospectionCache`]) and fail-open/fail-closed
691    /// mapping. Client auth is HTTP Basic (RFC 7662 section 2.1);
692    /// the token is sent as `token` in the form-encoded body.
693    pub async fn introspect(
694        &self,
695        token: &str,
696        cache: &OidcIntrospectionCache,
697    ) -> Result<IntrospectionResponse, OidcError> {
698        // Fast path: a cached `active: true` result.
699        if let Some(cached) = cache.get(&self.cfg.name, token) {
700            return Ok(cached);
701        }
702        let endpoint = self.introspection_endpoint().await?;
703        let basic = self.basic_auth()?;
704        let body = format!("token={}", &urlencode(token));
705        let req = Request::builder()
706            .method(Method::POST)
707            .uri(endpoint.parse::<Uri>().map_err(|e| {
708                OidcError::InvalidEndpoint(format!("introspection endpoint is invalid: {e}"))
709            })?)
710            .header(hyper::header::AUTHORIZATION, basic)
711            .header(
712                hyper::header::CONTENT_TYPE,
713                "application/x-www-form-urlencoded",
714            )
715            .body(Full::new(bytes::Bytes::from(body)))
716            .map_err(|e| OidcError::Fetch(format!("introspection request build failed: {e}")))?;
717        let resp = self
718            .http
719            .request(req)
720            .await
721            .map_err(|e| OidcError::Fetch(format!("introspection request failed: {e:#}")))?;
722        if !resp.status().is_success() {
723            return Err(OidcError::EndpointStatus(resp.status().as_u16()));
724        }
725        let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
726            .collect()
727            .await
728            .map_err(|e| OidcError::Fetch(format!("introspection body read failed: {e}")))?
729            .to_bytes();
730        let result = IntrospectionResponse::parse(&body)?;
731        if !result.active {
732            return Err(OidcError::Inactive);
733        }
734        // Defense against token confusion: when the introspection result
735        // carries an `iss`, it MUST match the configured issuer. A
736        // mismatched issuer means the token was issued by a different
737        // IdP than the one this provider is configured for.
738        if let Some(iss) = &result.iss {
739            if iss != &self.cfg.issuer {
740                return Err(OidcError::MalformedResponse(format!(
741                    "introspection issuer '{iss}' does not match configured issuer '{}'",
742                    self.cfg.issuer
743                )));
744            }
745        }
746        // Cache the active result.
747        cache.put(
748            &self.cfg.name,
749            token,
750            result.clone(),
751            Duration::from_secs(self.cfg.introspection_cache_ttl_s),
752        );
753        Ok(result)
754    }
755
756    /// Revoke a token (RFC 7009). POST to the revocation endpoint with
757    /// the token. This is an admin/management operation, not on the hot
758    /// request path. The introspection cache entry for this token is
759    /// invalidated so the next request re-introspects (or fails 401).
760    /// Returns `Ok(())` on a 2xx response; the RFC notes a 200 is the
761    /// success response and a 400 may indicate an unrecognized token
762    /// (treated as an error here so the caller can report it).
763    pub async fn revoke(
764        &self,
765        token: &str,
766        cache: &OidcIntrospectionCache,
767    ) -> Result<(), OidcError> {
768        let endpoint = self.revocation_endpoint().await?;
769        let basic = self.basic_auth()?;
770        let body = format!("token={}", urlencode(token));
771        let req = Request::builder()
772            .method(Method::POST)
773            .uri(endpoint.parse::<Uri>().map_err(|e| {
774                OidcError::InvalidEndpoint(format!("revocation endpoint is invalid: {e}"))
775            })?)
776            .header(hyper::header::AUTHORIZATION, basic)
777            .header(
778                hyper::header::CONTENT_TYPE,
779                "application/x-www-form-urlencoded",
780            )
781            .body(Full::new(bytes::Bytes::from(body)))
782            .map_err(|e| OidcError::Fetch(format!("revocation request build failed: {e}")))?;
783        let resp = self
784            .http
785            .request(req)
786            .await
787            .map_err(|e| OidcError::Fetch(format!("revocation request failed: {e:#}")))?;
788        let status = resp.status();
789        // Drain the body so the connection can be reused.
790        let _ = Limited::new(resp.into_body(), OIDC_BODY_CAP)
791            .collect()
792            .await;
793        if !status.is_success() {
794            return Err(OidcError::EndpointStatus(status.as_u16()));
795        }
796        cache.invalidate(&self.cfg.name, token);
797        Ok(())
798    }
799
800    /// Token exchange (RFC 8693): exchange a subject token for an
801    /// actor token. The subject token is the client's bearer token
802    /// (`subject_token`); the actor token is what the gateway forwards
803    /// to the upstream. `audience` is the resource server the actor
804    /// token is intended for (RFC 8693 section 2.1). Returns the actor
805    /// token string on success.
806    pub async fn exchange_token(
807        &self,
808        subject_token: &str,
809        audience: &str,
810    ) -> Result<String, OidcError> {
811        let endpoint = self.token_endpoint().await?;
812        let basic = self.basic_auth()?;
813        // RFC 8693 section 2.1: grant_type, subject_token,
814        // subject_token_type, audience (requested), and optionally
815        // scope. The subject token is an access token
816        // (urn:ietf:params:oauth:token-type:access_token); the actor
817        // token is requested as an access token too.
818        let mut body = format!(
819            "grant_type=urn:ietf:params:oauth:grant-type:token-exchange\
820             &subject_token={}&subject_token_type=urn:ietf:params:oauth:token-type:access_token\
821             &audience={}",
822            urlencode(subject_token),
823            urlencode(audience)
824        );
825        if !self.cfg.scopes.is_empty() {
826            body.push_str("&scope=");
827            body.push_str(&urlencode(&self.cfg.scopes.join(" ")));
828        }
829        let req = Request::builder()
830            .method(Method::POST)
831            .uri(endpoint.parse::<Uri>().map_err(|e| {
832                OidcError::InvalidEndpoint(format!("token endpoint is invalid: {e}"))
833            })?)
834            .header(hyper::header::AUTHORIZATION, basic)
835            .header(
836                hyper::header::CONTENT_TYPE,
837                "application/x-www-form-urlencoded",
838            )
839            .body(Full::new(bytes::Bytes::from(body)))
840            .map_err(|e| OidcError::Fetch(format!("token exchange request build failed: {e}")))?;
841        let resp = self
842            .http
843            .request(req)
844            .await
845            .map_err(|e| OidcError::Fetch(format!("token exchange request failed: {e:#}")))?;
846        if !resp.status().is_success() {
847            return Err(OidcError::EndpointStatus(resp.status().as_u16()));
848        }
849        let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
850            .collect()
851            .await
852            .map_err(|e| OidcError::Fetch(format!("token exchange body read failed: {e}")))?
853            .to_bytes();
854        let json: serde_json::Value = serde_json::from_slice(&body).map_err(|e| {
855            OidcError::MalformedResponse(format!("token exchange is not JSON: {e}"))
856        })?;
857        let token = json
858            .get("access_token")
859            .and_then(|v| v.as_str())
860            .ok_or_else(|| {
861                OidcError::MalformedResponse("token exchange response missing access_token".into())
862            })?
863            .to_string();
864        if token.is_empty() {
865            return Err(OidcError::MalformedResponse(
866                "token exchange access_token is empty".into(),
867            ));
868        }
869        Ok(token)
870    }
871
872    /// Exchange an authorization code for tokens (RFC 6749 section 4.1,
873    /// the auth-code + PKCE flow). `code` is the authorization code
874    /// from the IdP's callback redirect; `redirect_uri` is the
875    /// gateway's callback URL; `code_verifier` is the PKCE verifier
876    /// matching the challenge sent in the authorization request. Returns
877    /// the parsed token response (access, refresh, ID token, etc.).
878    pub async fn exchange_code(
879        &self,
880        code: &str,
881        redirect_uri: &str,
882        code_verifier: &str,
883    ) -> Result<TokenSet, OidcError> {
884        let endpoint = self.token_endpoint().await?;
885        let basic = self.basic_auth()?;
886        let body = format!(
887            "grant_type=authorization_code\
888             &code={}&redirect_uri={}&code_verifier={}",
889            urlencode(code),
890            urlencode(redirect_uri),
891            urlencode(code_verifier)
892        );
893        let req = Request::builder()
894            .method(Method::POST)
895            .uri(endpoint.parse::<Uri>().map_err(|e| {
896                OidcError::InvalidEndpoint(format!("token endpoint is invalid: {e}"))
897            })?)
898            .header(hyper::header::AUTHORIZATION, basic)
899            .header(
900                hyper::header::CONTENT_TYPE,
901                "application/x-www-form-urlencoded",
902            )
903            .body(Full::new(bytes::Bytes::from(body)))
904            .map_err(|e| {
905                OidcError::Fetch(format!("auth-code exchange request build failed: {e}"))
906            })?;
907        let resp =
908            self.http.request(req).await.map_err(|e| {
909                OidcError::Fetch(format!("auth-code exchange request failed: {e:#}"))
910            })?;
911        if !resp.status().is_success() {
912            return Err(OidcError::EndpointStatus(resp.status().as_u16()));
913        }
914        let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
915            .collect()
916            .await
917            .map_err(|e| OidcError::Fetch(format!("auth-code exchange body read failed: {e}")))?
918            .to_bytes();
919        TokenSet::parse(&body)
920    }
921
922    /// Build the authorization request URL for the auth-code + PKCE
923    /// flow (RFC 6749 section 4.1.1 + RFC 7636). The gateway redirects
924    /// the user agent to this URL; the IdP authenticates the user and
925    /// redirects back to `redirect_uri` with an authorization code.
926    /// `state` is an opaque value the gateway round-trips for CSRF
927    /// protection; `code_challenge` is the PKCE challenge derived from
928    /// `code_verifier` (S256: `base64url(sha256(verifier))`).
929    pub async fn authorization_url(
930        &self,
931        redirect_uri: &str,
932        state: &str,
933        code_challenge: &str,
934    ) -> Result<String, OidcError> {
935        let endpoint = self.authorization_endpoint().await?;
936        let mut params = vec![
937            ("response_type", "code"),
938            ("client_id", self.cfg.client_id.as_str()),
939            ("redirect_uri", redirect_uri),
940            ("state", state),
941            ("code_challenge", code_challenge),
942            ("code_challenge_method", "S256"),
943        ];
944        let scope = self.cfg.scopes.join(" ");
945        if !scope.is_empty() {
946            params.push(("scope", scope.as_str()));
947        }
948        let query = params
949            .iter()
950            .map(|(k, v)| format!("{}={}", urlencode(k), urlencode(v)))
951            .collect::<Vec<_>>()
952            .join("&");
953        Ok(format!("{endpoint}?{query}"))
954    }
955}
956
957impl std::fmt::Debug for OidcClient {
958    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
959        f.debug_struct("OidcClient")
960            .field("name", &self.cfg.name)
961            .field("issuer", &self.cfg.issuer)
962            .field("client_id", &self.cfg.client_id)
963            .field("client_secret", &"[redacted]")
964            .field("scopes", &self.cfg.scopes)
965            .field(
966                "introspection_cache_ttl_s",
967                &self.cfg.introspection_cache_ttl_s,
968            )
969            .field("fail_open", &self.cfg.fail_open)
970            .finish_non_exhaustive()
971    }
972}
973
974/// The token set returned by an auth-code exchange (RFC 6749 section
975/// 5.1 + OIDC `id_token`). All fields except `access_token` are
976/// optional.
977#[derive(Debug, Clone)]
978pub struct TokenSet {
979    /// The access token (always present in a successful response).
980    pub access_token: String,
981    /// The token type (typically "Bearer").
982    pub token_type: Option<String>,
983    /// The refresh token (optional; for offline access).
984    pub refresh_token: Option<String>,
985    /// The ID token (OIDC; optional when the `openid` scope was not
986    /// requested).
987    pub id_token: Option<String>,
988    /// Lifetime in seconds.
989    pub expires_in: Option<u64>,
990    /// The scope granted (may differ from requested).
991    pub scope: Option<String>,
992}
993
994impl TokenSet {
995    /// Parse a token response JSON body.
996    fn parse(body: &[u8]) -> Result<Self, OidcError> {
997        let json: serde_json::Value = serde_json::from_slice(body).map_err(|e| {
998            OidcError::MalformedResponse(format!("token response is not JSON: {e}"))
999        })?;
1000        let get = |k: &str| json.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
1001        let access_token = get("access_token").ok_or_else(|| {
1002            OidcError::MalformedResponse("token response missing access_token".into())
1003        })?;
1004        if access_token.is_empty() {
1005            return Err(OidcError::MalformedResponse("access_token is empty".into()));
1006        }
1007        Ok(TokenSet {
1008            access_token,
1009            token_type: get("token_type"),
1010            refresh_token: get("refresh_token"),
1011            id_token: get("id_token"),
1012            expires_in: json.get("expires_in").and_then(|v| v.as_u64()),
1013            scope: get("scope"),
1014        })
1015    }
1016}
1017
1018/// Percent-encode a string for `application/x-www-form-urlencoded`
1019/// bodies (RFC 3986 unreserved characters kept; everything else
1020/// percent-encoded). Inlined to avoid a new dependency — the body
1021/// values are tokens, codes, and URLs whose characters are almost
1022/// entirely unreserved, so a minimal encoder is sufficient and correct.
1023fn urlencode(input: &str) -> String {
1024    const HEX: &[u8; 16] = b"0123456789ABCDEF";
1025    let mut out = String::with_capacity(input.len());
1026    for &b in input.as_bytes() {
1027        // RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
1028        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1029            out.push(b as char);
1030        } else {
1031            out.push('%');
1032            out.push(HEX[(b >> 4) as usize] as char);
1033            out.push(HEX[(b & 0x0f) as usize] as char);
1034        }
1035    }
1036    out
1037}
1038
1039/// Generate a PKCE code verifier (43..=128 random ASCII characters per
1040/// RFC 7636 section 4.1). Returns the verifier; the challenge is
1041/// `base64url(sha256(verifier))` (S256 method). The verifier MUST be
1042/// sent in the token exchange; the challenge is sent in the
1043/// authorization request. Uses a caller-provided 32-byte random seed
1044/// (the gateway does not pull in a crypto RNG crate; tests pass a
1045/// fixed seed for determinism, production callers use the OS RNG).
1046pub fn pkce_code_verifier(seed: &[u8; 32]) -> String {
1047    // RFC 7636 section 4.1: 43..=128 chars from the unreserved set
1048    // [A-Z][a-z][0-9]-._~. A 32-byte seed base64url-encoded yields 43
1049    // chars (the minimum), which is within the allowed range.
1050    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(seed)
1051}
1052
1053/// Derive the PKCE code challenge (S256) from a verifier: the
1054/// `base64url(sha256(verifier))` per RFC 7636 section 4.2.
1055pub fn pkce_code_challenge(verifier: &str) -> String {
1056    let mut hasher = Sha256::new();
1057    hasher.update(verifier.as_bytes());
1058    let digest = hasher.finalize();
1059    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
1060}