Skip to main content

featherbit/plugins/native/
openid_connect.rs

1//! OpenID Connect authentication plugin (`openid-connect`).
2//!
3//! Two modes, selected by `bearer_only`:
4//!
5//! - **Resource-server / bearer mode** (`bearer_only: true`, the default):
6//!   validates an OAuth2 / OIDC **access token** presented as a `Bearer` token
7//!   in the `Authorization` header and, on success, exposes the token claims to
8//!   downstream nodes via `context.message`. Validation is either local JWT
9//!   verification against the provider's JWKS (by `kid`, cached with a TTL,
10//!   refetched once on an unknown `kid`) or RFC 7662 token introspection.
11//!
12//! - **Interactive login** (`bearer_only: false`): the full **Authorization
13//!   Code flow with PKCE**. An unauthenticated browser is redirected to the
14//!   identity provider; the provider redirects back to `redirect_uri` with a
15//!   code; the plugin exchanges it for tokens, validates the `id_token`, and
16//!   seals the resulting claims into an **encrypted client-side session
17//!   cookie** (see [`crate::plugins::util::cookie_session`]). Subsequent
18//!   requests carrying a valid session cookie are let through with the claims
19//!   attached. No server-side session store is needed, so this works across a
20//!   horizontally-scaled deployment as long as every instance shares
21//!   `session.secret`.
22//!
23//! # Flow wiring (interactive mode)
24//!
25//! In interactive mode the node exits through the dedicated **`redirect`**
26//! port whenever the browser must move (the 302 to the IdP, the post-callback
27//! 302 back to the original URL, or a logout redirect) — wire `redirect` to
28//! `client.in`. Deliberate rejections (missing/invalid flow cookie, CSRF
29//! `state` mismatch, an invalid `id_token`, or a nonce mismatch) exit through
30//! **`denied`** — wire it to `client.in` too, or a custom denial handler.
31//! Genuine provider failures (discovery, JWKS, or token-endpoint callouts that
32//! transport-fail, return a non-2xx status, or hand back unparseable data)
33//! exit through the ordinary **`error`** port, since the node could not do its
34//! job. Only a request that arrives with a valid session cookie continues out
35//! **`success`** toward the upstream. The node must be on a route whose match
36//! rule also covers the `redirect_uri` path so the callback reaches it.
37//!
38//! # Deviations from APISIX
39//!
40//! - **No server-side session revocation.** Sessions live entirely in the
41//!   encrypted cookie, so a session cannot be invalidated before its
42//!   `session.cookie.lifetime` expiry without a shared denylist (a future
43//!   feature). Use short lifetimes. This is the standard client-side-cookie
44//!   trade-off APISIX shares when configured for cookie sessions.
45//! - **Token refresh, redis mode only.** When `session.storage: redis`, the
46//!   callback captures the token response's `refresh_token`/`expires_in`
47//!   alongside the session; a read that finds the access token within 30s of
48//!   `expires_at` transparently refreshes it at the token endpoint before
49//!   attaching identity, coordinated across concurrent requests via the
50//!   store's short-lived lock (`SessionStore::try_lock`/`unlock`) so only one
51//!   request per session performs the callout — losers re-read the
52//!   (usually already-refreshed) session instead of also calling the IdP.
53//!   An id_token in the refresh response is re-validated and its claims
54//!   replace the session's; an IdP-side refresh failure (unreachable,
55//!   non-2xx, invalid id_token) is not a store outage, so it falls back to
56//!   a fresh login rather than a 503. Set `session.refresh: false` to
57//!   disable (default `true`). Cookie-mode sessions have no server-side
58//!   coordination point for this, so they keep the original behavior: when
59//!   the session cookie expires the user re-authenticates (a fresh, fast
60//!   redirect round-trip if the IdP session is still valid).
61//! - Only the Authorization Code grant is implemented (the OIDC gateway case);
62//!   implicit/hybrid flows are not.
63
64use async_trait::async_trait;
65use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
66use base64::engine::general_purpose::URL_SAFE_NO_PAD;
67use base64::Engine;
68use bytes::Bytes;
69use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
70use ring::digest::{digest, SHA256};
71use ring::rand::{SecureRandom, SystemRandom};
72use serde::{Deserialize, Serialize};
73use std::collections::HashMap;
74use std::ops::ControlFlow;
75use std::sync::Arc;
76use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
77use tokio::sync::Mutex;
78
79use crate::context::{Context, GatewayError};
80use crate::outbound::{OutboundRequest, OutboundResponse};
81use crate::plugins::resources::PluginResources;
82use crate::plugins::util::cookie_session::{
83    build_set_cookie, delete_cookie, path_covers, read_cookie, CookieAttrs, CookieSealer, SameSite,
84};
85use crate::plugins::util::server_session::{self, SessionBackend};
86use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
87use crate::sessions::{SessionId, StoreError};
88
89/// Transient state carried in the short-lived flow cookie across the redirect
90/// to the IdP and back to the callback (CSRF `state`, replay `nonce`, PKCE
91/// `verifier`, and where to send the browser after login).
92#[derive(Serialize, Deserialize)]
93struct FlowState {
94    state: String,
95    nonce: String,
96    verifier: String,
97    original_uri: String,
98}
99
100/// The sealed session payload: the validated identity, kept small.
101///
102/// `refresh_token`/`expires_at` are populated ONLY in redis mode (see
103/// [`OpenidConnectPlugin::handle_callback`]) — cookie-mode sessions have no
104/// server-side coordination point for a lock-guarded refresh, so those
105/// fields always stay `None` there and no refresh is ever attempted.
106#[derive(Serialize, Deserialize)]
107struct SessionData {
108    claims: serde_json::Value,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    access_token: Option<String>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    refresh_token: Option<String>,
113    /// Access-token expiry, epoch seconds.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    expires_at: Option<u64>,
116}
117
118/// Interactive-mode configuration, present only when `bearer_only: false`.
119struct Interactive {
120    sealer: CookieSealer,
121    backend: SessionBackend,
122    authorization_endpoint_cfg: Option<String>,
123    token_endpoint_cfg: Option<String>,
124    redirect_uri: String,
125    /// Path portion of `redirect_uri`, matched to detect the callback.
126    redirect_path: String,
127    scope: String,
128    session_cookie: String,
129    flow_cookie: String,
130    /// `Path` attribute for the session and flow cookies. Scoping this to the
131    /// app's subpath (e.g. `/app_a`) lets two openid-connect nodes on distinct
132    /// subpaths hold independent sessions in the browser. Defaults to `/`.
133    cookie_path: String,
134    session_lifetime: Duration,
135    /// `session.refresh` (default `true`): redis-mode only — whether a
136    /// near-expiry access token is transparently refreshed before identity
137    /// is attached. Ignored in cookie mode, which never refreshes.
138    refresh_enabled: bool,
139    logout_path: Option<String>,
140    post_logout_redirect_uri: String,
141    authz_endpoint_resolved: Mutex<Option<String>>,
142    token_endpoint_resolved: Mutex<Option<String>>,
143}
144
145/// A single JSON Web Key from a provider's JWKS document.
146#[derive(Debug, Clone, Deserialize)]
147// Mirrors the JWK spec; some members are deserialized for completeness but not
148// consulted during verification.
149#[allow(dead_code)]
150struct Jwk {
151    kty: String,
152    kid: Option<String>,
153    alg: Option<String>,
154    // RSA
155    n: Option<String>,
156    e: Option<String>,
157    // EC
158    x: Option<String>,
159    y: Option<String>,
160    crv: Option<String>,
161}
162
163/// A JWKS document (`{ "keys": [ ... ] }`).
164#[derive(Debug, Clone, Deserialize)]
165struct JwkSet {
166    keys: Vec<Jwk>,
167}
168
169/// Cached JWKS with the time it was fetched, for TTL-based expiry.
170struct CachedJwks {
171    keys: Vec<Jwk>,
172    fetched_at: Instant,
173}
174
175/// Distinguishes a genuine provider/infrastructure failure (discovery, JWKS,
176/// or introspection endpoint unreachable, non-2xx, or unparseable) from the
177/// presented token being deliberately invalid (bad signature, unknown `kid`,
178/// wrong issuer/audience, expired, or inactive). `Infra` exits through the
179/// node's `error` port — the node could not do its job; `Denied` exits
180/// through `denied` — the node did its job and the token was rejected.
181#[derive(Debug)]
182enum TokenError {
183    Infra(String),
184    Denied(String),
185}
186
187/// Outcome of a redis-mode refresh attempt ([`OpenidConnectPlugin::do_refresh`]).
188/// `ReAuth` (IdP unreachable/errored, or the refreshed id_token failing
189/// validation) is not a store outage — the caller falls back to re-login,
190/// never a 503. `Store` is a genuine session-store failure and maps to
191/// [`OpenidConnectPlugin::store_error`] (503) same as everywhere else.
192#[derive(Debug)]
193enum RefreshFailure {
194    ReAuth(String),
195    Store(StoreError),
196}
197
198/// Authenticates requests by validating a bearer access token via JWKS
199/// signature verification or token introspection.
200pub struct OpenidConnectPlugin {
201    /// Well-known discovery URL; resolves `jwks_uri` when not given directly.
202    discovery: Option<String>,
203    /// Explicit JWKS endpoint (takes precedence over discovery).
204    jwks_uri_cfg: Option<String>,
205    /// Introspection endpoint; used only when no JWKS source is configured.
206    introspection_endpoint: Option<String>,
207    client_id: Option<String>,
208    client_secret: Option<String>,
209    ssl_verify: bool,
210    timeout: Duration,
211    /// Signature algorithms the token is allowed to be signed with.
212    allowed_algs: Vec<Algorithm>,
213    /// Issuers accepted for the `iss` claim; empty = do not validate issuer.
214    valid_issuers: Vec<String>,
215    audience_claim: String,
216    audience_required: bool,
217    audience_match_client_id: bool,
218    set_userinfo_header: bool,
219    set_access_token_header: bool,
220    access_token_in_authorization_header: bool,
221    /// True when a JWKS source (discovery/jwks_uri) is configured.
222    use_jwks: bool,
223    jwk_ttl: Duration,
224    resources: Arc<PluginResources>,
225    /// Lazily-resolved JWKS URI (from discovery), cached for the process.
226    jwks_uri_resolved: Mutex<Option<String>>,
227    jwks_cache: Mutex<Option<CachedJwks>>,
228    /// Interactive login flow; `None` in bearer-only mode.
229    interactive: Option<Interactive>,
230}
231
232impl OpenidConnectPlugin {
233    /// Builds the plugin from node config (bearer-only subset).
234    ///
235    /// Accepted keys:
236    /// - `discovery` (string): OIDC discovery URL
237    ///   (`.../.well-known/openid-configuration`); used to resolve `jwks_uri`.
238    /// - `jwks_uri` (string): explicit JWKS endpoint; takes precedence over
239    ///   `discovery` for signature verification.
240    /// - `introspection_endpoint` (string): RFC 7662 introspection endpoint;
241    ///   used only when no JWKS source is configured.
242    /// - `client_id` (string): OAuth client id (introspection auth / audience).
243    /// - `client_secret` (string): OAuth client secret (introspection auth).
244    /// - `bearer_only` (bool, default `true`): **must be true**. `false` is
245    ///   rejected at load — interactive login is not supported (see module docs).
246    /// - `token_signing_alg_values_expected` (string or array): permitted
247    ///   signature algorithms (e.g. `RS256`, `ES256`). Defaults to
248    ///   `RS256, RS384, RS512, ES256, ES384`.
249    /// - `claim_validator.issuer.valid_issuers` (array): accepted `iss` values.
250    /// - `claim_validator.audience.{claim,required,match_with_client_id}`:
251    ///   audience validation (claim defaults to `aud`).
252    /// - `set_userinfo_header` (bool, default `true`): base64-encode the claims
253    ///   into the `X-Userinfo` request header for the upstream.
254    /// - `set_access_token_header` (bool, default `true`) /
255    ///   `access_token_in_authorization_header` (bool, default `false`):
256    ///   forward the validated access token as `X-Access-Token` (or leave it in
257    ///   `Authorization`).
258    /// - `ssl_verify` (bool, default `true`), `timeout` (integer seconds,
259    ///   default `3`).
260    ///
261    /// Rejected at load: `bearer_only: false`, and configs with neither a JWKS
262    /// source (`discovery`/`jwks_uri`) nor an `introspection_endpoint`.
263    ///
264    /// ```yaml
265    /// type: openid-connect
266    /// config:
267    ///   discovery: https://idp.example.com/.well-known/openid-configuration
268    ///   bearer_only: true
269    ///   client_id: my-api
270    ///   token_signing_alg_values_expected: RS256
271    ///   claim_validator:
272    ///     issuer:
273    ///       valid_issuers: ["https://idp.example.com/"]
274    ///     audience:
275    ///       required: true
276    ///       match_with_client_id: true
277    /// ```
278    pub fn from_config(
279        config: &HashMap<String, serde_json::Value>,
280        resources: &Arc<PluginResources>,
281    ) -> Result<Self, String> {
282        let bearer_only = config
283            .get("bearer_only")
284            .and_then(|v| v.as_bool())
285            .unwrap_or(true);
286
287        let discovery = string_opt(config, "discovery");
288        let jwks_uri_cfg = string_opt(config, "jwks_uri");
289        let introspection_endpoint = string_opt(config, "introspection_endpoint");
290
291        let use_jwks = discovery.is_some() || jwks_uri_cfg.is_some();
292        if !use_jwks && introspection_endpoint.is_none() {
293            return Err(
294                "openid-connect: requires a JWKS source ('discovery' or 'jwks_uri') \
295                 or an 'introspection_endpoint'"
296                    .to_string(),
297            );
298        }
299
300        let client_id = string_opt(config, "client_id");
301        let client_secret = string_opt(config, "client_secret");
302
303        // Introspection needs client credentials to authenticate the call.
304        if !use_jwks && (client_id.is_none() || client_secret.is_none()) {
305            return Err(
306                "openid-connect: introspection requires 'client_id' and 'client_secret'"
307                    .to_string(),
308            );
309        }
310
311        // Interactive login (bearer_only: false) needs the Authorization Code
312        // machinery: a session secret, client credentials, a redirect URI, and
313        // token/authorization endpoints (via discovery or explicit config). The
314        // id_token is validated with the same JWKS path as bearer mode.
315        let interactive = if bearer_only {
316            None
317        } else {
318            if !use_jwks {
319                return Err("openid-connect: interactive login requires a JWKS source \
320                            ('discovery' or 'jwks_uri') to validate the id_token"
321                    .to_string());
322            }
323            if client_id.is_none() || client_secret.is_none() {
324                return Err(
325                    "openid-connect: interactive login requires 'client_id' and \
326                            'client_secret'"
327                        .to_string(),
328                );
329            }
330            Some(build_interactive(config, &discovery, resources)?)
331        };
332
333        let allowed_algs = parse_allowed_algs(config.get("token_signing_alg_values_expected"))?;
334
335        let valid_issuers = read_valid_issuers(config);
336        let (audience_claim, audience_required, audience_match_client_id) =
337            read_audience_cfg(config);
338
339        let set_userinfo_header = config
340            .get("set_userinfo_header")
341            .and_then(|v| v.as_bool())
342            .unwrap_or(true);
343        let set_access_token_header = config
344            .get("set_access_token_header")
345            .and_then(|v| v.as_bool())
346            .unwrap_or(true);
347        let access_token_in_authorization_header = config
348            .get("access_token_in_authorization_header")
349            .and_then(|v| v.as_bool())
350            .unwrap_or(false);
351
352        let ssl_verify = config
353            .get("ssl_verify")
354            .and_then(|v| v.as_bool())
355            .unwrap_or(true);
356        let timeout = Duration::from_secs(
357            config
358                .get("timeout")
359                .and_then(|v| v.as_u64())
360                .unwrap_or(3)
361                .max(1),
362        );
363        let jwk_ttl = Duration::from_secs(
364            config
365                .get("jwk_expires_in")
366                .and_then(|v| v.as_u64())
367                .unwrap_or(86400),
368        );
369
370        Ok(Self {
371            discovery,
372            jwks_uri_cfg,
373            introspection_endpoint,
374            client_id,
375            client_secret,
376            ssl_verify,
377            timeout,
378            allowed_algs,
379            valid_issuers,
380            audience_claim,
381            audience_required,
382            audience_match_client_id,
383            set_userinfo_header,
384            set_access_token_header,
385            access_token_in_authorization_header,
386            use_jwks,
387            jwk_ttl,
388            resources: resources.clone(),
389            jwks_uri_resolved: Mutex::new(None),
390            jwks_cache: Mutex::new(None),
391            interactive,
392        })
393    }
394
395    /// Resolves the JWKS URI, fetching the discovery document once if needed.
396    /// Every failure here is a genuine provider/infra problem, not the
397    /// caller's token being bad, so it always classifies as [`TokenError::Infra`].
398    async fn jwks_uri(&self) -> Result<String, TokenError> {
399        if let Some(uri) = &self.jwks_uri_cfg {
400            return Ok(uri.clone());
401        }
402        {
403            let cached = self.jwks_uri_resolved.lock().await;
404            if let Some(uri) = cached.as_ref() {
405                return Ok(uri.clone());
406            }
407        }
408        let discovery = self
409            .discovery
410            .as_ref()
411            .ok_or_else(|| TokenError::Infra("no discovery URL configured".to_string()))?;
412        let resp = self
413            .get(discovery)
414            .await
415            .map_err(|e| TokenError::Infra(format!("discovery fetch failed: {}", e)))?;
416        if resp.status != 200 {
417            return Err(TokenError::Infra(format!(
418                "discovery returned status {}",
419                resp.status
420            )));
421        }
422        let doc: serde_json::Value = serde_json::from_slice(&resp.body)
423            .map_err(|e| TokenError::Infra(format!("failed to parse discovery doc: {}", e)))?;
424        let uri = doc
425            .get("jwks_uri")
426            .and_then(|v| v.as_str())
427            .ok_or_else(|| TokenError::Infra("discovery doc missing jwks_uri".to_string()))?
428            .to_string();
429        *self.jwks_uri_resolved.lock().await = Some(uri.clone());
430        Ok(uri)
431    }
432
433    /// Returns the current JWKS, fetching/refreshing when stale or when
434    /// `force` is set (used on an unknown `kid`). Every failure here is a
435    /// genuine provider/infra problem, so it always classifies as
436    /// [`TokenError::Infra`].
437    async fn get_jwks(&self, force: bool) -> Result<Vec<Jwk>, TokenError> {
438        let mut cache = self.jwks_cache.lock().await;
439        if !force {
440            if let Some(c) = cache.as_ref() {
441                if c.fetched_at.elapsed() < self.jwk_ttl {
442                    return Ok(c.keys.clone());
443                }
444            }
445        }
446        let uri = self.jwks_uri().await?;
447        let resp = self
448            .get(&uri)
449            .await
450            .map_err(|e| TokenError::Infra(format!("JWKS fetch failed: {}", e)))?;
451        if resp.status != 200 {
452            return Err(TokenError::Infra(format!(
453                "JWKS endpoint returned status {}",
454                resp.status
455            )));
456        }
457        let set: JwkSet = serde_json::from_slice(&resp.body)
458            .map_err(|e| TokenError::Infra(format!("failed to parse JWKS: {}", e)))?;
459        *cache = Some(CachedJwks {
460            keys: set.keys.clone(),
461            fetched_at: Instant::now(),
462        });
463        Ok(set.keys)
464    }
465
466    /// Convenience GET through the shared outbound client.
467    async fn get(&self, url: &str) -> Result<OutboundResponse, crate::outbound::OutboundError> {
468        let req = OutboundRequest {
469            method: http::Method::GET,
470            url: url.to_string(),
471            headers: Vec::new(),
472            body: Bytes::new(),
473            timeout: self.timeout,
474            ssl_verify: self.ssl_verify,
475            tls: None,
476        };
477        self.resources.outbound.request(req).await
478    }
479
480    /// Validates the token via JWKS signature verification plus claim checks.
481    /// Provider/JWKS callout trouble classifies as [`TokenError::Infra`]; the
482    /// token itself being malformed, unsigned by a known key, unverifiable, or
483    /// failing an issuer/audience check classifies as [`TokenError::Denied`].
484    async fn validate_via_jwks(
485        &self,
486        token: &str,
487    ) -> Result<HashMap<String, serde_json::Value>, TokenError> {
488        let header = decode_header(token)
489            .map_err(|e| TokenError::Denied(format!("invalid JWT header: {}", e)))?;
490        let kid = header.kid.clone();
491
492        let keys = self.get_jwks(false).await?;
493        let jwk = match select_jwk(&keys, kid.as_deref()) {
494            Some(j) => j.clone(),
495            None => {
496                // Unknown kid: refetch once to pick up rotated keys.
497                let keys = self.get_jwks(true).await?;
498                select_jwk(&keys, kid.as_deref()).cloned().ok_or_else(|| {
499                    TokenError::Denied("no matching JWK for token kid".to_string())
500                })?
501            }
502        };
503
504        // A malformed JWK or an allowed-algorithm list that cannot verify this
505        // key's family is a provider/config problem, not the caller's fault.
506        let key = jwk_to_decoding_key(&jwk).map_err(TokenError::Infra)?;
507        // Narrow the permitted algorithms to the ones this key could possibly have
508        // signed with. jsonwebtoken rejects the whole Validation if *any* listed
509        // algorithm belongs to a different family than the key, so passing the
510        // default list (RSA + EC) against an RSA key fails every token. See
511        // `algs_for_key`.
512        let algs = algs_for_key(&self.allowed_algs, &jwk.kty).map_err(TokenError::Infra)?;
513        let claims = decode_and_validate(token, &key, &algs).map_err(TokenError::Denied)?;
514        self.validate_claims(&claims).map_err(TokenError::Denied)?;
515        Ok(claims)
516    }
517
518    /// Validates the token via the introspection endpoint. Provider callout
519    /// trouble classifies as [`TokenError::Infra`]; the introspection response
520    /// itself declaring the token inactive, or failing claim checks,
521    /// classifies as [`TokenError::Denied`].
522    async fn validate_via_introspection(
523        &self,
524        token: &str,
525    ) -> Result<HashMap<String, serde_json::Value>, TokenError> {
526        let endpoint = self
527            .introspection_endpoint
528            .as_ref()
529            .ok_or_else(|| TokenError::Infra("no introspection endpoint configured".to_string()))?;
530        let client_id = self.client_id.as_deref().unwrap_or("");
531        let client_secret = self.client_secret.as_deref().unwrap_or("");
532        let basic = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret));
533
534        let body = format!("token={}&token_type_hint=access_token", form_encode(token));
535        let req = OutboundRequest {
536            method: http::Method::POST,
537            url: endpoint.clone(),
538            headers: vec![
539                (
540                    "content-type".to_string(),
541                    "application/x-www-form-urlencoded".to_string(),
542                ),
543                ("authorization".to_string(), format!("Basic {}", basic)),
544                ("accept".to_string(), "application/json".to_string()),
545            ],
546            body: Bytes::from(body),
547            timeout: self.timeout,
548            ssl_verify: self.ssl_verify,
549            tls: None,
550        };
551        let resp = self
552            .resources
553            .outbound
554            .request(req)
555            .await
556            .map_err(|e| TokenError::Infra(format!("introspection callout failed: {}", e)))?;
557        if resp.status != 200 {
558            return Err(TokenError::Infra(format!(
559                "introspection returned status {}",
560                resp.status
561            )));
562        }
563        let claims = parse_introspection(&resp.body)?;
564        self.validate_claims(&claims).map_err(TokenError::Denied)?;
565        Ok(claims)
566    }
567
568    /// Applies the configured issuer and audience claim checks.
569    fn validate_claims(&self, claims: &HashMap<String, serde_json::Value>) -> Result<(), String> {
570        // Issuer
571        if !self.valid_issuers.is_empty() {
572            let iss = claims.get("iss").and_then(|v| v.as_str());
573            match iss {
574                Some(iss) if self.valid_issuers.iter().any(|v| v == iss) => {}
575                _ => return Err("issuer not in valid_issuers".to_string()),
576            }
577        }
578
579        // Audience
580        let aud = claims.get(&self.audience_claim);
581        if self.audience_required && aud.is_none() {
582            return Err(format!(
583                "required audience claim '{}' missing",
584                self.audience_claim
585            ));
586        }
587        if self.audience_match_client_id {
588            if let Some(aud) = aud {
589                let client_id = self.client_id.as_deref().unwrap_or("");
590                if !audience_contains(aud, client_id) {
591                    return Err("audience does not match client_id".to_string());
592                }
593            }
594        }
595        Ok(())
596    }
597
598    /// Writes the validated claims into `context.message` and the configured
599    /// forwarding headers.
600    fn attach(&self, ctx: &mut Context, claims: HashMap<String, serde_json::Value>, token: &str) {
601        let claims_value = serde_json::to_value(&claims).unwrap_or_default();
602        if let Some(sub) = claims.get("sub") {
603            ctx.message.insert("user_id".to_string(), sub.clone());
604        }
605        ctx.message
606            .insert("jwt_claims".to_string(), claims_value.clone());
607
608        if self.set_userinfo_header {
609            if let Ok(raw) = serde_json::to_vec(&claims_value) {
610                ctx.request
611                    .headers
612                    .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
613            }
614        }
615        if self.set_access_token_header {
616            if self.access_token_in_authorization_header {
617                ctx.request.headers.insert(
618                    "authorization".to_string(),
619                    vec![format!("Bearer {}", token)],
620                );
621            } else {
622                ctx.request
623                    .headers
624                    .insert("x-access-token".to_string(), vec![token.to_string()]);
625            }
626        }
627    }
628
629    /// Builds a 401 rejection for a deliberate authentication failure (missing
630    /// bearer token, invalid/unverifiable token, CSRF/nonce/session-flow
631    /// failure) and exits on the `denied` port.
632    fn reject(ctx: Context, message: &str) -> PluginResult {
633        let mut ctx = ctx;
634        ctx.response.status_code = 401;
635        ctx.response.body = Bytes::from(format!(
636            r#"{{"error": "unauthorized", "message": "{}"}}"#,
637            message.replace('"', "'")
638        ));
639        ctx.response.headers.insert(
640            "content-type".to_string(),
641            vec!["application/json".to_string()],
642        );
643        ctx.response.headers.insert(
644            "www-authenticate".to_string(),
645            vec!["Bearer error=\"invalid_token\"".to_string()],
646        );
647        Ok(PluginOutput::on_port(ctx, "denied"))
648    }
649
650    /// Builds a genuine infrastructure-failure `Err` (discovery, JWKS,
651    /// introspection, or token-endpoint callout that transport-failed,
652    /// returned a non-2xx status, or handed back unparseable data). Unlike
653    /// `reject`, this exits through the `error` port because the node could
654    /// not do its job, not because a presented credential was deliberately
655    /// refused — and the response says so (see [`provider_error`]): a `502`
656    /// with `{"error": "provider_error"}` and no `www-authenticate` challenge,
657    /// so neither an API client nor a browser user mistakes an IdP outage for
658    /// a failed login.
659    ///
660    /// [`provider_error`]: crate::plugins::util::provider_error::provider_error
661    fn infra_error(ctx: Context, message: String) -> PluginExecutionError {
662        crate::plugins::util::provider_error::provider_error(ctx, "OIDC_PROVIDER_ERROR", message)
663    }
664
665    /// Session-store outage: 503 through the error port. Deliberately NOT
666    /// 401 — bouncing users to an IdP whose callback also cannot persist a
667    /// session is a redirect loop disguised as an outage.
668    fn store_error(mut ctx: Context, e: StoreError) -> PluginExecutionError {
669        ctx.response.status_code = 503;
670        ctx.response.body = Bytes::from(r#"{"error": "session store unavailable"}"#.as_bytes());
671        ctx.response.headers.insert(
672            "content-type".to_string(),
673            vec!["application/json".to_string()],
674        );
675        PluginExecutionError {
676            context: ctx,
677            error: GatewayError {
678                node_id: String::new(),
679                code: "SESSION_STORE_ERROR".to_string(),
680                message: e.to_string(),
681                metadata: HashMap::new(),
682            },
683        }
684    }
685
686    // ---- Interactive Authorization Code flow ------------------------------
687
688    /// Drives the interactive flow: session check, callback handling, or a
689    /// fresh redirect to the identity provider.
690    async fn execute_interactive(&self, ctx: Context) -> PluginResult {
691        let flow = self.interactive.as_ref().expect("interactive mode");
692
693        // Logout: clear the session (server-side, in store mode) and redirect.
694        if let Some(logout_path) = &flow.logout_path {
695            if ctx.request.path == *logout_path {
696                let cookie_value = ctx
697                    .request
698                    .headers
699                    .get("cookie")
700                    .and_then(|v| v.first())
701                    .and_then(|h| read_cookie(h, &flow.session_cookie))
702                    .map(str::to_string);
703                let clear = match server_session::destroy(
704                    &flow.backend,
705                    cookie_value.as_deref(),
706                    &flow.session_cookie,
707                    &flow.cookie_path,
708                )
709                .await
710                {
711                    Ok(c) => c,
712                    Err(e) => return Err(Self::store_error(ctx, e)),
713                };
714                return redirect(ctx, &flow.post_logout_redirect_uri, vec![clear]);
715            }
716        }
717
718        // Callback: the IdP has redirected back with code + state.
719        if ctx.request.path == flow.redirect_path && ctx.request.query_params.contains_key("code") {
720            return self.handle_callback(ctx).await;
721        }
722
723        // Existing valid session cookie → attach identity and continue. Kept
724        // alongside the session read (rather than only inside `read_session`)
725        // because the redis-mode refresh check below needs the raw id to
726        // take the store's lock.
727        let raw_cookie_value = ctx
728            .request
729            .headers
730            .get("cookie")
731            .and_then(|v| v.first())
732            .and_then(|h| read_cookie(h, &flow.session_cookie))
733            .map(str::to_string);
734        let session = match self.read_session(&ctx).await {
735            Ok(s) => s,
736            Err(e) => return Err(Self::store_error(ctx, e)),
737        };
738        if let Some(session) = session {
739            let (mut ctx, session) = match raw_cookie_value.as_deref() {
740                Some(raw) => match self.refresh_if_needed(ctx, flow, raw, session).await {
741                    ControlFlow::Continue(pair) => pair,
742                    ControlFlow::Break(result) => return result,
743                },
744                // A session was loaded from a cookie value that somehow
745                // isn't readable here — refresh needs the raw id, but
746                // authentication itself does not, so just proceed.
747                None => (ctx, session),
748            };
749            if let Some(sub) = session.claims.get("sub") {
750                ctx.message.insert("user_id".to_string(), sub.clone());
751            }
752            ctx.message
753                .insert("jwt_claims".to_string(), session.claims.clone());
754            if self.set_userinfo_header {
755                if let Ok(raw) = serde_json::to_vec(&session.claims) {
756                    ctx.request
757                        .headers
758                        .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
759                }
760            }
761            if let (true, Some(tok)) = (self.set_access_token_header, &session.access_token) {
762                if self.access_token_in_authorization_header {
763                    ctx.request
764                        .headers
765                        .insert("authorization".to_string(), vec![format!("Bearer {}", tok)]);
766                } else {
767                    ctx.request
768                        .headers
769                        .insert("x-access-token".to_string(), vec![tok.clone()]);
770                }
771            }
772            return Ok(PluginOutput::success(ctx));
773        }
774
775        // No session → begin the Authorization Code flow.
776        self.begin_auth(ctx).await
777    }
778
779    /// Reads the session cookie via the configured backend. `Ok(None)` =
780    /// unauthenticated; `Err` = store outage (503 via `store_error`).
781    async fn read_session(&self, ctx: &Context) -> Result<Option<SessionData>, StoreError> {
782        let Some(flow) = self.interactive.as_ref() else {
783            return Ok(None);
784        };
785        let Some(cookie_header) = ctx.request.headers.get("cookie").and_then(|v| v.first()) else {
786            return Ok(None);
787        };
788        let Some(raw) = read_cookie(cookie_header, &flow.session_cookie) else {
789            return Ok(None);
790        };
791        let bytes = server_session::load(&flow.backend, &flow.sealer, raw).await?;
792        Ok(bytes.and_then(|b| serde_json::from_slice(&b).ok()))
793    }
794
795    /// Pre-attach refresh check (redis mode only, gated on `refresh_enabled`
796    /// and the access token being within 30s of `expires_at`): coordinates a
797    /// lock-guarded refresh so concurrent requests for the same session don't
798    /// all hit the IdP.
799    ///
800    /// `ControlFlow::Continue` carries the (possibly refreshed) session data
801    /// for the caller to attach as normal; `ControlFlow::Break` is an
802    /// immediate exit the caller must return directly — either a 503 store
803    /// error, or a fallback to re-login when the IdP-side refresh itself
804    /// failed. Nothing outside this function and [`Self::do_refresh`] ever
805    /// touches the lock: every branch below unlocks exactly once before
806    /// breaking or continuing.
807    async fn refresh_if_needed(
808        &self,
809        ctx: Context,
810        flow: &Interactive,
811        raw: &str,
812        session: SessionData,
813    ) -> ControlFlow<PluginResult, (Context, SessionData)> {
814        if !flow.refresh_enabled || !matches!(flow.backend, SessionBackend::Store { .. }) {
815            return ControlFlow::Continue((ctx, session));
816        }
817        let (Some(expires_at), Some(refresh_token)) =
818            (session.expires_at, session.refresh_token.clone())
819        else {
820            return ControlFlow::Continue((ctx, session));
821        };
822        if now_unix() + 30 < expires_at {
823            return ControlFlow::Continue((ctx, session));
824        }
825        let Some(id) = SessionId::parse(raw) else {
826            // Shouldn't happen (the session was loaded through this same raw
827            // cookie value), but a malformed id is not grounds to fail the
828            // request — just proceed with what we already have.
829            return ControlFlow::Continue((ctx, session));
830        };
831        let SessionBackend::Store { store, .. } = &flow.backend else {
832            unreachable!("matches! above guarantees Store");
833        };
834
835        let acquired = match store.try_lock(&id, Duration::from_secs(10)).await {
836            Ok(b) => b,
837            Err(e) => return ControlFlow::Break(Err(Self::store_error(ctx, e))),
838        };
839        if !acquired {
840            // Loser: the winner is usually already refreshing/refreshed —
841            // re-read once and proceed with whatever is there now.
842            return match self.read_session(&ctx).await {
843                Ok(Some(fresh)) => ControlFlow::Continue((ctx, fresh)),
844                // The session vanished under us (e.g. evicted) — treat like
845                // no session at all rather than authenticating on stale data.
846                Ok(None) => ControlFlow::Break(self.begin_auth(ctx).await),
847                Err(e) => ControlFlow::Break(Err(Self::store_error(ctx, e))),
848            };
849        }
850
851        // Winner: every branch below unlocks exactly once.
852        match self
853            .do_refresh(&ctx, flow, &id, &refresh_token, &session)
854            .await
855        {
856            Ok(updated) => {
857                // Post-success unlock is best-effort: the refreshed session
858                // is already durably written and the lock self-heals via its
859                // TTL; failing the request here would 503 a correct response
860                // over lock-key cleanup.
861                if let Err(e) = store.unlock(&id).await {
862                    tracing::warn!(
863                        "openid-connect: post-refresh unlock failed (self-heals via TTL): {e}"
864                    );
865                }
866                ControlFlow::Continue((ctx, updated))
867            }
868            Err(RefreshFailure::ReAuth(reason)) => {
869                // An IdP-side refresh failure (unreachable, non-2xx, or an
870                // invalid refreshed id_token) is not a store outage — unlock
871                // (best effort; the lock has a 10s TTL regardless) and fall
872                // back to a fresh login rather than a 503.
873                tracing::debug!(
874                    "openid-connect: redis-mode refresh failed, falling back to re-login: {reason}"
875                );
876                // Deliberately best-effort: a secondary unlock error here
877                // must never override the already-decided re-login outcome.
878                let _ = store.unlock(&id).await;
879                ControlFlow::Break(self.begin_auth(ctx).await)
880            }
881            Err(RefreshFailure::Store(e)) => {
882                // Deliberately best-effort: a secondary unlock error here
883                // must never override the already-decided 503 outcome.
884                let _ = store.unlock(&id).await;
885                ControlFlow::Break(Err(Self::store_error(ctx, e)))
886            }
887        }
888    }
889
890    /// Performs the refresh callout, optional id_token re-validation, and
891    /// session rewrite. Never touches the lock — [`Self::refresh_if_needed`]
892    /// unlocks on every outcome of this call.
893    async fn do_refresh(
894        &self,
895        ctx: &Context,
896        flow: &Interactive,
897        id: &SessionId,
898        refresh_token: &str,
899        old: &SessionData,
900    ) -> Result<SessionData, RefreshFailure> {
901        let tokens = self
902            .refresh_tokens(refresh_token)
903            .await
904            .map_err(RefreshFailure::ReAuth)?;
905
906        let claims = match tokens.get("id_token").and_then(|v| v.as_str()) {
907            Some(id_token) => match self.validate_via_jwks(id_token).await {
908                Ok(c) => serde_json::to_value(&c).unwrap_or_default(),
909                Err(TokenError::Infra(e)) | Err(TokenError::Denied(e)) => {
910                    return Err(RefreshFailure::ReAuth(format!(
911                        "refreshed id_token validation failed: {e}"
912                    )))
913                }
914            },
915            None => old.claims.clone(),
916        };
917        let access_token = tokens
918            .get("access_token")
919            .and_then(|v| v.as_str())
920            .map(String::from)
921            .or_else(|| old.access_token.clone());
922        // Keep the old refresh_token unless the IdP rotated it.
923        let refresh_token = tokens
924            .get("refresh_token")
925            .and_then(|v| v.as_str())
926            .map(String::from)
927            .or_else(|| old.refresh_token.clone());
928        let expires_at = tokens
929            .get("expires_in")
930            .and_then(|v| v.as_u64())
931            .map(|secs| now_unix() + secs)
932            .or(old.expires_at);
933
934        let updated = SessionData {
935            claims,
936            access_token,
937            refresh_token,
938            expires_at,
939        };
940        let payload = serde_json::to_vec(&updated)
941            .map_err(|e| RefreshFailure::ReAuth(format!("session serialize failed: {e}")))?;
942        let subject = updated
943            .claims
944            .get("sub")
945            .and_then(|v| v.as_str())
946            .unwrap_or("")
947            .to_string();
948
949        // Refresh must not extend the session's absolute lifetime, but the
950        // store doesn't expose the record's remaining TTL from here, so we
951        // approximate the remaining lifetime with the configured
952        // `session.cookie.lifetime` — an acceptable, documented
953        // over-approximation rather than a true "remaining" value.
954        let ttl = flow.session_lifetime;
955        let meta = server_session::meta_now(ctx, "openid-connect", &subject, ttl);
956        server_session::update(
957            &flow.backend,
958            &flow.sealer,
959            id.as_str(),
960            &payload,
961            ttl,
962            meta,
963            &flow.session_cookie,
964            &CookieAttrs {
965                path: &flow.cookie_path,
966                max_age: Some(ttl.as_secs()),
967                http_only: true,
968                secure: request_is_https(ctx),
969                same_site: SameSite::Lax,
970            },
971        )
972        .await
973        .map_err(RefreshFailure::Store)?;
974
975        Ok(updated)
976    }
977
978    /// Refreshes an access token at the token endpoint (RFC 6749 §6). Cloned
979    /// from [`Self::exchange_code`]'s request shape, with the refresh-token
980    /// grant body instead.
981    async fn refresh_tokens(&self, refresh_token: &str) -> Result<serde_json::Value, String> {
982        let token_endpoint = self.token_endpoint().await?;
983        let client_id = self.client_id.as_deref().unwrap_or("");
984        let client_secret = self.client_secret.as_deref().unwrap_or("");
985        let body = format!(
986            "grant_type=refresh_token&refresh_token={}&client_id={}&client_secret={}",
987            form_encode(refresh_token),
988            form_encode(client_id),
989            form_encode(client_secret),
990        );
991        let req = OutboundRequest {
992            method: http::Method::POST,
993            url: token_endpoint,
994            headers: vec![
995                (
996                    "content-type".to_string(),
997                    "application/x-www-form-urlencoded".to_string(),
998                ),
999                ("accept".to_string(), "application/json".to_string()),
1000            ],
1001            body: Bytes::from(body),
1002            timeout: self.timeout,
1003            ssl_verify: self.ssl_verify,
1004            tls: None,
1005        };
1006        let resp = self
1007            .resources
1008            .outbound
1009            .request(req)
1010            .await
1011            .map_err(|e| format!("token refresh callout failed: {}", e))?;
1012        if resp.status != 200 {
1013            return Err(format!("token endpoint returned status {}", resp.status));
1014        }
1015        serde_json::from_slice(&resp.body).map_err(|e| format!("invalid token response: {}", e))
1016    }
1017
1018    /// Starts the flow: generate CSRF/nonce/PKCE, set the flow cookie, and
1019    /// redirect the browser to the IdP authorization endpoint.
1020    async fn begin_auth(&self, ctx: Context) -> PluginResult {
1021        let flow = self.interactive.as_ref().expect("interactive mode");
1022        let authz = match self.authorization_endpoint().await {
1023            Ok(u) => u,
1024            Err(e) => return Err(Self::infra_error(ctx, e)),
1025        };
1026
1027        let state = random_token();
1028        let nonce = random_token();
1029        let verifier = random_token();
1030        let challenge = pkce_challenge(&verifier);
1031        let original_uri = request_uri(&ctx);
1032
1033        let flow_state = FlowState {
1034            state: state.clone(),
1035            nonce: nonce.clone(),
1036            verifier,
1037            original_uri,
1038        };
1039        let sealed = match serde_json::to_vec(&flow_state) {
1040            Ok(b) => flow.sealer.seal(&b, Duration::from_secs(300)),
1041            Err(e) => {
1042                return Err(Self::infra_error(
1043                    ctx,
1044                    format!("flow cookie seal failed: {}", e),
1045                ))
1046            }
1047        };
1048        let set_flow = build_set_cookie(
1049            &flow.flow_cookie,
1050            &sealed,
1051            &CookieAttrs {
1052                path: &flow.cookie_path,
1053                max_age: Some(300),
1054                http_only: true,
1055                secure: request_is_https(&ctx),
1056                same_site: SameSite::Lax,
1057            },
1058        );
1059
1060        let url = format!(
1061            "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}\
1062             &code_challenge={}&code_challenge_method=S256",
1063            authz,
1064            form_encode(self.client_id.as_deref().unwrap_or("")),
1065            form_encode(&flow.redirect_uri),
1066            form_encode(&flow.scope),
1067            form_encode(&state),
1068            form_encode(&nonce),
1069            form_encode(&challenge),
1070        );
1071        redirect(ctx, &url, vec![set_flow])
1072    }
1073
1074    /// Handles the IdP redirect back: verify state, exchange the code, validate
1075    /// the id_token, seal a session cookie, and redirect to the original URL.
1076    async fn handle_callback(&self, ctx: Context) -> PluginResult {
1077        let flow = self.interactive.as_ref().expect("interactive mode");
1078
1079        let code = first_query(&ctx, "code").unwrap_or_default();
1080        let state = first_query(&ctx, "state").unwrap_or_default();
1081
1082        // Recover and validate the flow cookie (CSRF).
1083        let flow_state = match self.read_flow(&ctx) {
1084            Some(f) => f,
1085            None => return Self::reject(ctx, "missing or invalid login flow cookie"),
1086        };
1087        if flow_state.state != state || state.is_empty() {
1088            return Self::reject(ctx, "state mismatch (possible CSRF)");
1089        }
1090
1091        // Exchange the authorization code for tokens. A token-endpoint
1092        // callout that transport-fails, returns non-2xx, or hands back
1093        // unparseable JSON is a genuine provider failure, not a deliberate
1094        // rejection of the caller.
1095        let token_endpoint = match self.token_endpoint().await {
1096            Ok(u) => u,
1097            Err(e) => return Err(Self::infra_error(ctx, e)),
1098        };
1099        let tokens = match self
1100            .exchange_code(
1101                &token_endpoint,
1102                &code,
1103                &flow_state.verifier,
1104                &flow.redirect_uri,
1105            )
1106            .await
1107        {
1108            Ok(t) => t,
1109            Err(e) => return Err(Self::infra_error(ctx, e)),
1110        };
1111
1112        let id_token = match tokens.get("id_token").and_then(|v| v.as_str()) {
1113            Some(t) => t.to_string(),
1114            None => {
1115                return Err(Self::infra_error(
1116                    ctx,
1117                    "token response missing id_token".to_string(),
1118                ))
1119            }
1120        };
1121        let access_token = tokens
1122            .get("access_token")
1123            .and_then(|v| v.as_str())
1124            .map(String::from);
1125        // Refresh material is captured only in redis mode with refresh
1126        // enabled: cookie-mode sessions have no server-side coordination
1127        // point for a lock-guarded refresh, so they keep the pre-Task-7
1128        // re-login-on-expiry behavior untouched.
1129        let (refresh_token, expires_at) =
1130            if matches!(flow.backend, SessionBackend::Store { .. }) && flow.refresh_enabled {
1131                let refresh_token = tokens
1132                    .get("refresh_token")
1133                    .and_then(|v| v.as_str())
1134                    .map(String::from);
1135                let expires_at = tokens
1136                    .get("expires_in")
1137                    .and_then(|v| v.as_u64())
1138                    .map(|secs| now_unix() + secs);
1139                (refresh_token, expires_at)
1140            } else {
1141                (None, None)
1142            };
1143
1144        // Validate the id_token signature/claims via the JWKS path and check
1145        // the nonce binds it to this login attempt. A JWKS callout failure is
1146        // a genuine provider failure; an invalid/unverifiable id_token is a
1147        // deliberate rejection.
1148        let claims = match self.validate_via_jwks(&id_token).await {
1149            Ok(c) => c,
1150            Err(TokenError::Infra(e)) => {
1151                return Err(Self::infra_error(
1152                    ctx,
1153                    format!("id_token validation failed: {}", e),
1154                ))
1155            }
1156            Err(TokenError::Denied(e)) => {
1157                return Self::reject(ctx, &format!("id_token validation failed: {}", e))
1158            }
1159        };
1160        if claims.get("nonce").and_then(|v| v.as_str()) != Some(flow_state.nonce.as_str()) {
1161            return Self::reject(ctx, "id_token nonce mismatch");
1162        }
1163
1164        // Seal the session and redirect to where the user was going.
1165        let session = SessionData {
1166            claims: serde_json::to_value(&claims).unwrap_or_default(),
1167            access_token,
1168            refresh_token,
1169            expires_at,
1170        };
1171        let payload = match serde_json::to_vec(&session) {
1172            Ok(b) => b,
1173            Err(e) => {
1174                return Err(Self::infra_error(
1175                    ctx,
1176                    format!("session serialize failed: {}", e),
1177                ))
1178            }
1179        };
1180        let subject = claims
1181            .get("sub")
1182            .and_then(|v| v.as_str())
1183            .unwrap_or("")
1184            .to_string();
1185        let meta =
1186            server_session::meta_now(&ctx, "openid-connect", &subject, flow.session_lifetime);
1187        let set_session = match server_session::establish(
1188            &flow.backend,
1189            &flow.sealer,
1190            &payload,
1191            flow.session_lifetime,
1192            meta,
1193            &flow.session_cookie,
1194            &CookieAttrs {
1195                path: &flow.cookie_path,
1196                max_age: Some(flow.session_lifetime.as_secs()),
1197                http_only: true,
1198                secure: request_is_https(&ctx),
1199                same_site: SameSite::Lax,
1200            },
1201        )
1202        .await
1203        {
1204            Ok(s) => s,
1205            Err(e) => return Err(Self::store_error(ctx, e)),
1206        };
1207        let clear_flow = delete_cookie(&flow.flow_cookie, &flow.cookie_path);
1208        let target = if flow_state.original_uri.is_empty() {
1209            "/".to_string()
1210        } else {
1211            flow_state.original_uri.clone()
1212        };
1213        redirect(ctx, &target, vec![set_session, clear_flow])
1214    }
1215
1216    /// Reads and opens the transient flow cookie.
1217    fn read_flow(&self, ctx: &Context) -> Option<FlowState> {
1218        let flow = self.interactive.as_ref()?;
1219        let cookie_header = ctx.request.headers.get("cookie")?.first()?;
1220        let raw = read_cookie(cookie_header, &flow.flow_cookie)?;
1221        let bytes = flow.sealer.open(raw).ok()?;
1222        serde_json::from_slice(&bytes).ok()
1223    }
1224
1225    /// Exchanges an authorization code for tokens at the token endpoint.
1226    async fn exchange_code(
1227        &self,
1228        token_endpoint: &str,
1229        code: &str,
1230        verifier: &str,
1231        redirect_uri: &str,
1232    ) -> Result<serde_json::Value, String> {
1233        let client_id = self.client_id.as_deref().unwrap_or("");
1234        let client_secret = self.client_secret.as_deref().unwrap_or("");
1235        let basic = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret));
1236        let body = format!(
1237            "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}",
1238            form_encode(code),
1239            form_encode(redirect_uri),
1240            form_encode(verifier),
1241            form_encode(client_id),
1242        );
1243        let req = OutboundRequest {
1244            method: http::Method::POST,
1245            url: token_endpoint.to_string(),
1246            headers: vec![
1247                (
1248                    "content-type".to_string(),
1249                    "application/x-www-form-urlencoded".to_string(),
1250                ),
1251                ("authorization".to_string(), format!("Basic {}", basic)),
1252                ("accept".to_string(), "application/json".to_string()),
1253            ],
1254            body: Bytes::from(body),
1255            timeout: self.timeout,
1256            ssl_verify: self.ssl_verify,
1257            tls: None,
1258        };
1259        let resp = self
1260            .resources
1261            .outbound
1262            .request(req)
1263            .await
1264            .map_err(|e| format!("token exchange callout failed: {}", e))?;
1265        if resp.status != 200 {
1266            return Err(format!("token endpoint returned status {}", resp.status));
1267        }
1268        serde_json::from_slice(&resp.body).map_err(|e| format!("invalid token response: {}", e))
1269    }
1270
1271    /// Resolves the authorization endpoint (config or discovery).
1272    async fn authorization_endpoint(&self) -> Result<String, String> {
1273        let flow = self.interactive.as_ref().expect("interactive mode");
1274        if let Some(u) = &flow.authorization_endpoint_cfg {
1275            return Ok(u.clone());
1276        }
1277        self.discovery_field("authorization_endpoint", &flow.authz_endpoint_resolved)
1278            .await
1279    }
1280
1281    /// Resolves the token endpoint (config or discovery).
1282    async fn token_endpoint(&self) -> Result<String, String> {
1283        let flow = self.interactive.as_ref().expect("interactive mode");
1284        if let Some(u) = &flow.token_endpoint_cfg {
1285            return Ok(u.clone());
1286        }
1287        self.discovery_field("token_endpoint", &flow.token_endpoint_resolved)
1288            .await
1289    }
1290
1291    /// Reads a URL field from the discovery document, memoizing the result.
1292    async fn discovery_field(
1293        &self,
1294        field: &str,
1295        cache: &Mutex<Option<String>>,
1296    ) -> Result<String, String> {
1297        {
1298            if let Some(u) = cache.lock().await.as_ref() {
1299                return Ok(u.clone());
1300            }
1301        }
1302        let discovery = self
1303            .discovery
1304            .as_ref()
1305            .ok_or_else(|| format!("no discovery URL to resolve {}", field))?;
1306        let resp = self
1307            .get(discovery)
1308            .await
1309            .map_err(|e| format!("discovery fetch failed: {}", e))?;
1310        if resp.status != 200 {
1311            return Err(format!("discovery returned status {}", resp.status));
1312        }
1313        let doc: serde_json::Value = serde_json::from_slice(&resp.body)
1314            .map_err(|e| format!("failed to parse discovery doc: {}", e))?;
1315        let uri = doc
1316            .get(field)
1317            .and_then(|v| v.as_str())
1318            .ok_or_else(|| format!("discovery doc missing {}", field))?
1319            .to_string();
1320        *cache.lock().await = Some(uri.clone());
1321        Ok(uri)
1322    }
1323}
1324
1325/// Builds the interactive-mode configuration from the plugin config.
1326fn build_interactive(
1327    config: &HashMap<String, serde_json::Value>,
1328    discovery: &Option<String>,
1329    resources: &Arc<PluginResources>,
1330) -> Result<Interactive, String> {
1331    let secret = session_field(config, "secret")
1332        .or_else(|| string_opt(config, "session_secret"))
1333        .ok_or("openid-connect: interactive login requires 'session.secret'")?;
1334    let backend = server_session::parse_backend(config, resources, "openid-connect")?;
1335
1336    let redirect_uri = string_opt(config, "redirect_uri")
1337        .ok_or("openid-connect: interactive login requires 'redirect_uri'")?;
1338    let redirect_path = url_path(&redirect_uri);
1339
1340    let authorization_endpoint_cfg = string_opt(config, "authorization_endpoint");
1341    let token_endpoint_cfg = string_opt(config, "token_endpoint");
1342    if discovery.is_none() && (authorization_endpoint_cfg.is_none() || token_endpoint_cfg.is_none())
1343    {
1344        return Err(
1345            "openid-connect: interactive login requires 'discovery', or both \
1346                    'authorization_endpoint' and 'token_endpoint'"
1347                .to_string(),
1348        );
1349    }
1350
1351    let scope = string_opt(config, "scope").unwrap_or_else(|| "openid".to_string());
1352    let session_cookie =
1353        session_cookie_field(config, "name").unwrap_or_else(|| "oidc_session".to_string());
1354    let cookie_path = session_cookie_field(config, "path").unwrap_or_else(|| "/".to_string());
1355    // The callback must be reachable with the session/flow cookies attached, so
1356    // the cookie path has to cover the redirect_uri path. Otherwise the browser
1357    // withholds the flow cookie on the callback and login loops forever — fail
1358    // fast at load instead of shipping a silently-broken route.
1359    if !path_covers(&cookie_path, &redirect_path) {
1360        return Err(format!(
1361            "openid-connect: session.cookie.path '{}' does not cover the redirect_uri \
1362             path '{}'; the session cookie would not be sent to the callback and login \
1363             would loop. Set session.cookie.path to a prefix of the callback path.",
1364            cookie_path, redirect_path
1365        ));
1366    }
1367    let session_lifetime = Duration::from_secs(
1368        config
1369            .get("session")
1370            .and_then(|s| s.get("cookie"))
1371            .and_then(|c| c.get("lifetime"))
1372            .or_else(|| config.get("session_cookie_lifetime"))
1373            .and_then(|v| v.as_u64())
1374            .unwrap_or(3600),
1375    );
1376
1377    Ok(Interactive {
1378        sealer: CookieSealer::new(&secret),
1379        backend,
1380        authorization_endpoint_cfg,
1381        token_endpoint_cfg,
1382        redirect_uri,
1383        redirect_path,
1384        scope,
1385        flow_cookie: format!("{}_flow", session_cookie),
1386        session_cookie,
1387        cookie_path,
1388        session_lifetime,
1389        refresh_enabled: session_refresh_enabled(config),
1390        logout_path: string_opt(config, "logout_path"),
1391        post_logout_redirect_uri: string_opt(config, "post_logout_redirect_uri")
1392            .unwrap_or_else(|| "/".to_string()),
1393        authz_endpoint_resolved: Mutex::new(None),
1394        token_endpoint_resolved: Mutex::new(None),
1395    })
1396}
1397
1398/// Reads `session.<field>` as a string.
1399fn session_field(config: &HashMap<String, serde_json::Value>, field: &str) -> Option<String> {
1400    config
1401        .get("session")
1402        .and_then(|s| s.get(field))
1403        .and_then(|v| v.as_str())
1404        .filter(|s| !s.is_empty())
1405        .map(String::from)
1406}
1407
1408/// Reads `session.refresh` (nested), falling back to the flat
1409/// `session_refresh` key the Web UI schema would emit; default `true`.
1410/// Redis-mode only — cookie mode never attempts a refresh regardless.
1411fn session_refresh_enabled(config: &HashMap<String, serde_json::Value>) -> bool {
1412    config
1413        .get("session")
1414        .and_then(|s| s.get("refresh"))
1415        .or_else(|| config.get("session_refresh"))
1416        .and_then(|v| v.as_bool())
1417        .unwrap_or(true)
1418}
1419
1420/// Reads a session cookie string field from nested `session.cookie.<field>`,
1421/// falling back to the flat `session_cookie_<field>` form the Web UI schema
1422/// emits (the SchemaForm is flat and cannot author nested maps).
1423fn session_cookie_field(
1424    config: &HashMap<String, serde_json::Value>,
1425    field: &str,
1426) -> Option<String> {
1427    config
1428        .get("session")
1429        .and_then(|s| s.get("cookie"))
1430        .and_then(|c| c.get(field))
1431        .or_else(|| config.get(&format!("session_cookie_{field}")))
1432        .and_then(|v| v.as_str())
1433        .filter(|s| !s.is_empty())
1434        .map(String::from)
1435}
1436
1437/// Extracts the path portion of a URL (everything from the first `/` after the
1438/// authority), defaulting to `/`.
1439fn url_path(url: &str) -> String {
1440    let after_scheme = url.split("://").nth(1).unwrap_or(url);
1441    match after_scheme.find('/') {
1442        Some(i) => {
1443            let path = &after_scheme[i..];
1444            path.split(['?', '#']).next().unwrap_or(path).to_string()
1445        }
1446        None => "/".to_string(),
1447    }
1448}
1449
1450/// Rebuilds the request URI (path plus sorted query string) for the
1451/// post-login redirect target.
1452fn request_uri(ctx: &Context) -> String {
1453    let mut pairs: Vec<String> = Vec::new();
1454    for (k, values) in &ctx.request.query_params {
1455        for v in values {
1456            pairs.push(format!("{}={}", form_encode(k), form_encode(v)));
1457        }
1458    }
1459    pairs.sort();
1460    if pairs.is_empty() {
1461        ctx.request.path.clone()
1462    } else {
1463        format!("{}?{}", ctx.request.path, pairs.join("&"))
1464    }
1465}
1466
1467/// First value of a query parameter.
1468fn first_query(ctx: &Context, name: &str) -> Option<String> {
1469    ctx.request
1470        .query_params
1471        .get(name)
1472        .and_then(|v| v.first())
1473        .cloned()
1474}
1475
1476/// True when the request arrived over HTTPS (controls the cookie `Secure` flag).
1477fn request_is_https(ctx: &Context) -> bool {
1478    ctx.request.scheme.eq_ignore_ascii_case("https")
1479}
1480
1481/// A URL-safe random token (32 bytes → base64url) for state/nonce/PKCE.
1482fn random_token() -> String {
1483    let mut bytes = [0u8; 32];
1484    SystemRandom::new()
1485        .fill(&mut bytes)
1486        .expect("system RNG must produce random bytes");
1487    URL_SAFE_NO_PAD.encode(bytes)
1488}
1489
1490/// PKCE S256 challenge: base64url(SHA-256(verifier)).
1491fn pkce_challenge(verifier: &str) -> String {
1492    URL_SAFE_NO_PAD.encode(digest(&SHA256, verifier.as_bytes()).as_ref())
1493}
1494
1495/// Current time, epoch seconds. Used for `expires_at` bookkeeping on the
1496/// redis-mode refresh path.
1497fn now_unix() -> u64 {
1498    SystemTime::now()
1499        .duration_since(UNIX_EPOCH)
1500        .map(|d| d.as_secs())
1501        .unwrap_or(0)
1502}
1503
1504/// Prepares a 302 redirect on the context and exits through the dedicated
1505/// `redirect` port (wire the node's `redirect` edge to `client.in`).
1506fn redirect(mut ctx: Context, location: &str, set_cookies: Vec<String>) -> PluginResult {
1507    ctx.response.status_code = 302;
1508    ctx.response.body = Bytes::new();
1509    ctx.response
1510        .headers
1511        .insert("location".to_string(), vec![location.to_string()]);
1512    if !set_cookies.is_empty() {
1513        ctx.response
1514            .headers
1515            .insert("set-cookie".to_string(), set_cookies);
1516    }
1517    Ok(PluginOutput::on_port(ctx, "redirect"))
1518}
1519
1520/// Reads an optional non-empty string config value.
1521fn string_opt(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
1522    config
1523        .get(key)
1524        .and_then(|v| v.as_str())
1525        .filter(|s| !s.is_empty())
1526        .map(String::from)
1527}
1528
1529/// Parses one algorithm name into a [`jsonwebtoken::Algorithm`] (asymmetric
1530/// only — OIDC JWKS keys are RSA/EC).
1531fn parse_alg(name: &str) -> Option<Algorithm> {
1532    match name {
1533        "RS256" => Some(Algorithm::RS256),
1534        "RS384" => Some(Algorithm::RS384),
1535        "RS512" => Some(Algorithm::RS512),
1536        "PS256" => Some(Algorithm::PS256),
1537        "PS384" => Some(Algorithm::PS384),
1538        "PS512" => Some(Algorithm::PS512),
1539        "ES256" => Some(Algorithm::ES256),
1540        "ES384" => Some(Algorithm::ES384),
1541        _ => None,
1542    }
1543}
1544
1545/// Parses `token_signing_alg_values_expected` (string, comma/space list, or
1546/// array) into the allowed-algorithm set, defaulting to the common asymmetric
1547/// algorithms.
1548fn parse_allowed_algs(value: Option<&serde_json::Value>) -> Result<Vec<Algorithm>, String> {
1549    let default = || {
1550        vec![
1551            Algorithm::RS256,
1552            Algorithm::RS384,
1553            Algorithm::RS512,
1554            Algorithm::ES256,
1555            Algorithm::ES384,
1556        ]
1557    };
1558    let names: Vec<String> = match value {
1559        None => return Ok(default()),
1560        Some(serde_json::Value::String(s)) => s
1561            .split([',', ' '])
1562            .map(str::trim)
1563            .filter(|s| !s.is_empty())
1564            .map(String::from)
1565            .collect(),
1566        Some(serde_json::Value::Array(a)) => a
1567            .iter()
1568            .filter_map(|v| v.as_str().map(String::from))
1569            .collect(),
1570        Some(_) => {
1571            return Err("token_signing_alg_values_expected must be a string or array".to_string())
1572        }
1573    };
1574    if names.is_empty() {
1575        return Ok(default());
1576    }
1577    let mut algs = Vec::new();
1578    for name in names {
1579        match parse_alg(&name) {
1580            Some(a) => algs.push(a),
1581            None => {
1582                return Err(format!(
1583                    "unsupported token signing algorithm '{}' \
1584                     (supported: RS256/384/512, PS256/384/512, ES256/384)",
1585                    name
1586                ))
1587            }
1588        }
1589    }
1590    Ok(algs)
1591}
1592
1593/// Reads `claim_validator.issuer.valid_issuers`.
1594fn read_valid_issuers(config: &HashMap<String, serde_json::Value>) -> Vec<String> {
1595    config
1596        .get("claim_validator")
1597        .and_then(|v| v.get("issuer"))
1598        .and_then(|v| v.get("valid_issuers"))
1599        .and_then(|v| v.as_array())
1600        .map(|arr| {
1601            arr.iter()
1602                .filter_map(|v| v.as_str().map(String::from))
1603                .collect()
1604        })
1605        .unwrap_or_default()
1606}
1607
1608/// Reads `claim_validator.audience.{claim,required,match_with_client_id}`.
1609fn read_audience_cfg(config: &HashMap<String, serde_json::Value>) -> (String, bool, bool) {
1610    let audience = config
1611        .get("claim_validator")
1612        .and_then(|v| v.get("audience"));
1613    let claim = audience
1614        .and_then(|a| a.get("claim"))
1615        .and_then(|v| v.as_str())
1616        .unwrap_or("aud")
1617        .to_string();
1618    let required = audience
1619        .and_then(|a| a.get("required"))
1620        .and_then(|v| v.as_bool())
1621        .unwrap_or(false);
1622    let match_client = audience
1623        .and_then(|a| a.get("match_with_client_id"))
1624        .and_then(|v| v.as_bool())
1625        .unwrap_or(false);
1626    (claim, required, match_client)
1627}
1628
1629/// Selects the JWK matching `kid`, or the sole key when no `kid` is present.
1630fn select_jwk<'a>(keys: &'a [Jwk], kid: Option<&str>) -> Option<&'a Jwk> {
1631    match kid {
1632        Some(kid) => keys.iter().find(|k| k.kid.as_deref() == Some(kid)),
1633        None => {
1634            if keys.len() == 1 {
1635                keys.first()
1636            } else {
1637                None
1638            }
1639        }
1640    }
1641}
1642
1643/// Whether `alg` can be verified with a key of JWK type `kty`.
1644fn alg_matches_kty(alg: Algorithm, kty: &str) -> bool {
1645    match alg {
1646        Algorithm::RS256
1647        | Algorithm::RS384
1648        | Algorithm::RS512
1649        | Algorithm::PS256
1650        | Algorithm::PS384
1651        | Algorithm::PS512 => kty == "RSA",
1652        Algorithm::ES256 | Algorithm::ES384 => kty == "EC",
1653        Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => kty == "oct",
1654        Algorithm::EdDSA => kty == "OKP",
1655    }
1656}
1657
1658/// Narrows the configured algorithms to those a `kty` key can verify.
1659///
1660/// This is not an optimization — it is required for correctness. `jsonwebtoken`
1661/// validates the *whole* algorithm list against the key family before it even
1662/// looks at the token:
1663///
1664/// ```ignore
1665/// for alg in &validation.algorithms {
1666///     if key.family != alg.family() { return Err(InvalidAlgorithm); }
1667/// }
1668/// ```
1669///
1670/// So a list spanning two families can never verify anything. The default
1671/// `token_signing_alg_values_expected` spans RSA *and* EC, which meant every
1672/// JWKS-verified token — bearer tokens and interactive `id_token`s alike — was
1673/// rejected with `InvalidAlgorithm` unless the operator happened to pin a single
1674/// family. Filtering per key keeps the permissive default working with whichever
1675/// key the IdP actually published.
1676fn algs_for_key(allowed: &[Algorithm], kty: &str) -> Result<Vec<Algorithm>, String> {
1677    let algs: Vec<Algorithm> = allowed
1678        .iter()
1679        .copied()
1680        .filter(|a| alg_matches_kty(*a, kty))
1681        .collect();
1682    if algs.is_empty() {
1683        return Err(format!(
1684            "no permitted signing algorithm can verify a '{}' key; \
1685             check token_signing_alg_values_expected",
1686            kty
1687        ));
1688    }
1689    Ok(algs)
1690}
1691
1692/// Builds a [`DecodingKey`] from a JWK based on its key type.
1693fn jwk_to_decoding_key(jwk: &Jwk) -> Result<DecodingKey, String> {
1694    match jwk.kty.as_str() {
1695        "RSA" => {
1696            let n = jwk.n.as_deref().ok_or("RSA JWK missing 'n'")?;
1697            let e = jwk.e.as_deref().ok_or("RSA JWK missing 'e'")?;
1698            DecodingKey::from_rsa_components(n, e).map_err(|e| format!("invalid RSA JWK: {}", e))
1699        }
1700        "EC" => {
1701            let x = jwk.x.as_deref().ok_or("EC JWK missing 'x'")?;
1702            let y = jwk.y.as_deref().ok_or("EC JWK missing 'y'")?;
1703            DecodingKey::from_ec_components(x, y).map_err(|e| format!("invalid EC JWK: {}", e))
1704        }
1705        other => Err(format!("unsupported JWK key type '{}'", other)),
1706    }
1707}
1708
1709/// Verifies the token signature (against `key`, restricted to `allowed_algs`)
1710/// and `exp`, returning the decoded claims. Issuer/audience are validated
1711/// separately by [`OpenidConnectPlugin::validate_claims`].
1712fn decode_and_validate(
1713    token: &str,
1714    key: &DecodingKey,
1715    allowed_algs: &[Algorithm],
1716) -> Result<HashMap<String, serde_json::Value>, String> {
1717    let first = allowed_algs.first().copied().unwrap_or(Algorithm::RS256);
1718    let mut validation = Validation::new(first);
1719    validation.algorithms = allowed_algs.to_vec();
1720    validation.validate_exp = true;
1721    // Issuer/audience handled manually to honor the plugin's flags precisely.
1722    validation.validate_aud = false;
1723    decode::<HashMap<String, serde_json::Value>>(token, key, &validation)
1724        .map(|data| data.claims)
1725        .map_err(|e| format!("token verification failed: {}", e))
1726}
1727
1728/// Parses an RFC 7662 introspection response, requiring `active: true`. An
1729/// unparseable response is a genuine provider failure ([`TokenError::Infra`]);
1730/// an inactive token is a deliberate rejection ([`TokenError::Denied`]).
1731fn parse_introspection(body: &[u8]) -> Result<HashMap<String, serde_json::Value>, TokenError> {
1732    let value: serde_json::Value = serde_json::from_slice(body)
1733        .map_err(|e| TokenError::Infra(format!("invalid introspection response: {}", e)))?;
1734    let active = value
1735        .get("active")
1736        .and_then(|v| v.as_bool())
1737        .unwrap_or(false);
1738    if !active {
1739        return Err(TokenError::Denied("token is not active".to_string()));
1740    }
1741    let map = value
1742        .as_object()
1743        .map(|m| m.clone().into_iter().collect())
1744        .unwrap_or_default();
1745    Ok(map)
1746}
1747
1748/// True when `aud` equals `client_id` (string aud) or contains it (array aud).
1749fn audience_contains(aud: &serde_json::Value, client_id: &str) -> bool {
1750    match aud {
1751        serde_json::Value::String(s) => s == client_id,
1752        serde_json::Value::Array(arr) => arr.iter().any(|v| v.as_str() == Some(client_id)),
1753        _ => false,
1754    }
1755}
1756
1757/// Extracts the bearer token from an `Authorization` header value.
1758fn parse_bearer(header_value: &str) -> Option<&str> {
1759    let mut parts = header_value.splitn(2, ' ');
1760    let scheme = parts.next()?;
1761    let token = parts.next()?.trim();
1762    if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() {
1763        Some(token)
1764    } else {
1765        None
1766    }
1767}
1768
1769/// Percent-encodes a token for an `application/x-www-form-urlencoded` body.
1770fn form_encode(value: &str) -> String {
1771    let mut out = String::with_capacity(value.len());
1772    for b in value.bytes() {
1773        match b {
1774            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1775                out.push(b as char)
1776            }
1777            _ => out.push_str(&format!("%{:02X}", b)),
1778        }
1779    }
1780    out
1781}
1782
1783#[async_trait]
1784impl Plugin for OpenidConnectPlugin {
1785    fn plugin_type(&self) -> &str {
1786        "openid-connect"
1787    }
1788
1789    async fn execute(&self, mut ctx: Context) -> PluginResult {
1790        // Strip any client-supplied userinfo header before authentication.
1791        ctx.request.headers.remove("x-userinfo");
1792
1793        // Interactive login (bearer_only: false) runs the cookie-session flow.
1794        if self.interactive.is_some() {
1795            return self.execute_interactive(ctx).await;
1796        }
1797
1798        let token = ctx
1799            .request
1800            .headers
1801            .get("authorization")
1802            .and_then(|v| v.first())
1803            .and_then(|v| parse_bearer(v))
1804            .map(String::from);
1805
1806        let token = match token {
1807            Some(t) => t,
1808            None => {
1809                return Self::reject(
1810                    ctx,
1811                    "No bearer token found in request (bearer_only is true; set                      bearer_only: false for interactive login)",
1812                )
1813            }
1814        };
1815
1816        let result = if self.use_jwks {
1817            self.validate_via_jwks(&token).await
1818        } else {
1819            self.validate_via_introspection(&token).await
1820        };
1821
1822        match result {
1823            Ok(claims) => {
1824                self.attach(&mut ctx, claims, &token);
1825                Ok(PluginOutput::success(ctx))
1826            }
1827            Err(TokenError::Denied(e)) => Self::reject(ctx, &e),
1828            Err(TokenError::Infra(e)) => Err(Self::infra_error(ctx, e)),
1829        }
1830    }
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835    use super::*;
1836    use crate::plugins::util::provider_error::testing::assert_provider_error;
1837    use jsonwebtoken::{encode, EncodingKey, Header};
1838
1839    // Test RSA keypair (PKCS#8). The public modulus/exponent below are the same
1840    // key, expressed as JWK n/e, so a token signed with PRIV_PEM verifies
1841    // against the JWK — exercising the JWKS → DecodingKey path end to end.
1842    const PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1843MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCvciOuri5uG88q\n\
1844rZ3T6qUhTYl7nWDHvVGBBsA8ku3xUfOW97PGpWbTe/Yq/3jovVxAQsAe/QoIMyUU\n\
1845HKCdDKAsIBO9j9OEPs3Le6cThFx+/9Z1U9cw4wCIa4TNtGBhyDgqbqKpOLNnXLI6\n\
1846WEcrykkoV5nUUH/47aS2i9BiqZn6H9eEL1VH82IX/x4fWNIEyXQAxKZtyULgznR4\n\
1847oUz2QPaY/cWtpK85B12scs1IpLnzEdjy69t28ZQnYZ7Nrvl+aFjSkvnqxhoNJ9Ut\n\
1848Lw2/3vld8t3Lh6B4vTM4vdJsue1dum6WnyEKEx/SDuCSDxWONfmdhu/B4XUghaQS\n\
18491wBNiEhvAgMBAAECggEASXDcee8ktWfDsShK9F35MLcd0VaAICxiFUInr1OL8ePt\n\
1850tSjMIt+y6t0tnzMgwEAgATBP7sjabbNHFqOjIgqac84bpVKy5l1J1R9WQWe7NlhO\n\
1851w/9MCYVEgFaNmXQjklr3E+ALDA4VnzNg0eaJKE39kLsWxBbMcv27YMSm/t3i/B2s\n\
1852rwZbzBgxXXR5r7j/Tt+hRJmGHXe0zZvsNLzFNj4CsyngBiY9CIcexroGxd3yGEf7\n\
18530PKHwbZKkH0CPr6QAc4f+tPgIfHB+8+29QPrUTR9e60Sc6dZNUjTr1EWIxyvFxVK\n\
1854dI3ekR5W26a81+yxc2MpRK8wZsv+mJ6okaeVs2+3jQKBgQDr0b3YX4RC9trW+RsE\n\
18559wUXeLr3o9Vb0FTHf/8ALAZ9EWywEmF+sdA8fKs8+H+IyIzX6KGw/UbzqIi2aDuJ\n\
1856q63IPxKyyXr7nfVSUz8qWIGT/WoG/1d4rpFN2sbR/r/oue7uJnaXMIPswVT+zO8q\n\
18575YieEPDwhteJ8bJUC16NWwddBQKBgQC+dcEmNm7MzxI/cuwubkojhayXw1ouACu4\n\
1858giGp3lJywzIAnV1CsJTGTpvHk31j+/L9oB2U/586+65MGklGJ2TGs0IQZs0iAy1H\n\
1859Oq3zzsLp0KiVizyqchgkIWP6KVpx5aPkpJSgPJGyJzuwofZwRzPK7IZr8c4MOtsy\n\
1860M8j8up8p4wKBgGbUxTYvIJuazX7kjXWyydOcX9tQ497vj6iXFflbOVEcYgq9WSpI\n\
1861G4fkzT7/FY3t9gzIcomdSG1D1qnD9gJojJU/e8XeufQywyEtD+RFR+vim3OFsPz9\n\
1862EnuipQQ5VDIFsjzDJP90tnJtM8UQVFKeWN6kgIxCIIcUkDC57HczdJiJAoGASPG4\n\
1863g/YdAXvdNUfChRXgdzJfI9DB3RRbqlLMqc5oLWPs5qdebIhMspawuwMV5xE7wz9r\n\
1864lQFB7sktvB/lKGU2B5PoHXgB4KDu2nTy4omxxPMRXhTxqyX/cPcI32qvJSgaWRtf\n\
1865gO8xrdWw2rltNRtQDsv/v5/glnaENPn4ZDLlepkCgYAqag5Uxj0ps6WNE/D6IEWA\n\
1866eTGicEEJPJQB9bGrElna7WyOjntnO5miRmpM1jH39R417czBURmvZHO2oTnqghZF\n\
1867c/7P2kweQNU7vtM/iLcm8EyFRw2lVB3J/XVTEcPU6ZeZHlVbGtiKx3gukkMBc4Ct\n\
1868CQTyrvDSz5J6MQhLtbNHnQ==\n\
1869-----END PRIVATE KEY-----\n";
1870
1871    const JWK_N: &str = "r3Ijrq4ubhvPKq2d0-qlIU2Je51gx71RgQbAPJLt8VHzlvezxqVm03v2Kv946L1cQELAHv0KCDMlFBygnQygLCATvY_ThD7Ny3unE4Rcfv_WdVPXMOMAiGuEzbRgYcg4Km6iqTizZ1yyOlhHK8pJKFeZ1FB_-O2ktovQYqmZ-h_XhC9VR_NiF_8eH1jSBMl0AMSmbclC4M50eKFM9kD2mP3FraSvOQddrHLNSKS58xHY8uvbdvGUJ2Geza75fmhY0pL56sYaDSfVLS8Nv975XfLdy4egeL0zOL3SbLntXbpulp8hChMf0g7gkg8VjjX5nYbvweF1IIWkEtcATYhIbw";
1872    const JWK_E: &str = "AQAB";
1873
1874    fn test_jwk(kid: &str) -> Jwk {
1875        Jwk {
1876            kty: "RSA".to_string(),
1877            kid: Some(kid.to_string()),
1878            alg: Some("RS256".to_string()),
1879            n: Some(JWK_N.to_string()),
1880            e: Some(JWK_E.to_string()),
1881            x: None,
1882            y: None,
1883            crv: None,
1884        }
1885    }
1886
1887    fn sign(claims: serde_json::Value, kid: &str) -> String {
1888        let mut header = Header::new(Algorithm::RS256);
1889        header.kid = Some(kid.to_string());
1890        encode(
1891            &header,
1892            &claims,
1893            &EncodingKey::from_rsa_pem(PRIV_PEM.as_bytes()).unwrap(),
1894        )
1895        .unwrap()
1896    }
1897
1898    fn cfg(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1899        pairs
1900            .iter()
1901            .map(|(k, v)| (k.to_string(), v.clone()))
1902            .collect()
1903    }
1904
1905    #[test]
1906    fn test_interactive_requires_secret_and_redirect() {
1907        // bearer_only:false without session.secret / redirect_uri is rejected.
1908        let missing = cfg(&[
1909            (
1910                "discovery",
1911                serde_json::json!("https://idp/.well-known/openid-configuration"),
1912            ),
1913            ("bearer_only", serde_json::json!(false)),
1914            ("client_id", serde_json::json!("app")),
1915            ("client_secret", serde_json::json!("s")),
1916        ]);
1917        assert!(OpenidConnectPlugin::from_config(&missing, &PluginResources::empty()).is_err());
1918
1919        // Fully configured interactive mode builds.
1920        let ok = cfg(&[
1921            (
1922                "discovery",
1923                serde_json::json!("https://idp/.well-known/openid-configuration"),
1924            ),
1925            ("bearer_only", serde_json::json!(false)),
1926            ("client_id", serde_json::json!("app")),
1927            ("client_secret", serde_json::json!("s")),
1928            (
1929                "redirect_uri",
1930                serde_json::json!("https://app.example.com/oidc/callback"),
1931            ),
1932            (
1933                "session",
1934                serde_json::json!({ "secret": "cookie-signing-secret" }),
1935            ),
1936        ]);
1937        let plugin = OpenidConnectPlugin::from_config(&ok, &PluginResources::empty()).unwrap();
1938        let interactive = plugin.interactive.as_ref().unwrap();
1939        assert_eq!(interactive.redirect_path, "/oidc/callback");
1940        assert_eq!(interactive.session_cookie, "oidc_session");
1941        assert_eq!(interactive.flow_cookie, "oidc_session_flow");
1942        // Defaults: whole-origin cookie, one-hour lifetime.
1943        assert_eq!(interactive.cookie_path, "/");
1944        assert_eq!(interactive.session_lifetime, Duration::from_secs(3600));
1945    }
1946
1947    /// Two nodes on distinct subpaths can carry independent, path-scoped sessions
1948    /// with their own names and lifetimes — the /app_a vs /app_b case.
1949    #[test]
1950    fn test_interactive_custom_session_cookie() {
1951        let c = cfg(&[
1952            (
1953                "discovery",
1954                serde_json::json!("https://idp/.well-known/openid-configuration"),
1955            ),
1956            ("bearer_only", serde_json::json!(false)),
1957            ("client_id", serde_json::json!("app")),
1958            ("client_secret", serde_json::json!("s")),
1959            (
1960                "redirect_uri",
1961                serde_json::json!("https://app.example.com/app_a/callback"),
1962            ),
1963            (
1964                "session",
1965                serde_json::json!({
1966                    "secret": "cookie-signing-secret",
1967                    "cookie": { "name": "a_session", "path": "/app_a", "lifetime": 900 }
1968                }),
1969            ),
1970        ]);
1971        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
1972        let i = plugin.interactive.as_ref().unwrap();
1973        assert_eq!(i.session_cookie, "a_session");
1974        assert_eq!(i.flow_cookie, "a_session_flow");
1975        assert_eq!(i.cookie_path, "/app_a");
1976        assert_eq!(i.session_lifetime, Duration::from_secs(900));
1977    }
1978
1979    /// The Web UI's flat `session_cookie_*` keys are honored just like the
1980    /// nested `session.cookie.*` form, so session properties edited in the UI
1981    /// take effect.
1982    #[test]
1983    fn test_interactive_flat_ui_session_keys() {
1984        let c = cfg(&[
1985            (
1986                "discovery",
1987                serde_json::json!("https://idp/.well-known/openid-configuration"),
1988            ),
1989            ("bearer_only", serde_json::json!(false)),
1990            ("client_id", serde_json::json!("app")),
1991            ("client_secret", serde_json::json!("s")),
1992            (
1993                "redirect_uri",
1994                serde_json::json!("https://app.example.com/app_a/callback"),
1995            ),
1996            ("session_secret", serde_json::json!("cookie-signing-secret")),
1997            ("session_cookie_name", serde_json::json!("a_session")),
1998            ("session_cookie_path", serde_json::json!("/app_a")),
1999            ("session_cookie_lifetime", serde_json::json!(1200)),
2000        ]);
2001        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2002        let i = plugin.interactive.as_ref().unwrap();
2003        assert_eq!(i.session_cookie, "a_session");
2004        assert_eq!(i.flow_cookie, "a_session_flow");
2005        assert_eq!(i.cookie_path, "/app_a");
2006        assert_eq!(i.session_lifetime, Duration::from_secs(1200));
2007    }
2008
2009    /// A cookie path that does not cover the callback is rejected at load — it
2010    /// would starve the callback of the flow cookie and loop login forever.
2011    #[test]
2012    fn test_interactive_cookie_path_must_cover_callback() {
2013        let c = cfg(&[
2014            (
2015                "discovery",
2016                serde_json::json!("https://idp/.well-known/openid-configuration"),
2017            ),
2018            ("bearer_only", serde_json::json!(false)),
2019            ("client_id", serde_json::json!("app")),
2020            ("client_secret", serde_json::json!("s")),
2021            (
2022                "redirect_uri",
2023                serde_json::json!("https://app.example.com/app_a/callback"),
2024            ),
2025            (
2026                "session",
2027                serde_json::json!({
2028                    "secret": "s",
2029                    "cookie": { "path": "/app_b" }  // callback is under /app_a
2030                }),
2031            ),
2032        ]);
2033        // `.err().unwrap()` (not `unwrap_err()`): the Ok type isn't `Debug`.
2034        let err = OpenidConnectPlugin::from_config(&c, &PluginResources::empty())
2035            .err()
2036            .unwrap();
2037        assert!(
2038            err.contains("session.cookie.path"),
2039            "unexpected error: {err}"
2040        );
2041    }
2042
2043    #[test]
2044    fn test_bearer_only_default_has_no_interactive() {
2045        let c = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
2046        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2047        assert!(plugin.interactive.is_none());
2048    }
2049
2050    #[test]
2051    fn test_url_path() {
2052        assert_eq!(
2053            url_path("https://app.example.com/oidc/callback"),
2054            "/oidc/callback"
2055        );
2056        assert_eq!(url_path("https://app.example.com/cb?x=1"), "/cb");
2057        assert_eq!(url_path("https://app.example.com"), "/");
2058    }
2059
2060    #[test]
2061    fn test_pkce_challenge_is_stable_and_urlsafe() {
2062        // RFC 7636 test vector: verifier -> S256 challenge.
2063        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
2064        let challenge = pkce_challenge(verifier);
2065        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
2066        assert!(!challenge.contains('+') && !challenge.contains('/') && !challenge.contains('='));
2067    }
2068
2069    #[test]
2070    fn test_flow_and_session_seal_round_trip() {
2071        let sealer = CookieSealer::new("k");
2072        let flow = FlowState {
2073            state: "st".into(),
2074            nonce: "nc".into(),
2075            verifier: "vf".into(),
2076            original_uri: "/dashboard?tab=1".into(),
2077        };
2078        let sealed = sealer.seal(
2079            &serde_json::to_vec(&flow).unwrap(),
2080            Duration::from_secs(300),
2081        );
2082        let back: FlowState = serde_json::from_slice(&sealer.open(&sealed).unwrap()).unwrap();
2083        assert_eq!(back.state, "st");
2084        assert_eq!(back.original_uri, "/dashboard?tab=1");
2085
2086        let session = SessionData {
2087            claims: serde_json::json!({ "sub": "u1", "name": "Alice" }),
2088            access_token: Some("at".into()),
2089            refresh_token: None,
2090            expires_at: None,
2091        };
2092        let sealed = sealer.seal(
2093            &serde_json::to_vec(&session).unwrap(),
2094            Duration::from_secs(3600),
2095        );
2096        let back: SessionData = serde_json::from_slice(&sealer.open(&sealed).unwrap()).unwrap();
2097        assert_eq!(back.claims.get("sub").unwrap(), "u1");
2098        assert_eq!(back.access_token.as_deref(), Some("at"));
2099    }
2100
2101    #[test]
2102    fn test_rejects_no_validation_source() {
2103        let c = cfg(&[("client_id", serde_json::json!("x"))]);
2104        assert!(OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).is_err());
2105    }
2106
2107    #[test]
2108    fn test_accepts_jwks_and_introspection_configs() {
2109        let jwks = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
2110        assert!(OpenidConnectPlugin::from_config(&jwks, &PluginResources::empty()).is_ok());
2111
2112        let introspect = cfg(&[
2113            (
2114                "introspection_endpoint",
2115                serde_json::json!("https://idp/introspect"),
2116            ),
2117            ("client_id", serde_json::json!("id")),
2118            ("client_secret", serde_json::json!("secret")),
2119        ]);
2120        assert!(OpenidConnectPlugin::from_config(&introspect, &PluginResources::empty()).is_ok());
2121    }
2122
2123    #[test]
2124    fn test_rejects_unknown_alg() {
2125        let c = cfg(&[
2126            ("jwks_uri", serde_json::json!("https://idp/jwks")),
2127            (
2128                "token_signing_alg_values_expected",
2129                serde_json::json!("HS256"),
2130            ),
2131        ]);
2132        assert!(OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).is_err());
2133    }
2134
2135    #[test]
2136    fn test_select_jwk_by_kid() {
2137        let keys = vec![test_jwk("k1"), test_jwk("k2")];
2138        assert_eq!(
2139            select_jwk(&keys, Some("k2")).unwrap().kid.as_deref(),
2140            Some("k2")
2141        );
2142        assert!(select_jwk(&keys, Some("nope")).is_none());
2143        // No kid with multiple keys is ambiguous.
2144        assert!(select_jwk(&keys, None).is_none());
2145        // No kid with a single key resolves.
2146        let one = vec![test_jwk("only")];
2147        assert!(select_jwk(&one, None).is_some());
2148    }
2149
2150    #[test]
2151    fn test_jwk_to_decoding_key_rsa() {
2152        let jwk = test_jwk("k1");
2153        assert!(jwk_to_decoding_key(&jwk).is_ok());
2154        // Missing components fail.
2155        let mut bad = test_jwk("k1");
2156        bad.n = None;
2157        assert!(jwk_to_decoding_key(&bad).is_err());
2158    }
2159
2160    /// Regression: the *default* algorithm list spans RSA and EC, and
2161    /// jsonwebtoken rejects a Validation whose list contains any algorithm from a
2162    /// different family than the key. Verifying an RS256 token with the defaults
2163    /// therefore failed with `InvalidAlgorithm` — openid-connect did not work at
2164    /// all out of the box. Every pre-existing test passed a single-family list
2165    /// explicitly, which is exactly why none of them caught it.
2166    #[test]
2167    fn test_default_algs_verify_an_rs256_token() {
2168        let defaults = parse_allowed_algs(None).unwrap();
2169        assert!(
2170            defaults.len() > 1,
2171            "the default list must span families to be a regression test"
2172        );
2173
2174        let keys = vec![test_jwk("k1")];
2175        let token = sign(
2176            serde_json::json!({ "sub": "user-1", "exp": 9999999999u64 }),
2177            "k1",
2178        );
2179        let jwk = select_jwk(&keys, Some("k1")).unwrap();
2180        let key = jwk_to_decoding_key(jwk).unwrap();
2181
2182        // What the plugin now does: narrow the list to the key's family first.
2183        let algs = algs_for_key(&defaults, &jwk.kty).unwrap();
2184        let claims = decode_and_validate(&token, &key, &algs)
2185            .expect("an RS256 token must verify under the default algorithm list");
2186        assert_eq!(claims.get("sub").unwrap(), "user-1");
2187
2188        // Passing the unfiltered default list is what used to fail.
2189        assert!(
2190            decode_and_validate(&token, &key, &defaults).is_err(),
2191            "sanity: the unfiltered mixed-family list is rejected by jsonwebtoken"
2192        );
2193    }
2194
2195    #[test]
2196    fn test_algs_for_key_filters_by_family() {
2197        let defaults = parse_allowed_algs(None).unwrap();
2198
2199        let rsa = algs_for_key(&defaults, "RSA").unwrap();
2200        assert!(rsa.contains(&Algorithm::RS256));
2201        assert!(!rsa.contains(&Algorithm::ES256));
2202
2203        let ec = algs_for_key(&defaults, "EC").unwrap();
2204        assert!(ec.contains(&Algorithm::ES256));
2205        assert!(!ec.contains(&Algorithm::RS256));
2206
2207        // A key type nothing configured can verify is an error, not a silent pass.
2208        assert!(algs_for_key(&[Algorithm::RS256], "EC").is_err());
2209        assert!(algs_for_key(&defaults, "oct").is_err());
2210    }
2211
2212    #[test]
2213    fn test_verify_signed_token_end_to_end() {
2214        let keys = vec![test_jwk("k1")];
2215        let token = sign(
2216            serde_json::json!({ "sub": "user-1", "iss": "https://idp/", "aud": "my-api", "exp": 9999999999u64 }),
2217            "k1",
2218        );
2219        let jwk = select_jwk(&keys, Some("k1")).unwrap();
2220        let key = jwk_to_decoding_key(jwk).unwrap();
2221        let claims = decode_and_validate(&token, &key, &[Algorithm::RS256]).unwrap();
2222        assert_eq!(claims.get("sub").unwrap(), "user-1");
2223
2224        // Tampered signature (wrong kid selects the wrong key would fail; here
2225        // an expired token fails exp validation).
2226        let expired = sign(serde_json::json!({ "sub": "u", "exp": 100u64 }), "k1");
2227        assert!(decode_and_validate(&expired, &key, &[Algorithm::RS256]).is_err());
2228
2229        // Algorithm not in the allowed set is rejected.
2230        assert!(decode_and_validate(&token, &key, &[Algorithm::ES256]).is_err());
2231    }
2232
2233    #[test]
2234    fn test_validate_claims_issuer_and_audience() {
2235        let c = cfg(&[
2236            ("jwks_uri", serde_json::json!("https://idp/jwks")),
2237            ("client_id", serde_json::json!("my-api")),
2238            (
2239                "claim_validator",
2240                serde_json::json!({
2241                    "issuer": { "valid_issuers": ["https://idp/"] },
2242                    "audience": { "required": true, "match_with_client_id": true }
2243                }),
2244            ),
2245        ]);
2246        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2247
2248        let good: HashMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
2249            "iss": "https://idp/", "aud": ["my-api", "other"], "sub": "u"
2250        }))
2251        .unwrap();
2252        assert!(plugin.validate_claims(&good).is_ok());
2253
2254        // Wrong issuer.
2255        let bad_iss: HashMap<String, serde_json::Value> =
2256            serde_json::from_value(serde_json::json!({
2257                "iss": "https://evil/", "aud": "my-api"
2258            }))
2259            .unwrap();
2260        assert!(plugin.validate_claims(&bad_iss).is_err());
2261
2262        // Audience does not include client_id.
2263        let bad_aud: HashMap<String, serde_json::Value> =
2264            serde_json::from_value(serde_json::json!({
2265                "iss": "https://idp/", "aud": "someone-else"
2266            }))
2267            .unwrap();
2268        assert!(plugin.validate_claims(&bad_aud).is_err());
2269
2270        // Missing required audience.
2271        let no_aud: HashMap<String, serde_json::Value> =
2272            serde_json::from_value(serde_json::json!({
2273                "iss": "https://idp/"
2274            }))
2275            .unwrap();
2276        assert!(plugin.validate_claims(&no_aud).is_err());
2277    }
2278
2279    #[test]
2280    fn test_parse_introspection() {
2281        let active =
2282            serde_json::to_vec(&serde_json::json!({ "active": true, "sub": "u1" })).unwrap();
2283        let claims = parse_introspection(&active).unwrap();
2284        assert_eq!(claims.get("sub").unwrap(), "u1");
2285
2286        let inactive = serde_json::to_vec(&serde_json::json!({ "active": false })).unwrap();
2287        assert!(parse_introspection(&inactive).is_err());
2288    }
2289
2290    #[test]
2291    fn test_parse_bearer() {
2292        assert_eq!(parse_bearer("Bearer abc.def"), Some("abc.def"));
2293        assert_eq!(parse_bearer("bearer xyz"), Some("xyz"));
2294        assert_eq!(parse_bearer("Basic abc"), None);
2295        assert_eq!(parse_bearer("Bearer "), None);
2296        assert_eq!(parse_bearer("token"), None);
2297    }
2298
2299    // ---- Port-split coverage: denied / redirect (Ok) vs error (Err) --------
2300
2301    fn req_ctx(path: &str, query: HashMap<String, Vec<String>>) -> crate::context::Context {
2302        crate::context::Context::new(crate::context::GatewayRequest {
2303            method: "GET".into(),
2304            path: path.into(),
2305            host: "app.example.com".into(),
2306            scheme: "https".into(),
2307            headers: HashMap::new(),
2308            query_params: query,
2309            body: Bytes::new(),
2310            remote_addr: "1.2.3.4:5".into(),
2311            protocol: crate::context::Protocol::Http1,
2312        })
2313    }
2314
2315    fn with_bearer(mut ctx: crate::context::Context, token: &str) -> crate::context::Context {
2316        ctx.request
2317            .headers
2318            .insert("authorization".to_string(), vec![format!("Bearer {token}")]);
2319        ctx
2320    }
2321
2322    #[tokio::test]
2323    async fn test_bearer_missing_token_denied() {
2324        let c = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
2325        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2326
2327        let out = plugin.execute(req_ctx("/", HashMap::new())).await.unwrap();
2328        assert_eq!(out.port, Some("denied"));
2329        assert_eq!(out.context.response.status_code, 401);
2330        assert!(out
2331            .context
2332            .response
2333            .headers
2334            .contains_key("www-authenticate"));
2335        // The message names the mode, so a misconfigured interactive setup
2336        // that silently fell back to bearer mode is recognizable from the body.
2337        let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
2338        assert!(
2339            body.contains("No bearer token found in request")
2340                && body.contains("bearer_only is true")
2341                && body.contains("bearer_only: false"),
2342            "{body}"
2343        );
2344    }
2345
2346    /// Regression: before the port split, every failure (deliberate or
2347    /// infra) flowed through the same `Err`. A JWKS endpoint that is
2348    /// unreachable (nothing listening) is a genuine provider failure and
2349    /// must stay on `Err`, not be folded into `denied`.
2350    #[tokio::test]
2351    async fn test_bearer_jwks_unreachable_stays_on_error_port() {
2352        let c = cfg(&[
2353            ("jwks_uri", serde_json::json!("http://127.0.0.1:1/jwks")),
2354            ("timeout", serde_json::json!(1)),
2355        ]);
2356        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2357
2358        // A structurally valid JWT so `decode_header` succeeds and the
2359        // failure comes from the (unreachable) JWKS callout, not header parsing.
2360        let token = sign(serde_json::json!({ "sub": "user-1" }), "k1");
2361        let err = plugin
2362            .execute(with_bearer(req_ctx("/", HashMap::new()), &token))
2363            .await
2364            .unwrap_err();
2365        assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2366    }
2367
2368    /// Same regression, via the discovery path: an unreachable discovery
2369    /// document is also a genuine provider failure.
2370    #[tokio::test]
2371    async fn test_bearer_discovery_unreachable_stays_on_error_port() {
2372        let c = cfg(&[
2373            (
2374                "discovery",
2375                serde_json::json!("http://127.0.0.1:1/.well-known/openid-configuration"),
2376            ),
2377            ("timeout", serde_json::json!(1)),
2378        ]);
2379        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2380
2381        let token = sign(serde_json::json!({ "sub": "user-1" }), "k1");
2382        let err = plugin
2383            .execute(with_bearer(req_ctx("/", HashMap::new()), &token))
2384            .await
2385            .unwrap_err();
2386        assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2387    }
2388
2389    /// Same regression, via introspection: an unreachable introspection
2390    /// endpoint is a genuine provider failure, not a token denial.
2391    #[tokio::test]
2392    async fn test_bearer_introspection_unreachable_stays_on_error_port() {
2393        let c = cfg(&[
2394            (
2395                "introspection_endpoint",
2396                serde_json::json!("http://127.0.0.1:1/introspect"),
2397            ),
2398            ("client_id", serde_json::json!("id")),
2399            ("client_secret", serde_json::json!("secret")),
2400            ("timeout", serde_json::json!(1)),
2401        ]);
2402        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2403
2404        let err = plugin
2405            .execute(with_bearer(req_ctx("/", HashMap::new()), "opaque-token"))
2406            .await
2407            .unwrap_err();
2408        assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2409    }
2410
2411    /// An introspection response that reports the token inactive is a
2412    /// deliberate rejection and must stay `denied`, distinct from the
2413    /// callout-unreachable case above.
2414    #[test]
2415    fn test_parse_introspection_inactive_is_denied_not_infra() {
2416        let inactive = serde_json::to_vec(&serde_json::json!({ "active": false })).unwrap();
2417        match parse_introspection(&inactive) {
2418            Err(TokenError::Denied(_)) => {}
2419            other => panic!("expected Denied, got {other:?}"),
2420        }
2421        let bad_json = b"not json";
2422        match parse_introspection(bad_json) {
2423            Err(TokenError::Infra(_)) => {}
2424            other => panic!("expected Infra, got {other:?}"),
2425        }
2426    }
2427
2428    fn interactive_explicit_cfg() -> HashMap<String, serde_json::Value> {
2429        cfg(&[
2430            (
2431                "authorization_endpoint",
2432                serde_json::json!("https://idp.example.com/authorize"),
2433            ),
2434            (
2435                "token_endpoint",
2436                serde_json::json!("http://127.0.0.1:1/token"),
2437            ),
2438            (
2439                "jwks_uri",
2440                serde_json::json!("https://idp.example.com/jwks"),
2441            ),
2442            ("bearer_only", serde_json::json!(false)),
2443            ("client_id", serde_json::json!("app")),
2444            ("client_secret", serde_json::json!("s")),
2445            (
2446                "redirect_uri",
2447                serde_json::json!("https://app.example.com/oidc/callback"),
2448            ),
2449            (
2450                "session",
2451                serde_json::json!({ "secret": "cookie-signing-secret" }),
2452            ),
2453        ])
2454    }
2455
2456    #[tokio::test]
2457    async fn test_interactive_begin_login_redirects() {
2458        let plugin = OpenidConnectPlugin::from_config(
2459            &interactive_explicit_cfg(),
2460            &PluginResources::empty(),
2461        )
2462        .unwrap();
2463
2464        let out = plugin
2465            .execute(req_ctx("/dashboard", HashMap::new()))
2466            .await
2467            .unwrap();
2468        assert_eq!(out.port, Some("redirect"));
2469        assert_eq!(out.context.response.status_code, 302);
2470        let location = &out.context.response.headers.get("location").unwrap()[0];
2471        assert!(
2472            location.starts_with("https://idp.example.com/authorize?"),
2473            "{location}"
2474        );
2475        let set = &out.context.response.headers.get("set-cookie").unwrap()[0];
2476        assert!(set.starts_with("oidc_session_flow="), "{set}");
2477    }
2478
2479    /// Interactive mode, no session, discovery unreachable: the node cannot
2480    /// even build the redirect. That is a provider failure on the error port
2481    /// (502), not a `denied` 401 — a browser user is not "unauthorized", the
2482    /// IdP is unreachable.
2483    #[tokio::test]
2484    async fn test_interactive_discovery_unreachable_is_provider_error() {
2485        let c = cfg(&[
2486            (
2487                "discovery",
2488                serde_json::json!("http://127.0.0.1:1/.well-known/openid-configuration"),
2489            ),
2490            ("timeout", serde_json::json!(1)),
2491            ("bearer_only", serde_json::json!(false)),
2492            ("client_id", serde_json::json!("app")),
2493            ("client_secret", serde_json::json!("s")),
2494            (
2495                "redirect_uri",
2496                serde_json::json!("https://app.example.com/oidc/callback"),
2497            ),
2498            (
2499                "session",
2500                serde_json::json!({ "secret": "cookie-signing-secret" }),
2501            ),
2502        ]);
2503        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2504
2505        let err = plugin
2506            .execute(req_ctx("/dashboard", HashMap::new()))
2507            .await
2508            .unwrap_err();
2509        assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2510        assert!(
2511            err.error.message.contains("discovery fetch failed"),
2512            "{}",
2513            err.error.message
2514        );
2515    }
2516
2517    #[tokio::test]
2518    async fn test_interactive_logout_redirects() {
2519        let mut c = interactive_explicit_cfg();
2520        c.insert("logout_path".to_string(), serde_json::json!("/logout"));
2521        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2522
2523        let out = plugin
2524            .execute(req_ctx("/logout", HashMap::new()))
2525            .await
2526            .unwrap();
2527        assert_eq!(out.port, Some("redirect"));
2528        assert_eq!(out.context.response.status_code, 302);
2529        assert_eq!(
2530            out.context.response.headers.get("location").unwrap()[0],
2531            "/"
2532        );
2533    }
2534
2535    #[tokio::test]
2536    async fn test_interactive_callback_missing_flow_cookie_denied() {
2537        let plugin = OpenidConnectPlugin::from_config(
2538            &interactive_explicit_cfg(),
2539            &PluginResources::empty(),
2540        )
2541        .unwrap();
2542
2543        let mut query = HashMap::new();
2544        query.insert("code".to_string(), vec!["c".to_string()]);
2545        query.insert("state".to_string(), vec!["st".to_string()]);
2546        let out = plugin
2547            .execute(req_ctx("/oidc/callback", query))
2548            .await
2549            .unwrap();
2550        assert_eq!(out.port, Some("denied"));
2551        assert_eq!(out.context.response.status_code, 401);
2552    }
2553
2554    #[tokio::test]
2555    async fn test_interactive_callback_state_mismatch_denied() {
2556        let plugin = OpenidConnectPlugin::from_config(
2557            &interactive_explicit_cfg(),
2558            &PluginResources::empty(),
2559        )
2560        .unwrap();
2561        let flow = FlowState {
2562            state: "expected".into(),
2563            nonce: "nonce".into(),
2564            verifier: "verifier".into(),
2565            original_uri: "/dashboard".into(),
2566        };
2567        let sealer = CookieSealer::new("cookie-signing-secret");
2568        let sealed = sealer.seal(
2569            &serde_json::to_vec(&flow).unwrap(),
2570            Duration::from_secs(300),
2571        );
2572
2573        let mut query = HashMap::new();
2574        query.insert("code".to_string(), vec!["c".to_string()]);
2575        query.insert("state".to_string(), vec!["WRONG".to_string()]);
2576        let mut c = req_ctx("/oidc/callback", query);
2577        c.request.headers.insert(
2578            "cookie".to_string(),
2579            vec![format!("oidc_session_flow={sealed}")],
2580        );
2581
2582        let out = plugin.execute(c).await.unwrap();
2583        assert_eq!(out.port, Some("denied"));
2584        assert_eq!(out.context.response.status_code, 401);
2585    }
2586
2587    /// The callback's code-exchange call to the token endpoint is a genuine
2588    /// provider callout; when it is unreachable that must stay `Err`, not be
2589    /// folded into `denied` alongside the CSRF/state checks above.
2590    #[tokio::test]
2591    async fn test_interactive_callback_token_endpoint_unreachable_stays_on_error_port() {
2592        let plugin = OpenidConnectPlugin::from_config(
2593            &interactive_explicit_cfg(),
2594            &PluginResources::empty(),
2595        )
2596        .unwrap();
2597        let flow = FlowState {
2598            state: "matching".into(),
2599            nonce: "nonce".into(),
2600            verifier: "verifier".into(),
2601            original_uri: "/dashboard".into(),
2602        };
2603        let sealer = CookieSealer::new("cookie-signing-secret");
2604        let sealed = sealer.seal(
2605            &serde_json::to_vec(&flow).unwrap(),
2606            Duration::from_secs(300),
2607        );
2608
2609        let mut query = HashMap::new();
2610        query.insert("code".to_string(), vec!["c".to_string()]);
2611        query.insert("state".to_string(), vec!["matching".to_string()]);
2612        let mut c = req_ctx("/oidc/callback", query);
2613        c.request.headers.insert(
2614            "cookie".to_string(),
2615            vec![format!("oidc_session_flow={sealed}")],
2616        );
2617
2618        let err = plugin.execute(c).await.unwrap_err();
2619        assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2620    }
2621
2622    // ---- Redis session storage ---------------------------------------
2623
2624    #[cfg(feature = "redis-store")]
2625    fn resources_with_fake_store() -> (
2626        std::sync::Arc<crate::plugins::resources::PluginResources>,
2627        std::sync::Arc<crate::sessions::FakeSessionStore>,
2628    ) {
2629        let fake = std::sync::Arc::new(crate::sessions::FakeSessionStore::default());
2630        let resources = crate::plugins::resources::PluginResources::empty();
2631        resources.stores.store(std::sync::Arc::new(
2632            crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
2633        ));
2634        (resources, fake)
2635    }
2636
2637    /// redis storage requires a store name; unknown stores fail at config.
2638    #[test]
2639    fn test_session_storage_redis_requires_store() {
2640        let mut cfg = interactive_explicit_cfg();
2641        cfg.insert(
2642            "session".to_string(),
2643            serde_json::json!({"secret": "cookie-signing-secret", "storage": "redis"}),
2644        );
2645        // `.err().unwrap()` (not `unwrap_err()`): the Ok type isn't `Debug`.
2646        let err = OpenidConnectPlugin::from_config(&cfg, &PluginResources::empty())
2647            .err()
2648            .unwrap();
2649        assert!(err.contains("requires 'session.store'"), "{err}");
2650    }
2651
2652    /// In redis mode a valid id-cookie authenticates from the store, and a
2653    /// store outage is a 503 on the error port — never a silent re-login.
2654    #[cfg(feature = "redis-store")]
2655    #[tokio::test]
2656    async fn test_redis_session_read_and_store_outage_503() {
2657        use crate::sessions::SessionStore as _;
2658
2659        let (resources, fake) = resources_with_fake_store();
2660        let mut cfg = interactive_explicit_cfg();
2661        cfg.insert(
2662            "session".to_string(),
2663            serde_json::json!({
2664                "secret": "cookie-signing-secret",
2665                "storage": "redis",
2666                "store": "s1"
2667            }),
2668        );
2669        let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2670
2671        // Establish a session by hand: seal SessionData, put under an id.
2672        let sealer = CookieSealer::new("cookie-signing-secret");
2673        let data = serde_json::json!({"claims": {"sub": "u1"}});
2674        let sealed = sealer.seal(&serde_json::to_vec(&data).unwrap(), Duration::from_secs(60));
2675        let id = crate::sessions::SessionId::random();
2676        let meta = crate::sessions::SessionMeta {
2677            id: String::new(),
2678            subject: "u1".to_string(),
2679            plugin: "openid-connect".to_string(),
2680            policy: String::new(),
2681            route: String::new(),
2682            created_at: 0,
2683            expires_at: 0,
2684        };
2685        fake.put(&id, sealed.as_bytes(), Duration::from_secs(60), &meta)
2686            .await
2687            .unwrap();
2688
2689        let mut ctx = req_ctx("/api", HashMap::new());
2690        ctx.request.headers.insert(
2691            "cookie".to_string(),
2692            vec![format!("oidc_session={}", id.as_str())],
2693        );
2694        let out = plugin.execute(ctx).await.unwrap();
2695        assert!(out.port.is_none(), "valid store session must pass");
2696        assert_eq!(out.context.message["user_id"], "u1");
2697
2698        // Outage: same request, failing store.
2699        fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
2700        let mut ctx = req_ctx("/api", HashMap::new());
2701        ctx.request.headers.insert(
2702            "cookie".to_string(),
2703            vec![format!("oidc_session={}", id.as_str())],
2704        );
2705        let err = plugin.execute(ctx).await.unwrap_err();
2706        assert_eq!(err.error.code, "SESSION_STORE_ERROR");
2707        assert_eq!(err.context.response.status_code, 503);
2708    }
2709
2710    /// Redis-mode refresh: expired access token + refresh_token triggers a
2711    /// locked refresh; the session is rewritten under the same id. The IdP
2712    /// being unreachable falls back to re-login (redirect), not 503.
2713    #[cfg(feature = "redis-store")]
2714    #[tokio::test]
2715    async fn test_redis_refresh_lock_and_fallback() {
2716        use crate::sessions::SessionStore as _;
2717
2718        let (resources, fake) = resources_with_fake_store();
2719        let mut cfg = interactive_explicit_cfg(); // token_endpoint: http://127.0.0.1:1 (unreachable)
2720        cfg.insert(
2721            "session".to_string(),
2722            serde_json::json!({
2723                "secret": "cookie-signing-secret",
2724                "storage": "redis",
2725                "store": "s1"
2726            }),
2727        );
2728        let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2729
2730        let sealer = CookieSealer::new("cookie-signing-secret");
2731        // Stale access token (expires_at in the past) + a refresh token.
2732        let data = serde_json::json!({
2733            "claims": {"sub": "u1"},
2734            "access_token": "old",
2735            "refresh_token": "rt",
2736            "expires_at": 1
2737        });
2738        let sealed = sealer.seal(
2739            &serde_json::to_vec(&data).unwrap(),
2740            Duration::from_secs(600),
2741        );
2742        let id = crate::sessions::SessionId::random();
2743        let meta = crate::sessions::SessionMeta {
2744            id: String::new(),
2745            subject: "u1".into(),
2746            plugin: "openid-connect".into(),
2747            policy: String::new(),
2748            route: String::new(),
2749            created_at: 0,
2750            expires_at: 0,
2751        };
2752        fake.put(&id, sealed.as_bytes(), Duration::from_secs(600), &meta)
2753            .await
2754            .unwrap();
2755
2756        let mut ctx = req_ctx("/api", HashMap::new());
2757        ctx.request.headers.insert(
2758            "cookie".to_string(),
2759            vec![format!("oidc_session={}", id.as_str())],
2760        );
2761        // Token endpoint unreachable → refresh fails → fall back to re-login.
2762        let out = plugin.execute(ctx).await.unwrap();
2763        assert_eq!(out.port, Some("redirect"), "failed refresh re-enters login");
2764        // The lock was released (unlock on the failure path).
2765        assert!(fake.try_lock(&id, Duration::from_secs(1)).await.unwrap());
2766    }
2767
2768    /// `session.refresh: false` disables the refresh check entirely: a stale
2769    /// access token + refresh_token pass through unchanged (no redirect, no
2770    /// lock taken) — the plugin behaves exactly as it did before Task 7.
2771    #[cfg(feature = "redis-store")]
2772    #[tokio::test]
2773    async fn test_redis_refresh_disabled_passes_stale_session_through() {
2774        use crate::sessions::SessionStore as _;
2775
2776        let (resources, fake) = resources_with_fake_store();
2777        let mut cfg = interactive_explicit_cfg();
2778        cfg.insert(
2779            "session".to_string(),
2780            serde_json::json!({
2781                "secret": "cookie-signing-secret",
2782                "storage": "redis",
2783                "store": "s1",
2784                "refresh": false
2785            }),
2786        );
2787        let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2788
2789        let sealer = CookieSealer::new("cookie-signing-secret");
2790        let data = serde_json::json!({
2791            "claims": {"sub": "u1"},
2792            "access_token": "old",
2793            "refresh_token": "rt",
2794            "expires_at": 1
2795        });
2796        let sealed = sealer.seal(
2797            &serde_json::to_vec(&data).unwrap(),
2798            Duration::from_secs(600),
2799        );
2800        let id = crate::sessions::SessionId::random();
2801        let meta = crate::sessions::SessionMeta {
2802            id: String::new(),
2803            subject: "u1".into(),
2804            plugin: "openid-connect".into(),
2805            policy: String::new(),
2806            route: String::new(),
2807            created_at: 0,
2808            expires_at: 0,
2809        };
2810        fake.put(&id, sealed.as_bytes(), Duration::from_secs(600), &meta)
2811            .await
2812            .unwrap();
2813
2814        let mut ctx = req_ctx("/api", HashMap::new());
2815        ctx.request.headers.insert(
2816            "cookie".to_string(),
2817            vec![format!("oidc_session={}", id.as_str())],
2818        );
2819        let out = plugin.execute(ctx).await.unwrap();
2820        assert!(
2821            out.port.is_none(),
2822            "disabled refresh must pass through as-is"
2823        );
2824        assert_eq!(out.context.message["user_id"], "u1");
2825        // No lock was ever taken — try_lock succeeds trivially.
2826        assert!(fake.try_lock(&id, Duration::from_secs(1)).await.unwrap());
2827    }
2828
2829    /// Minimal one-shot HTTP server that answers exactly one request with a
2830    /// fixed status line, `content-type: application/json`, and body.
2831    /// Returns its port. Stands in for a token endpoint in refresh tests
2832    /// (cribbed from `traffic_split.rs`'s body-returning `spawn_status_server`).
2833    #[cfg(feature = "redis-store")]
2834    async fn spawn_json_server(status_line: &'static str, body: &'static str) -> u16 {
2835        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2836        let port = listener.local_addr().unwrap().port();
2837        tokio::spawn(async move {
2838            if let Ok((mut stream, _)) = listener.accept().await {
2839                use tokio::io::{AsyncReadExt, AsyncWriteExt};
2840                let mut buf = [0u8; 4096];
2841                let _ = stream.read(&mut buf).await;
2842                let _ = stream
2843                    .write_all(
2844                        format!(
2845                            "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\n\
2846                             content-length: {}\r\n\r\n{}",
2847                            body.len(),
2848                            body
2849                        )
2850                        .as_bytes(),
2851                    )
2852                    .await;
2853                let _ = stream.shutdown().await;
2854            }
2855        });
2856        port
2857    }
2858
2859    /// Winner-success refresh: the token endpoint returns a fresh
2860    /// access_token + rotated refresh_token + expires_in (no id_token). The
2861    /// request passes authenticated with the existing claims preserved
2862    /// (nothing to re-validate), the store record under the SAME id is
2863    /// rewritten with the new tokens and a future `expires_at`, and the
2864    /// lock is released.
2865    #[cfg(feature = "redis-store")]
2866    #[tokio::test]
2867    async fn test_redis_refresh_winner_success_rewrites_session() {
2868        use crate::sessions::SessionStore as _;
2869
2870        let port = spawn_json_server(
2871            "200 OK",
2872            r#"{"access_token":"new-at","refresh_token":"new-rt","expires_in":3600}"#,
2873        )
2874        .await;
2875
2876        let (resources, fake) = resources_with_fake_store();
2877        let mut cfg = interactive_explicit_cfg();
2878        cfg.insert(
2879            "token_endpoint".to_string(),
2880            serde_json::json!(format!("http://127.0.0.1:{port}/token")),
2881        );
2882        cfg.insert(
2883            "session".to_string(),
2884            serde_json::json!({
2885                "secret": "cookie-signing-secret",
2886                "storage": "redis",
2887                "store": "s1"
2888            }),
2889        );
2890        let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2891
2892        let sealer = CookieSealer::new("cookie-signing-secret");
2893        // Stale access token (expires_at in the past) + a refresh token.
2894        let data = serde_json::json!({
2895            "claims": {"sub": "u1"},
2896            "access_token": "old",
2897            "refresh_token": "rt",
2898            "expires_at": 1
2899        });
2900        let sealed = sealer.seal(
2901            &serde_json::to_vec(&data).unwrap(),
2902            Duration::from_secs(600),
2903        );
2904        let id = crate::sessions::SessionId::random();
2905        let meta = crate::sessions::SessionMeta {
2906            id: String::new(),
2907            subject: "u1".into(),
2908            plugin: "openid-connect".into(),
2909            policy: String::new(),
2910            route: String::new(),
2911            created_at: 0,
2912            expires_at: 0,
2913        };
2914        fake.put(&id, sealed.as_bytes(), Duration::from_secs(600), &meta)
2915            .await
2916            .unwrap();
2917
2918        let mut ctx = req_ctx("/api", HashMap::new());
2919        ctx.request.headers.insert(
2920            "cookie".to_string(),
2921            vec![format!("oidc_session={}", id.as_str())],
2922        );
2923        let out = plugin.execute(ctx).await.unwrap();
2924
2925        // (a) passes authenticated; claims are preserved (no id_token came
2926        // back to re-validate).
2927        assert!(out.port.is_none(), "successful refresh must pass through");
2928        assert_eq!(out.context.message["user_id"], "u1");
2929
2930        // (b) the store record under the SAME id was rewritten with the new
2931        // tokens and a future expires_at.
2932        let stored = fake.get(&id).await.unwrap().unwrap();
2933        let sealed_str = String::from_utf8(stored).unwrap();
2934        let opened = sealer.open(&sealed_str).unwrap();
2935        let value: serde_json::Value = serde_json::from_slice(&opened).unwrap();
2936        assert_eq!(value["access_token"], "new-at");
2937        assert_eq!(value["refresh_token"], "new-rt");
2938        assert!(
2939            value["expires_at"].as_u64().unwrap() > now_unix(),
2940            "expires_at must be in the future: {value}"
2941        );
2942
2943        // (c) the lock was released.
2944        assert!(fake.try_lock(&id, Duration::from_secs(1)).await.unwrap());
2945    }
2946}