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 its **error port** whenever it
26//! needs the browser to move (the 302 to the IdP, the post-callback 302 back to
27//! the original URL, or a `401`); the prepared response already sits on the
28//! context. Wire the node's `error` edge to `client.in`. Only a request that
29//! arrives with a valid session cookie continues out the `success` port toward
30//! the upstream. The node must be on a route whose match rule also covers the
31//! `redirect_uri` path so the callback reaches it.
32//!
33//! # Deviations from APISIX
34//!
35//! - **No server-side session revocation.** Sessions live entirely in the
36//!   encrypted cookie, so a session cannot be invalidated before its
37//!   `session.cookie.lifetime` expiry without a shared denylist (a future
38//!   feature). Use short lifetimes. This is the standard client-side-cookie
39//!   trade-off APISIX shares when configured for cookie sessions.
40//! - **No token refresh** in this version: when the session cookie expires the
41//!   user re-authenticates (a fresh, fast redirect round-trip if the IdP
42//!   session is still valid).
43//! - Only the Authorization Code grant is implemented (the OIDC gateway case);
44//!   implicit/hybrid flows are not.
45
46use async_trait::async_trait;
47use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
48use base64::engine::general_purpose::URL_SAFE_NO_PAD;
49use base64::Engine;
50use bytes::Bytes;
51use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
52use ring::digest::{digest, SHA256};
53use ring::rand::{SecureRandom, SystemRandom};
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56use std::sync::Arc;
57use std::time::{Duration, Instant};
58use tokio::sync::Mutex;
59
60use crate::context::{Context, GatewayError};
61use crate::outbound::{OutboundRequest, OutboundResponse};
62use crate::plugins::resources::PluginResources;
63use crate::plugins::util::cookie_session::{
64    build_set_cookie, delete_cookie, path_covers, read_cookie, CookieAttrs, CookieSealer, SameSite,
65};
66use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
67
68/// Transient state carried in the short-lived flow cookie across the redirect
69/// to the IdP and back to the callback (CSRF `state`, replay `nonce`, PKCE
70/// `verifier`, and where to send the browser after login).
71#[derive(Serialize, Deserialize)]
72struct FlowState {
73    state: String,
74    nonce: String,
75    verifier: String,
76    original_uri: String,
77}
78
79/// The sealed session payload: the validated identity, kept small.
80#[derive(Serialize, Deserialize)]
81struct SessionData {
82    claims: serde_json::Value,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    access_token: Option<String>,
85}
86
87/// Interactive-mode configuration, present only when `bearer_only: false`.
88struct Interactive {
89    sealer: CookieSealer,
90    authorization_endpoint_cfg: Option<String>,
91    token_endpoint_cfg: Option<String>,
92    redirect_uri: String,
93    /// Path portion of `redirect_uri`, matched to detect the callback.
94    redirect_path: String,
95    scope: String,
96    session_cookie: String,
97    flow_cookie: String,
98    /// `Path` attribute for the session and flow cookies. Scoping this to the
99    /// app's subpath (e.g. `/app_a`) lets two openid-connect nodes on distinct
100    /// subpaths hold independent sessions in the browser. Defaults to `/`.
101    cookie_path: String,
102    session_lifetime: Duration,
103    logout_path: Option<String>,
104    post_logout_redirect_uri: String,
105    authz_endpoint_resolved: Mutex<Option<String>>,
106    token_endpoint_resolved: Mutex<Option<String>>,
107}
108
109/// A single JSON Web Key from a provider's JWKS document.
110#[derive(Debug, Clone, Deserialize)]
111// Mirrors the JWK spec; some members are deserialized for completeness but not
112// consulted during verification.
113#[allow(dead_code)]
114struct Jwk {
115    kty: String,
116    kid: Option<String>,
117    alg: Option<String>,
118    // RSA
119    n: Option<String>,
120    e: Option<String>,
121    // EC
122    x: Option<String>,
123    y: Option<String>,
124    crv: Option<String>,
125}
126
127/// A JWKS document (`{ "keys": [ ... ] }`).
128#[derive(Debug, Clone, Deserialize)]
129struct JwkSet {
130    keys: Vec<Jwk>,
131}
132
133/// Cached JWKS with the time it was fetched, for TTL-based expiry.
134struct CachedJwks {
135    keys: Vec<Jwk>,
136    fetched_at: Instant,
137}
138
139/// Authenticates requests by validating a bearer access token via JWKS
140/// signature verification or token introspection.
141pub struct OpenidConnectPlugin {
142    /// Well-known discovery URL; resolves `jwks_uri` when not given directly.
143    discovery: Option<String>,
144    /// Explicit JWKS endpoint (takes precedence over discovery).
145    jwks_uri_cfg: Option<String>,
146    /// Introspection endpoint; used only when no JWKS source is configured.
147    introspection_endpoint: Option<String>,
148    client_id: Option<String>,
149    client_secret: Option<String>,
150    ssl_verify: bool,
151    timeout: Duration,
152    /// Signature algorithms the token is allowed to be signed with.
153    allowed_algs: Vec<Algorithm>,
154    /// Issuers accepted for the `iss` claim; empty = do not validate issuer.
155    valid_issuers: Vec<String>,
156    audience_claim: String,
157    audience_required: bool,
158    audience_match_client_id: bool,
159    set_userinfo_header: bool,
160    set_access_token_header: bool,
161    access_token_in_authorization_header: bool,
162    /// True when a JWKS source (discovery/jwks_uri) is configured.
163    use_jwks: bool,
164    jwk_ttl: Duration,
165    resources: Arc<PluginResources>,
166    /// Lazily-resolved JWKS URI (from discovery), cached for the process.
167    jwks_uri_resolved: Mutex<Option<String>>,
168    jwks_cache: Mutex<Option<CachedJwks>>,
169    /// Interactive login flow; `None` in bearer-only mode.
170    interactive: Option<Interactive>,
171}
172
173impl OpenidConnectPlugin {
174    /// Builds the plugin from node config (bearer-only subset).
175    ///
176    /// Accepted keys:
177    /// - `discovery` (string): OIDC discovery URL
178    ///   (`.../.well-known/openid-configuration`); used to resolve `jwks_uri`.
179    /// - `jwks_uri` (string): explicit JWKS endpoint; takes precedence over
180    ///   `discovery` for signature verification.
181    /// - `introspection_endpoint` (string): RFC 7662 introspection endpoint;
182    ///   used only when no JWKS source is configured.
183    /// - `client_id` (string): OAuth client id (introspection auth / audience).
184    /// - `client_secret` (string): OAuth client secret (introspection auth).
185    /// - `bearer_only` (bool, default `true`): **must be true**. `false` is
186    ///   rejected at load — interactive login is not supported (see module docs).
187    /// - `token_signing_alg_values_expected` (string or array): permitted
188    ///   signature algorithms (e.g. `RS256`, `ES256`). Defaults to
189    ///   `RS256, RS384, RS512, ES256, ES384`.
190    /// - `claim_validator.issuer.valid_issuers` (array): accepted `iss` values.
191    /// - `claim_validator.audience.{claim,required,match_with_client_id}`:
192    ///   audience validation (claim defaults to `aud`).
193    /// - `set_userinfo_header` (bool, default `true`): base64-encode the claims
194    ///   into the `X-Userinfo` request header for the upstream.
195    /// - `set_access_token_header` (bool, default `true`) /
196    ///   `access_token_in_authorization_header` (bool, default `false`):
197    ///   forward the validated access token as `X-Access-Token` (or leave it in
198    ///   `Authorization`).
199    /// - `ssl_verify` (bool, default `true`), `timeout` (integer seconds,
200    ///   default `3`).
201    ///
202    /// Rejected at load: `bearer_only: false`, and configs with neither a JWKS
203    /// source (`discovery`/`jwks_uri`) nor an `introspection_endpoint`.
204    ///
205    /// ```yaml
206    /// type: openid-connect
207    /// config:
208    ///   discovery: https://idp.example.com/.well-known/openid-configuration
209    ///   bearer_only: true
210    ///   client_id: my-api
211    ///   token_signing_alg_values_expected: RS256
212    ///   claim_validator:
213    ///     issuer:
214    ///       valid_issuers: ["https://idp.example.com/"]
215    ///     audience:
216    ///       required: true
217    ///       match_with_client_id: true
218    /// ```
219    pub fn from_config(
220        config: &HashMap<String, serde_json::Value>,
221        resources: &Arc<PluginResources>,
222    ) -> Result<Self, String> {
223        let bearer_only = config
224            .get("bearer_only")
225            .and_then(|v| v.as_bool())
226            .unwrap_or(true);
227
228        let discovery = string_opt(config, "discovery");
229        let jwks_uri_cfg = string_opt(config, "jwks_uri");
230        let introspection_endpoint = string_opt(config, "introspection_endpoint");
231
232        let use_jwks = discovery.is_some() || jwks_uri_cfg.is_some();
233        if !use_jwks && introspection_endpoint.is_none() {
234            return Err(
235                "openid-connect: requires a JWKS source ('discovery' or 'jwks_uri') \
236                 or an 'introspection_endpoint'"
237                    .to_string(),
238            );
239        }
240
241        let client_id = string_opt(config, "client_id");
242        let client_secret = string_opt(config, "client_secret");
243
244        // Introspection needs client credentials to authenticate the call.
245        if !use_jwks && (client_id.is_none() || client_secret.is_none()) {
246            return Err(
247                "openid-connect: introspection requires 'client_id' and 'client_secret'"
248                    .to_string(),
249            );
250        }
251
252        // Interactive login (bearer_only: false) needs the Authorization Code
253        // machinery: a session secret, client credentials, a redirect URI, and
254        // token/authorization endpoints (via discovery or explicit config). The
255        // id_token is validated with the same JWKS path as bearer mode.
256        let interactive = if bearer_only {
257            None
258        } else {
259            if !use_jwks {
260                return Err("openid-connect: interactive login requires a JWKS source \
261                            ('discovery' or 'jwks_uri') to validate the id_token"
262                    .to_string());
263            }
264            if client_id.is_none() || client_secret.is_none() {
265                return Err(
266                    "openid-connect: interactive login requires 'client_id' and \
267                            'client_secret'"
268                        .to_string(),
269                );
270            }
271            Some(build_interactive(config, &discovery)?)
272        };
273
274        let allowed_algs = parse_allowed_algs(config.get("token_signing_alg_values_expected"))?;
275
276        let valid_issuers = read_valid_issuers(config);
277        let (audience_claim, audience_required, audience_match_client_id) =
278            read_audience_cfg(config);
279
280        let set_userinfo_header = config
281            .get("set_userinfo_header")
282            .and_then(|v| v.as_bool())
283            .unwrap_or(true);
284        let set_access_token_header = config
285            .get("set_access_token_header")
286            .and_then(|v| v.as_bool())
287            .unwrap_or(true);
288        let access_token_in_authorization_header = config
289            .get("access_token_in_authorization_header")
290            .and_then(|v| v.as_bool())
291            .unwrap_or(false);
292
293        let ssl_verify = config
294            .get("ssl_verify")
295            .and_then(|v| v.as_bool())
296            .unwrap_or(true);
297        let timeout = Duration::from_secs(
298            config
299                .get("timeout")
300                .and_then(|v| v.as_u64())
301                .unwrap_or(3)
302                .max(1),
303        );
304        let jwk_ttl = Duration::from_secs(
305            config
306                .get("jwk_expires_in")
307                .and_then(|v| v.as_u64())
308                .unwrap_or(86400),
309        );
310
311        Ok(Self {
312            discovery,
313            jwks_uri_cfg,
314            introspection_endpoint,
315            client_id,
316            client_secret,
317            ssl_verify,
318            timeout,
319            allowed_algs,
320            valid_issuers,
321            audience_claim,
322            audience_required,
323            audience_match_client_id,
324            set_userinfo_header,
325            set_access_token_header,
326            access_token_in_authorization_header,
327            use_jwks,
328            jwk_ttl,
329            resources: resources.clone(),
330            jwks_uri_resolved: Mutex::new(None),
331            jwks_cache: Mutex::new(None),
332            interactive,
333        })
334    }
335
336    /// Resolves the JWKS URI, fetching the discovery document once if needed.
337    async fn jwks_uri(&self) -> Result<String, String> {
338        if let Some(uri) = &self.jwks_uri_cfg {
339            return Ok(uri.clone());
340        }
341        {
342            let cached = self.jwks_uri_resolved.lock().await;
343            if let Some(uri) = cached.as_ref() {
344                return Ok(uri.clone());
345            }
346        }
347        let discovery = self
348            .discovery
349            .as_ref()
350            .ok_or_else(|| "no discovery URL configured".to_string())?;
351        let resp = self
352            .get(discovery)
353            .await
354            .map_err(|e| format!("discovery fetch failed: {}", e))?;
355        if resp.status != 200 {
356            return Err(format!("discovery returned status {}", resp.status));
357        }
358        let doc: serde_json::Value = serde_json::from_slice(&resp.body)
359            .map_err(|e| format!("failed to parse discovery doc: {}", e))?;
360        let uri = doc
361            .get("jwks_uri")
362            .and_then(|v| v.as_str())
363            .ok_or_else(|| "discovery doc missing jwks_uri".to_string())?
364            .to_string();
365        *self.jwks_uri_resolved.lock().await = Some(uri.clone());
366        Ok(uri)
367    }
368
369    /// Returns the current JWKS, fetching/refreshing when stale or when
370    /// `force` is set (used on an unknown `kid`).
371    async fn get_jwks(&self, force: bool) -> Result<Vec<Jwk>, String> {
372        let mut cache = self.jwks_cache.lock().await;
373        if !force {
374            if let Some(c) = cache.as_ref() {
375                if c.fetched_at.elapsed() < self.jwk_ttl {
376                    return Ok(c.keys.clone());
377                }
378            }
379        }
380        let uri = self.jwks_uri().await?;
381        let resp = self
382            .get(&uri)
383            .await
384            .map_err(|e| format!("JWKS fetch failed: {}", e))?;
385        if resp.status != 200 {
386            return Err(format!("JWKS endpoint returned status {}", resp.status));
387        }
388        let set: JwkSet = serde_json::from_slice(&resp.body)
389            .map_err(|e| format!("failed to parse JWKS: {}", e))?;
390        *cache = Some(CachedJwks {
391            keys: set.keys.clone(),
392            fetched_at: Instant::now(),
393        });
394        Ok(set.keys)
395    }
396
397    /// Convenience GET through the shared outbound client.
398    async fn get(&self, url: &str) -> Result<OutboundResponse, crate::outbound::OutboundError> {
399        let req = OutboundRequest {
400            method: http::Method::GET,
401            url: url.to_string(),
402            headers: Vec::new(),
403            body: Bytes::new(),
404            timeout: self.timeout,
405            ssl_verify: self.ssl_verify,
406            tls: None,
407        };
408        self.resources.outbound.request(req).await
409    }
410
411    /// Validates the token via JWKS signature verification plus claim checks.
412    async fn validate_via_jwks(
413        &self,
414        token: &str,
415    ) -> Result<HashMap<String, serde_json::Value>, String> {
416        let header = decode_header(token).map_err(|e| format!("invalid JWT header: {}", e))?;
417        let kid = header.kid.clone();
418
419        let keys = self.get_jwks(false).await?;
420        let jwk = match select_jwk(&keys, kid.as_deref()) {
421            Some(j) => j.clone(),
422            None => {
423                // Unknown kid: refetch once to pick up rotated keys.
424                let keys = self.get_jwks(true).await?;
425                select_jwk(&keys, kid.as_deref())
426                    .cloned()
427                    .ok_or_else(|| "no matching JWK for token kid".to_string())?
428            }
429        };
430
431        let key = jwk_to_decoding_key(&jwk)?;
432        // Narrow the permitted algorithms to the ones this key could possibly have
433        // signed with. jsonwebtoken rejects the whole Validation if *any* listed
434        // algorithm belongs to a different family than the key, so passing the
435        // default list (RSA + EC) against an RSA key fails every token. See
436        // `algs_for_key`.
437        let algs = algs_for_key(&self.allowed_algs, &jwk.kty)?;
438        let claims = decode_and_validate(token, &key, &algs)?;
439        self.validate_claims(&claims)?;
440        Ok(claims)
441    }
442
443    /// Validates the token via the introspection endpoint.
444    async fn validate_via_introspection(
445        &self,
446        token: &str,
447    ) -> Result<HashMap<String, serde_json::Value>, String> {
448        let endpoint = self
449            .introspection_endpoint
450            .as_ref()
451            .ok_or_else(|| "no introspection endpoint configured".to_string())?;
452        let client_id = self.client_id.as_deref().unwrap_or("");
453        let client_secret = self.client_secret.as_deref().unwrap_or("");
454        let basic = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret));
455
456        let body = format!("token={}&token_type_hint=access_token", form_encode(token));
457        let req = OutboundRequest {
458            method: http::Method::POST,
459            url: endpoint.clone(),
460            headers: vec![
461                (
462                    "content-type".to_string(),
463                    "application/x-www-form-urlencoded".to_string(),
464                ),
465                ("authorization".to_string(), format!("Basic {}", basic)),
466                ("accept".to_string(), "application/json".to_string()),
467            ],
468            body: Bytes::from(body),
469            timeout: self.timeout,
470            ssl_verify: self.ssl_verify,
471            tls: None,
472        };
473        let resp = self
474            .resources
475            .outbound
476            .request(req)
477            .await
478            .map_err(|e| format!("introspection callout failed: {}", e))?;
479        if resp.status != 200 {
480            return Err(format!("introspection returned status {}", resp.status));
481        }
482        let claims = parse_introspection(&resp.body)?;
483        self.validate_claims(&claims)?;
484        Ok(claims)
485    }
486
487    /// Applies the configured issuer and audience claim checks.
488    fn validate_claims(&self, claims: &HashMap<String, serde_json::Value>) -> Result<(), String> {
489        // Issuer
490        if !self.valid_issuers.is_empty() {
491            let iss = claims.get("iss").and_then(|v| v.as_str());
492            match iss {
493                Some(iss) if self.valid_issuers.iter().any(|v| v == iss) => {}
494                _ => return Err("issuer not in valid_issuers".to_string()),
495            }
496        }
497
498        // Audience
499        let aud = claims.get(&self.audience_claim);
500        if self.audience_required && aud.is_none() {
501            return Err(format!(
502                "required audience claim '{}' missing",
503                self.audience_claim
504            ));
505        }
506        if self.audience_match_client_id {
507            if let Some(aud) = aud {
508                let client_id = self.client_id.as_deref().unwrap_or("");
509                if !audience_contains(aud, client_id) {
510                    return Err("audience does not match client_id".to_string());
511                }
512            }
513        }
514        Ok(())
515    }
516
517    /// Writes the validated claims into `context.message` and the configured
518    /// forwarding headers.
519    fn attach(&self, ctx: &mut Context, claims: HashMap<String, serde_json::Value>, token: &str) {
520        let claims_value = serde_json::to_value(&claims).unwrap_or_default();
521        if let Some(sub) = claims.get("sub") {
522            ctx.message.insert("user_id".to_string(), sub.clone());
523        }
524        ctx.message
525            .insert("jwt_claims".to_string(), claims_value.clone());
526
527        if self.set_userinfo_header {
528            if let Ok(raw) = serde_json::to_vec(&claims_value) {
529                ctx.request
530                    .headers
531                    .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
532            }
533        }
534        if self.set_access_token_header {
535            if self.access_token_in_authorization_header {
536                ctx.request.headers.insert(
537                    "authorization".to_string(),
538                    vec![format!("Bearer {}", token)],
539                );
540            } else {
541                ctx.request
542                    .headers
543                    .insert("x-access-token".to_string(), vec![token.to_string()]);
544            }
545        }
546    }
547
548    fn reject(ctx: Context, message: &str) -> PluginResult {
549        let mut ctx = ctx;
550        ctx.response.status_code = 401;
551        ctx.response.body = Bytes::from(format!(
552            r#"{{"error": "unauthorized", "message": "{}"}}"#,
553            message.replace('"', "'")
554        ));
555        ctx.response.headers.insert(
556            "content-type".to_string(),
557            vec!["application/json".to_string()],
558        );
559        ctx.response.headers.insert(
560            "www-authenticate".to_string(),
561            vec!["Bearer error=\"invalid_token\"".to_string()],
562        );
563        Err(PluginExecutionError {
564            context: ctx,
565            error: GatewayError {
566                node_id: String::new(),
567                code: "OIDC_UNAUTHORIZED".to_string(),
568                message: message.to_string(),
569                metadata: HashMap::new(),
570            },
571        })
572    }
573
574    // ---- Interactive Authorization Code flow ------------------------------
575
576    /// Drives the interactive flow: session check, callback handling, or a
577    /// fresh redirect to the identity provider.
578    async fn execute_interactive(&self, mut ctx: Context) -> PluginResult {
579        let flow = self.interactive.as_ref().expect("interactive mode");
580
581        // Logout: clear the session cookie and redirect.
582        if let Some(logout_path) = &flow.logout_path {
583            if ctx.request.path == *logout_path {
584                let clear = delete_cookie(&flow.session_cookie, &flow.cookie_path);
585                return redirect(ctx, &flow.post_logout_redirect_uri, vec![clear]);
586            }
587        }
588
589        // Callback: the IdP has redirected back with code + state.
590        if ctx.request.path == flow.redirect_path && ctx.request.query_params.contains_key("code") {
591            return self.handle_callback(ctx).await;
592        }
593
594        // Existing valid session cookie → attach identity and continue.
595        if let Some(session) = self.read_session(&ctx) {
596            if let Some(sub) = session.claims.get("sub") {
597                ctx.message.insert("user_id".to_string(), sub.clone());
598            }
599            ctx.message
600                .insert("jwt_claims".to_string(), session.claims.clone());
601            if self.set_userinfo_header {
602                if let Ok(raw) = serde_json::to_vec(&session.claims) {
603                    ctx.request
604                        .headers
605                        .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
606                }
607            }
608            if let (true, Some(tok)) = (self.set_access_token_header, &session.access_token) {
609                if self.access_token_in_authorization_header {
610                    ctx.request
611                        .headers
612                        .insert("authorization".to_string(), vec![format!("Bearer {}", tok)]);
613                } else {
614                    ctx.request
615                        .headers
616                        .insert("x-access-token".to_string(), vec![tok.clone()]);
617                }
618            }
619            return Ok(PluginOutput {
620                context: ctx,
621                named_outputs: HashMap::new(),
622            });
623        }
624
625        // No session → begin the Authorization Code flow.
626        self.begin_auth(ctx).await
627    }
628
629    /// Reads and opens the session cookie, if present and valid.
630    fn read_session(&self, ctx: &Context) -> Option<SessionData> {
631        let flow = self.interactive.as_ref()?;
632        let cookie_header = ctx.request.headers.get("cookie")?.first()?;
633        let raw = read_cookie(cookie_header, &flow.session_cookie)?;
634        let bytes = flow.sealer.open(raw).ok()?;
635        serde_json::from_slice(&bytes).ok()
636    }
637
638    /// Starts the flow: generate CSRF/nonce/PKCE, set the flow cookie, and
639    /// redirect the browser to the IdP authorization endpoint.
640    async fn begin_auth(&self, ctx: Context) -> PluginResult {
641        let flow = self.interactive.as_ref().expect("interactive mode");
642        let authz = match self.authorization_endpoint().await {
643            Ok(u) => u,
644            Err(e) => return Self::reject(ctx, &e),
645        };
646
647        let state = random_token();
648        let nonce = random_token();
649        let verifier = random_token();
650        let challenge = pkce_challenge(&verifier);
651        let original_uri = request_uri(&ctx);
652
653        let flow_state = FlowState {
654            state: state.clone(),
655            nonce: nonce.clone(),
656            verifier,
657            original_uri,
658        };
659        let sealed = match serde_json::to_vec(&flow_state) {
660            Ok(b) => flow.sealer.seal(&b, Duration::from_secs(300)),
661            Err(e) => return Self::reject(ctx, &format!("flow cookie seal failed: {}", e)),
662        };
663        let set_flow = build_set_cookie(
664            &flow.flow_cookie,
665            &sealed,
666            &CookieAttrs {
667                path: &flow.cookie_path,
668                max_age: Some(300),
669                http_only: true,
670                secure: request_is_https(&ctx),
671                same_site: SameSite::Lax,
672            },
673        );
674
675        let url = format!(
676            "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}\
677             &code_challenge={}&code_challenge_method=S256",
678            authz,
679            form_encode(self.client_id.as_deref().unwrap_or("")),
680            form_encode(&flow.redirect_uri),
681            form_encode(&flow.scope),
682            form_encode(&state),
683            form_encode(&nonce),
684            form_encode(&challenge),
685        );
686        redirect(ctx, &url, vec![set_flow])
687    }
688
689    /// Handles the IdP redirect back: verify state, exchange the code, validate
690    /// the id_token, seal a session cookie, and redirect to the original URL.
691    async fn handle_callback(&self, ctx: Context) -> PluginResult {
692        let flow = self.interactive.as_ref().expect("interactive mode");
693
694        let code = first_query(&ctx, "code").unwrap_or_default();
695        let state = first_query(&ctx, "state").unwrap_or_default();
696
697        // Recover and validate the flow cookie (CSRF).
698        let flow_state = match self.read_flow(&ctx) {
699            Some(f) => f,
700            None => return Self::reject(ctx, "missing or invalid login flow cookie"),
701        };
702        if flow_state.state != state || state.is_empty() {
703            return Self::reject(ctx, "state mismatch (possible CSRF)");
704        }
705
706        // Exchange the authorization code for tokens.
707        let token_endpoint = match self.token_endpoint().await {
708            Ok(u) => u,
709            Err(e) => return Self::reject(ctx, &e),
710        };
711        let tokens = match self
712            .exchange_code(
713                &token_endpoint,
714                &code,
715                &flow_state.verifier,
716                &flow.redirect_uri,
717            )
718            .await
719        {
720            Ok(t) => t,
721            Err(e) => return Self::reject(ctx, &e),
722        };
723
724        let id_token = match tokens.get("id_token").and_then(|v| v.as_str()) {
725            Some(t) => t.to_string(),
726            None => return Self::reject(ctx, "token response missing id_token"),
727        };
728        let access_token = tokens
729            .get("access_token")
730            .and_then(|v| v.as_str())
731            .map(String::from);
732
733        // Validate the id_token signature/claims via the JWKS path and check
734        // the nonce binds it to this login attempt.
735        let claims = match self.validate_via_jwks(&id_token).await {
736            Ok(c) => c,
737            Err(e) => return Self::reject(ctx, &format!("id_token validation failed: {}", e)),
738        };
739        if claims.get("nonce").and_then(|v| v.as_str()) != Some(flow_state.nonce.as_str()) {
740            return Self::reject(ctx, "id_token nonce mismatch");
741        }
742
743        // Seal the session and redirect to where the user was going.
744        let session = SessionData {
745            claims: serde_json::to_value(&claims).unwrap_or_default(),
746            access_token,
747        };
748        let sealed = match serde_json::to_vec(&session) {
749            Ok(b) => flow.sealer.seal(&b, flow.session_lifetime),
750            Err(e) => return Self::reject(ctx, &format!("session seal failed: {}", e)),
751        };
752        let set_session = build_set_cookie(
753            &flow.session_cookie,
754            &sealed,
755            &CookieAttrs {
756                path: &flow.cookie_path,
757                max_age: Some(flow.session_lifetime.as_secs()),
758                http_only: true,
759                secure: request_is_https(&ctx),
760                same_site: SameSite::Lax,
761            },
762        );
763        let clear_flow = delete_cookie(&flow.flow_cookie, &flow.cookie_path);
764        let target = if flow_state.original_uri.is_empty() {
765            "/".to_string()
766        } else {
767            flow_state.original_uri.clone()
768        };
769        redirect(ctx, &target, vec![set_session, clear_flow])
770    }
771
772    /// Reads and opens the transient flow cookie.
773    fn read_flow(&self, ctx: &Context) -> Option<FlowState> {
774        let flow = self.interactive.as_ref()?;
775        let cookie_header = ctx.request.headers.get("cookie")?.first()?;
776        let raw = read_cookie(cookie_header, &flow.flow_cookie)?;
777        let bytes = flow.sealer.open(raw).ok()?;
778        serde_json::from_slice(&bytes).ok()
779    }
780
781    /// Exchanges an authorization code for tokens at the token endpoint.
782    async fn exchange_code(
783        &self,
784        token_endpoint: &str,
785        code: &str,
786        verifier: &str,
787        redirect_uri: &str,
788    ) -> Result<serde_json::Value, String> {
789        let client_id = self.client_id.as_deref().unwrap_or("");
790        let client_secret = self.client_secret.as_deref().unwrap_or("");
791        let basic = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret));
792        let body = format!(
793            "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}",
794            form_encode(code),
795            form_encode(redirect_uri),
796            form_encode(verifier),
797            form_encode(client_id),
798        );
799        let req = OutboundRequest {
800            method: http::Method::POST,
801            url: token_endpoint.to_string(),
802            headers: vec![
803                (
804                    "content-type".to_string(),
805                    "application/x-www-form-urlencoded".to_string(),
806                ),
807                ("authorization".to_string(), format!("Basic {}", basic)),
808                ("accept".to_string(), "application/json".to_string()),
809            ],
810            body: Bytes::from(body),
811            timeout: self.timeout,
812            ssl_verify: self.ssl_verify,
813            tls: None,
814        };
815        let resp = self
816            .resources
817            .outbound
818            .request(req)
819            .await
820            .map_err(|e| format!("token exchange callout failed: {}", e))?;
821        if resp.status != 200 {
822            return Err(format!("token endpoint returned status {}", resp.status));
823        }
824        serde_json::from_slice(&resp.body).map_err(|e| format!("invalid token response: {}", e))
825    }
826
827    /// Resolves the authorization endpoint (config or discovery).
828    async fn authorization_endpoint(&self) -> Result<String, String> {
829        let flow = self.interactive.as_ref().expect("interactive mode");
830        if let Some(u) = &flow.authorization_endpoint_cfg {
831            return Ok(u.clone());
832        }
833        self.discovery_field("authorization_endpoint", &flow.authz_endpoint_resolved)
834            .await
835    }
836
837    /// Resolves the token endpoint (config or discovery).
838    async fn token_endpoint(&self) -> Result<String, String> {
839        let flow = self.interactive.as_ref().expect("interactive mode");
840        if let Some(u) = &flow.token_endpoint_cfg {
841            return Ok(u.clone());
842        }
843        self.discovery_field("token_endpoint", &flow.token_endpoint_resolved)
844            .await
845    }
846
847    /// Reads a URL field from the discovery document, memoizing the result.
848    async fn discovery_field(
849        &self,
850        field: &str,
851        cache: &Mutex<Option<String>>,
852    ) -> Result<String, String> {
853        {
854            if let Some(u) = cache.lock().await.as_ref() {
855                return Ok(u.clone());
856            }
857        }
858        let discovery = self
859            .discovery
860            .as_ref()
861            .ok_or_else(|| format!("no discovery URL to resolve {}", field))?;
862        let resp = self
863            .get(discovery)
864            .await
865            .map_err(|e| format!("discovery fetch failed: {}", e))?;
866        if resp.status != 200 {
867            return Err(format!("discovery returned status {}", resp.status));
868        }
869        let doc: serde_json::Value = serde_json::from_slice(&resp.body)
870            .map_err(|e| format!("failed to parse discovery doc: {}", e))?;
871        let uri = doc
872            .get(field)
873            .and_then(|v| v.as_str())
874            .ok_or_else(|| format!("discovery doc missing {}", field))?
875            .to_string();
876        *cache.lock().await = Some(uri.clone());
877        Ok(uri)
878    }
879}
880
881/// Builds the interactive-mode configuration from the plugin config.
882fn build_interactive(
883    config: &HashMap<String, serde_json::Value>,
884    discovery: &Option<String>,
885) -> Result<Interactive, String> {
886    let secret = session_field(config, "secret")
887        .or_else(|| string_opt(config, "session_secret"))
888        .ok_or("openid-connect: interactive login requires 'session.secret'")?;
889
890    let redirect_uri = string_opt(config, "redirect_uri")
891        .ok_or("openid-connect: interactive login requires 'redirect_uri'")?;
892    let redirect_path = url_path(&redirect_uri);
893
894    let authorization_endpoint_cfg = string_opt(config, "authorization_endpoint");
895    let token_endpoint_cfg = string_opt(config, "token_endpoint");
896    if discovery.is_none() && (authorization_endpoint_cfg.is_none() || token_endpoint_cfg.is_none())
897    {
898        return Err(
899            "openid-connect: interactive login requires 'discovery', or both \
900                    'authorization_endpoint' and 'token_endpoint'"
901                .to_string(),
902        );
903    }
904
905    let scope = string_opt(config, "scope").unwrap_or_else(|| "openid".to_string());
906    let session_cookie =
907        session_cookie_field(config, "name").unwrap_or_else(|| "oidc_session".to_string());
908    let cookie_path = session_cookie_field(config, "path").unwrap_or_else(|| "/".to_string());
909    // The callback must be reachable with the session/flow cookies attached, so
910    // the cookie path has to cover the redirect_uri path. Otherwise the browser
911    // withholds the flow cookie on the callback and login loops forever — fail
912    // fast at load instead of shipping a silently-broken route.
913    if !path_covers(&cookie_path, &redirect_path) {
914        return Err(format!(
915            "openid-connect: session.cookie.path '{}' does not cover the redirect_uri \
916             path '{}'; the session cookie would not be sent to the callback and login \
917             would loop. Set session.cookie.path to a prefix of the callback path.",
918            cookie_path, redirect_path
919        ));
920    }
921    let session_lifetime = Duration::from_secs(
922        config
923            .get("session")
924            .and_then(|s| s.get("cookie"))
925            .and_then(|c| c.get("lifetime"))
926            .or_else(|| config.get("session_cookie_lifetime"))
927            .and_then(|v| v.as_u64())
928            .unwrap_or(3600),
929    );
930
931    Ok(Interactive {
932        sealer: CookieSealer::new(&secret),
933        authorization_endpoint_cfg,
934        token_endpoint_cfg,
935        redirect_uri,
936        redirect_path,
937        scope,
938        flow_cookie: format!("{}_flow", session_cookie),
939        session_cookie,
940        cookie_path,
941        session_lifetime,
942        logout_path: string_opt(config, "logout_path"),
943        post_logout_redirect_uri: string_opt(config, "post_logout_redirect_uri")
944            .unwrap_or_else(|| "/".to_string()),
945        authz_endpoint_resolved: Mutex::new(None),
946        token_endpoint_resolved: Mutex::new(None),
947    })
948}
949
950/// Reads `session.<field>` as a string.
951fn session_field(config: &HashMap<String, serde_json::Value>, field: &str) -> Option<String> {
952    config
953        .get("session")
954        .and_then(|s| s.get(field))
955        .and_then(|v| v.as_str())
956        .filter(|s| !s.is_empty())
957        .map(String::from)
958}
959
960/// Reads a session cookie string field from nested `session.cookie.<field>`,
961/// falling back to the flat `session_cookie_<field>` form the Web UI schema
962/// emits (the SchemaForm is flat and cannot author nested maps).
963fn session_cookie_field(
964    config: &HashMap<String, serde_json::Value>,
965    field: &str,
966) -> Option<String> {
967    config
968        .get("session")
969        .and_then(|s| s.get("cookie"))
970        .and_then(|c| c.get(field))
971        .or_else(|| config.get(&format!("session_cookie_{field}")))
972        .and_then(|v| v.as_str())
973        .filter(|s| !s.is_empty())
974        .map(String::from)
975}
976
977/// Extracts the path portion of a URL (everything from the first `/` after the
978/// authority), defaulting to `/`.
979fn url_path(url: &str) -> String {
980    let after_scheme = url.split("://").nth(1).unwrap_or(url);
981    match after_scheme.find('/') {
982        Some(i) => {
983            let path = &after_scheme[i..];
984            path.split(['?', '#']).next().unwrap_or(path).to_string()
985        }
986        None => "/".to_string(),
987    }
988}
989
990/// Rebuilds the request URI (path plus sorted query string) for the
991/// post-login redirect target.
992fn request_uri(ctx: &Context) -> String {
993    let mut pairs: Vec<String> = Vec::new();
994    for (k, values) in &ctx.request.query_params {
995        for v in values {
996            pairs.push(format!("{}={}", form_encode(k), form_encode(v)));
997        }
998    }
999    pairs.sort();
1000    if pairs.is_empty() {
1001        ctx.request.path.clone()
1002    } else {
1003        format!("{}?{}", ctx.request.path, pairs.join("&"))
1004    }
1005}
1006
1007/// First value of a query parameter.
1008fn first_query(ctx: &Context, name: &str) -> Option<String> {
1009    ctx.request
1010        .query_params
1011        .get(name)
1012        .and_then(|v| v.first())
1013        .cloned()
1014}
1015
1016/// True when the request arrived over HTTPS (controls the cookie `Secure` flag).
1017fn request_is_https(ctx: &Context) -> bool {
1018    ctx.request.scheme.eq_ignore_ascii_case("https")
1019}
1020
1021/// A URL-safe random token (32 bytes → base64url) for state/nonce/PKCE.
1022fn random_token() -> String {
1023    let mut bytes = [0u8; 32];
1024    SystemRandom::new()
1025        .fill(&mut bytes)
1026        .expect("system RNG must produce random bytes");
1027    URL_SAFE_NO_PAD.encode(bytes)
1028}
1029
1030/// PKCE S256 challenge: base64url(SHA-256(verifier)).
1031fn pkce_challenge(verifier: &str) -> String {
1032    URL_SAFE_NO_PAD.encode(digest(&SHA256, verifier.as_bytes()).as_ref())
1033}
1034
1035/// Prepares a 302 redirect on the context and exits through the error port
1036/// (the node's error edge should be wired to `client.in`).
1037fn redirect(mut ctx: Context, location: &str, set_cookies: Vec<String>) -> PluginResult {
1038    ctx.response.status_code = 302;
1039    ctx.response.body = Bytes::new();
1040    ctx.response
1041        .headers
1042        .insert("location".to_string(), vec![location.to_string()]);
1043    if !set_cookies.is_empty() {
1044        ctx.response
1045            .headers
1046            .insert("set-cookie".to_string(), set_cookies);
1047    }
1048    Err(PluginExecutionError {
1049        context: ctx,
1050        error: GatewayError {
1051            node_id: String::new(),
1052            code: "OIDC_REDIRECT".to_string(),
1053            message: "redirecting for interactive login".to_string(),
1054            metadata: HashMap::new(),
1055        },
1056    })
1057}
1058
1059/// Reads an optional non-empty string config value.
1060fn string_opt(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
1061    config
1062        .get(key)
1063        .and_then(|v| v.as_str())
1064        .filter(|s| !s.is_empty())
1065        .map(String::from)
1066}
1067
1068/// Parses one algorithm name into a [`jsonwebtoken::Algorithm`] (asymmetric
1069/// only — OIDC JWKS keys are RSA/EC).
1070fn parse_alg(name: &str) -> Option<Algorithm> {
1071    match name {
1072        "RS256" => Some(Algorithm::RS256),
1073        "RS384" => Some(Algorithm::RS384),
1074        "RS512" => Some(Algorithm::RS512),
1075        "PS256" => Some(Algorithm::PS256),
1076        "PS384" => Some(Algorithm::PS384),
1077        "PS512" => Some(Algorithm::PS512),
1078        "ES256" => Some(Algorithm::ES256),
1079        "ES384" => Some(Algorithm::ES384),
1080        _ => None,
1081    }
1082}
1083
1084/// Parses `token_signing_alg_values_expected` (string, comma/space list, or
1085/// array) into the allowed-algorithm set, defaulting to the common asymmetric
1086/// algorithms.
1087fn parse_allowed_algs(value: Option<&serde_json::Value>) -> Result<Vec<Algorithm>, String> {
1088    let default = || {
1089        vec![
1090            Algorithm::RS256,
1091            Algorithm::RS384,
1092            Algorithm::RS512,
1093            Algorithm::ES256,
1094            Algorithm::ES384,
1095        ]
1096    };
1097    let names: Vec<String> = match value {
1098        None => return Ok(default()),
1099        Some(serde_json::Value::String(s)) => s
1100            .split([',', ' '])
1101            .map(str::trim)
1102            .filter(|s| !s.is_empty())
1103            .map(String::from)
1104            .collect(),
1105        Some(serde_json::Value::Array(a)) => a
1106            .iter()
1107            .filter_map(|v| v.as_str().map(String::from))
1108            .collect(),
1109        Some(_) => {
1110            return Err("token_signing_alg_values_expected must be a string or array".to_string())
1111        }
1112    };
1113    if names.is_empty() {
1114        return Ok(default());
1115    }
1116    let mut algs = Vec::new();
1117    for name in names {
1118        match parse_alg(&name) {
1119            Some(a) => algs.push(a),
1120            None => {
1121                return Err(format!(
1122                    "unsupported token signing algorithm '{}' \
1123                     (supported: RS256/384/512, PS256/384/512, ES256/384)",
1124                    name
1125                ))
1126            }
1127        }
1128    }
1129    Ok(algs)
1130}
1131
1132/// Reads `claim_validator.issuer.valid_issuers`.
1133fn read_valid_issuers(config: &HashMap<String, serde_json::Value>) -> Vec<String> {
1134    config
1135        .get("claim_validator")
1136        .and_then(|v| v.get("issuer"))
1137        .and_then(|v| v.get("valid_issuers"))
1138        .and_then(|v| v.as_array())
1139        .map(|arr| {
1140            arr.iter()
1141                .filter_map(|v| v.as_str().map(String::from))
1142                .collect()
1143        })
1144        .unwrap_or_default()
1145}
1146
1147/// Reads `claim_validator.audience.{claim,required,match_with_client_id}`.
1148fn read_audience_cfg(config: &HashMap<String, serde_json::Value>) -> (String, bool, bool) {
1149    let audience = config
1150        .get("claim_validator")
1151        .and_then(|v| v.get("audience"));
1152    let claim = audience
1153        .and_then(|a| a.get("claim"))
1154        .and_then(|v| v.as_str())
1155        .unwrap_or("aud")
1156        .to_string();
1157    let required = audience
1158        .and_then(|a| a.get("required"))
1159        .and_then(|v| v.as_bool())
1160        .unwrap_or(false);
1161    let match_client = audience
1162        .and_then(|a| a.get("match_with_client_id"))
1163        .and_then(|v| v.as_bool())
1164        .unwrap_or(false);
1165    (claim, required, match_client)
1166}
1167
1168/// Selects the JWK matching `kid`, or the sole key when no `kid` is present.
1169fn select_jwk<'a>(keys: &'a [Jwk], kid: Option<&str>) -> Option<&'a Jwk> {
1170    match kid {
1171        Some(kid) => keys.iter().find(|k| k.kid.as_deref() == Some(kid)),
1172        None => {
1173            if keys.len() == 1 {
1174                keys.first()
1175            } else {
1176                None
1177            }
1178        }
1179    }
1180}
1181
1182/// Whether `alg` can be verified with a key of JWK type `kty`.
1183fn alg_matches_kty(alg: Algorithm, kty: &str) -> bool {
1184    match alg {
1185        Algorithm::RS256
1186        | Algorithm::RS384
1187        | Algorithm::RS512
1188        | Algorithm::PS256
1189        | Algorithm::PS384
1190        | Algorithm::PS512 => kty == "RSA",
1191        Algorithm::ES256 | Algorithm::ES384 => kty == "EC",
1192        Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => kty == "oct",
1193        Algorithm::EdDSA => kty == "OKP",
1194    }
1195}
1196
1197/// Narrows the configured algorithms to those a `kty` key can verify.
1198///
1199/// This is not an optimization — it is required for correctness. `jsonwebtoken`
1200/// validates the *whole* algorithm list against the key family before it even
1201/// looks at the token:
1202///
1203/// ```ignore
1204/// for alg in &validation.algorithms {
1205///     if key.family != alg.family() { return Err(InvalidAlgorithm); }
1206/// }
1207/// ```
1208///
1209/// So a list spanning two families can never verify anything. The default
1210/// `token_signing_alg_values_expected` spans RSA *and* EC, which meant every
1211/// JWKS-verified token — bearer tokens and interactive `id_token`s alike — was
1212/// rejected with `InvalidAlgorithm` unless the operator happened to pin a single
1213/// family. Filtering per key keeps the permissive default working with whichever
1214/// key the IdP actually published.
1215fn algs_for_key(allowed: &[Algorithm], kty: &str) -> Result<Vec<Algorithm>, String> {
1216    let algs: Vec<Algorithm> = allowed
1217        .iter()
1218        .copied()
1219        .filter(|a| alg_matches_kty(*a, kty))
1220        .collect();
1221    if algs.is_empty() {
1222        return Err(format!(
1223            "no permitted signing algorithm can verify a '{}' key; \
1224             check token_signing_alg_values_expected",
1225            kty
1226        ));
1227    }
1228    Ok(algs)
1229}
1230
1231/// Builds a [`DecodingKey`] from a JWK based on its key type.
1232fn jwk_to_decoding_key(jwk: &Jwk) -> Result<DecodingKey, String> {
1233    match jwk.kty.as_str() {
1234        "RSA" => {
1235            let n = jwk.n.as_deref().ok_or("RSA JWK missing 'n'")?;
1236            let e = jwk.e.as_deref().ok_or("RSA JWK missing 'e'")?;
1237            DecodingKey::from_rsa_components(n, e).map_err(|e| format!("invalid RSA JWK: {}", e))
1238        }
1239        "EC" => {
1240            let x = jwk.x.as_deref().ok_or("EC JWK missing 'x'")?;
1241            let y = jwk.y.as_deref().ok_or("EC JWK missing 'y'")?;
1242            DecodingKey::from_ec_components(x, y).map_err(|e| format!("invalid EC JWK: {}", e))
1243        }
1244        other => Err(format!("unsupported JWK key type '{}'", other)),
1245    }
1246}
1247
1248/// Verifies the token signature (against `key`, restricted to `allowed_algs`)
1249/// and `exp`, returning the decoded claims. Issuer/audience are validated
1250/// separately by [`OpenidConnectPlugin::validate_claims`].
1251fn decode_and_validate(
1252    token: &str,
1253    key: &DecodingKey,
1254    allowed_algs: &[Algorithm],
1255) -> Result<HashMap<String, serde_json::Value>, String> {
1256    let first = allowed_algs.first().copied().unwrap_or(Algorithm::RS256);
1257    let mut validation = Validation::new(first);
1258    validation.algorithms = allowed_algs.to_vec();
1259    validation.validate_exp = true;
1260    // Issuer/audience handled manually to honor the plugin's flags precisely.
1261    validation.validate_aud = false;
1262    decode::<HashMap<String, serde_json::Value>>(token, key, &validation)
1263        .map(|data| data.claims)
1264        .map_err(|e| format!("token verification failed: {}", e))
1265}
1266
1267/// Parses an RFC 7662 introspection response, requiring `active: true`.
1268fn parse_introspection(body: &[u8]) -> Result<HashMap<String, serde_json::Value>, String> {
1269    let value: serde_json::Value = serde_json::from_slice(body)
1270        .map_err(|e| format!("invalid introspection response: {}", e))?;
1271    let active = value
1272        .get("active")
1273        .and_then(|v| v.as_bool())
1274        .unwrap_or(false);
1275    if !active {
1276        return Err("token is not active".to_string());
1277    }
1278    let map = value
1279        .as_object()
1280        .map(|m| m.clone().into_iter().collect())
1281        .unwrap_or_default();
1282    Ok(map)
1283}
1284
1285/// True when `aud` equals `client_id` (string aud) or contains it (array aud).
1286fn audience_contains(aud: &serde_json::Value, client_id: &str) -> bool {
1287    match aud {
1288        serde_json::Value::String(s) => s == client_id,
1289        serde_json::Value::Array(arr) => arr.iter().any(|v| v.as_str() == Some(client_id)),
1290        _ => false,
1291    }
1292}
1293
1294/// Extracts the bearer token from an `Authorization` header value.
1295fn parse_bearer(header_value: &str) -> Option<&str> {
1296    let mut parts = header_value.splitn(2, ' ');
1297    let scheme = parts.next()?;
1298    let token = parts.next()?.trim();
1299    if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() {
1300        Some(token)
1301    } else {
1302        None
1303    }
1304}
1305
1306/// Percent-encodes a token for an `application/x-www-form-urlencoded` body.
1307fn form_encode(value: &str) -> String {
1308    let mut out = String::with_capacity(value.len());
1309    for b in value.bytes() {
1310        match b {
1311            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1312                out.push(b as char)
1313            }
1314            _ => out.push_str(&format!("%{:02X}", b)),
1315        }
1316    }
1317    out
1318}
1319
1320#[async_trait]
1321impl Plugin for OpenidConnectPlugin {
1322    fn plugin_type(&self) -> &str {
1323        "openid-connect"
1324    }
1325
1326    async fn execute(
1327        &self,
1328        mut ctx: Context,
1329        _named_inputs: &HashMap<String, serde_json::Value>,
1330    ) -> PluginResult {
1331        // Strip any client-supplied userinfo header before authentication.
1332        ctx.request.headers.remove("x-userinfo");
1333
1334        // Interactive login (bearer_only: false) runs the cookie-session flow.
1335        if self.interactive.is_some() {
1336            return self.execute_interactive(ctx).await;
1337        }
1338
1339        let token = ctx
1340            .request
1341            .headers
1342            .get("authorization")
1343            .and_then(|v| v.first())
1344            .and_then(|v| parse_bearer(v))
1345            .map(String::from);
1346
1347        let token = match token {
1348            Some(t) => t,
1349            None => return Self::reject(ctx, "No bearer token found in request"),
1350        };
1351
1352        let result = if self.use_jwks {
1353            self.validate_via_jwks(&token).await
1354        } else {
1355            self.validate_via_introspection(&token).await
1356        };
1357
1358        match result {
1359            Ok(claims) => {
1360                self.attach(&mut ctx, claims, &token);
1361                Ok(PluginOutput {
1362                    context: ctx,
1363                    named_outputs: HashMap::new(),
1364                })
1365            }
1366            Err(e) => Self::reject(ctx, &e),
1367        }
1368    }
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373    use super::*;
1374    use jsonwebtoken::{encode, EncodingKey, Header};
1375
1376    // Test RSA keypair (PKCS#8). The public modulus/exponent below are the same
1377    // key, expressed as JWK n/e, so a token signed with PRIV_PEM verifies
1378    // against the JWK — exercising the JWKS → DecodingKey path end to end.
1379    const PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1380MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCvciOuri5uG88q\n\
1381rZ3T6qUhTYl7nWDHvVGBBsA8ku3xUfOW97PGpWbTe/Yq/3jovVxAQsAe/QoIMyUU\n\
1382HKCdDKAsIBO9j9OEPs3Le6cThFx+/9Z1U9cw4wCIa4TNtGBhyDgqbqKpOLNnXLI6\n\
1383WEcrykkoV5nUUH/47aS2i9BiqZn6H9eEL1VH82IX/x4fWNIEyXQAxKZtyULgznR4\n\
1384oUz2QPaY/cWtpK85B12scs1IpLnzEdjy69t28ZQnYZ7Nrvl+aFjSkvnqxhoNJ9Ut\n\
1385Lw2/3vld8t3Lh6B4vTM4vdJsue1dum6WnyEKEx/SDuCSDxWONfmdhu/B4XUghaQS\n\
13861wBNiEhvAgMBAAECggEASXDcee8ktWfDsShK9F35MLcd0VaAICxiFUInr1OL8ePt\n\
1387tSjMIt+y6t0tnzMgwEAgATBP7sjabbNHFqOjIgqac84bpVKy5l1J1R9WQWe7NlhO\n\
1388w/9MCYVEgFaNmXQjklr3E+ALDA4VnzNg0eaJKE39kLsWxBbMcv27YMSm/t3i/B2s\n\
1389rwZbzBgxXXR5r7j/Tt+hRJmGHXe0zZvsNLzFNj4CsyngBiY9CIcexroGxd3yGEf7\n\
13900PKHwbZKkH0CPr6QAc4f+tPgIfHB+8+29QPrUTR9e60Sc6dZNUjTr1EWIxyvFxVK\n\
1391dI3ekR5W26a81+yxc2MpRK8wZsv+mJ6okaeVs2+3jQKBgQDr0b3YX4RC9trW+RsE\n\
13929wUXeLr3o9Vb0FTHf/8ALAZ9EWywEmF+sdA8fKs8+H+IyIzX6KGw/UbzqIi2aDuJ\n\
1393q63IPxKyyXr7nfVSUz8qWIGT/WoG/1d4rpFN2sbR/r/oue7uJnaXMIPswVT+zO8q\n\
13945YieEPDwhteJ8bJUC16NWwddBQKBgQC+dcEmNm7MzxI/cuwubkojhayXw1ouACu4\n\
1395giGp3lJywzIAnV1CsJTGTpvHk31j+/L9oB2U/586+65MGklGJ2TGs0IQZs0iAy1H\n\
1396Oq3zzsLp0KiVizyqchgkIWP6KVpx5aPkpJSgPJGyJzuwofZwRzPK7IZr8c4MOtsy\n\
1397M8j8up8p4wKBgGbUxTYvIJuazX7kjXWyydOcX9tQ497vj6iXFflbOVEcYgq9WSpI\n\
1398G4fkzT7/FY3t9gzIcomdSG1D1qnD9gJojJU/e8XeufQywyEtD+RFR+vim3OFsPz9\n\
1399EnuipQQ5VDIFsjzDJP90tnJtM8UQVFKeWN6kgIxCIIcUkDC57HczdJiJAoGASPG4\n\
1400g/YdAXvdNUfChRXgdzJfI9DB3RRbqlLMqc5oLWPs5qdebIhMspawuwMV5xE7wz9r\n\
1401lQFB7sktvB/lKGU2B5PoHXgB4KDu2nTy4omxxPMRXhTxqyX/cPcI32qvJSgaWRtf\n\
1402gO8xrdWw2rltNRtQDsv/v5/glnaENPn4ZDLlepkCgYAqag5Uxj0ps6WNE/D6IEWA\n\
1403eTGicEEJPJQB9bGrElna7WyOjntnO5miRmpM1jH39R417czBURmvZHO2oTnqghZF\n\
1404c/7P2kweQNU7vtM/iLcm8EyFRw2lVB3J/XVTEcPU6ZeZHlVbGtiKx3gukkMBc4Ct\n\
1405CQTyrvDSz5J6MQhLtbNHnQ==\n\
1406-----END PRIVATE KEY-----\n";
1407
1408    const JWK_N: &str = "r3Ijrq4ubhvPKq2d0-qlIU2Je51gx71RgQbAPJLt8VHzlvezxqVm03v2Kv946L1cQELAHv0KCDMlFBygnQygLCATvY_ThD7Ny3unE4Rcfv_WdVPXMOMAiGuEzbRgYcg4Km6iqTizZ1yyOlhHK8pJKFeZ1FB_-O2ktovQYqmZ-h_XhC9VR_NiF_8eH1jSBMl0AMSmbclC4M50eKFM9kD2mP3FraSvOQddrHLNSKS58xHY8uvbdvGUJ2Geza75fmhY0pL56sYaDSfVLS8Nv975XfLdy4egeL0zOL3SbLntXbpulp8hChMf0g7gkg8VjjX5nYbvweF1IIWkEtcATYhIbw";
1409    const JWK_E: &str = "AQAB";
1410
1411    fn test_jwk(kid: &str) -> Jwk {
1412        Jwk {
1413            kty: "RSA".to_string(),
1414            kid: Some(kid.to_string()),
1415            alg: Some("RS256".to_string()),
1416            n: Some(JWK_N.to_string()),
1417            e: Some(JWK_E.to_string()),
1418            x: None,
1419            y: None,
1420            crv: None,
1421        }
1422    }
1423
1424    fn sign(claims: serde_json::Value, kid: &str) -> String {
1425        let mut header = Header::new(Algorithm::RS256);
1426        header.kid = Some(kid.to_string());
1427        encode(
1428            &header,
1429            &claims,
1430            &EncodingKey::from_rsa_pem(PRIV_PEM.as_bytes()).unwrap(),
1431        )
1432        .unwrap()
1433    }
1434
1435    fn cfg(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1436        pairs
1437            .iter()
1438            .map(|(k, v)| (k.to_string(), v.clone()))
1439            .collect()
1440    }
1441
1442    #[test]
1443    fn test_interactive_requires_secret_and_redirect() {
1444        // bearer_only:false without session.secret / redirect_uri is rejected.
1445        let missing = cfg(&[
1446            (
1447                "discovery",
1448                serde_json::json!("https://idp/.well-known/openid-configuration"),
1449            ),
1450            ("bearer_only", serde_json::json!(false)),
1451            ("client_id", serde_json::json!("app")),
1452            ("client_secret", serde_json::json!("s")),
1453        ]);
1454        assert!(OpenidConnectPlugin::from_config(&missing, &PluginResources::empty()).is_err());
1455
1456        // Fully configured interactive mode builds.
1457        let ok = cfg(&[
1458            (
1459                "discovery",
1460                serde_json::json!("https://idp/.well-known/openid-configuration"),
1461            ),
1462            ("bearer_only", serde_json::json!(false)),
1463            ("client_id", serde_json::json!("app")),
1464            ("client_secret", serde_json::json!("s")),
1465            (
1466                "redirect_uri",
1467                serde_json::json!("https://app.example.com/oidc/callback"),
1468            ),
1469            (
1470                "session",
1471                serde_json::json!({ "secret": "cookie-signing-secret" }),
1472            ),
1473        ]);
1474        let plugin = OpenidConnectPlugin::from_config(&ok, &PluginResources::empty()).unwrap();
1475        let interactive = plugin.interactive.as_ref().unwrap();
1476        assert_eq!(interactive.redirect_path, "/oidc/callback");
1477        assert_eq!(interactive.session_cookie, "oidc_session");
1478        assert_eq!(interactive.flow_cookie, "oidc_session_flow");
1479        // Defaults: whole-origin cookie, one-hour lifetime.
1480        assert_eq!(interactive.cookie_path, "/");
1481        assert_eq!(interactive.session_lifetime, Duration::from_secs(3600));
1482    }
1483
1484    /// Two nodes on distinct subpaths can carry independent, path-scoped sessions
1485    /// with their own names and lifetimes — the /app_a vs /app_b case.
1486    #[test]
1487    fn test_interactive_custom_session_cookie() {
1488        let c = cfg(&[
1489            (
1490                "discovery",
1491                serde_json::json!("https://idp/.well-known/openid-configuration"),
1492            ),
1493            ("bearer_only", serde_json::json!(false)),
1494            ("client_id", serde_json::json!("app")),
1495            ("client_secret", serde_json::json!("s")),
1496            (
1497                "redirect_uri",
1498                serde_json::json!("https://app.example.com/app_a/callback"),
1499            ),
1500            (
1501                "session",
1502                serde_json::json!({
1503                    "secret": "cookie-signing-secret",
1504                    "cookie": { "name": "a_session", "path": "/app_a", "lifetime": 900 }
1505                }),
1506            ),
1507        ]);
1508        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
1509        let i = plugin.interactive.as_ref().unwrap();
1510        assert_eq!(i.session_cookie, "a_session");
1511        assert_eq!(i.flow_cookie, "a_session_flow");
1512        assert_eq!(i.cookie_path, "/app_a");
1513        assert_eq!(i.session_lifetime, Duration::from_secs(900));
1514    }
1515
1516    /// The Web UI's flat `session_cookie_*` keys are honored just like the
1517    /// nested `session.cookie.*` form, so session properties edited in the UI
1518    /// take effect.
1519    #[test]
1520    fn test_interactive_flat_ui_session_keys() {
1521        let c = cfg(&[
1522            (
1523                "discovery",
1524                serde_json::json!("https://idp/.well-known/openid-configuration"),
1525            ),
1526            ("bearer_only", serde_json::json!(false)),
1527            ("client_id", serde_json::json!("app")),
1528            ("client_secret", serde_json::json!("s")),
1529            (
1530                "redirect_uri",
1531                serde_json::json!("https://app.example.com/app_a/callback"),
1532            ),
1533            ("session_secret", serde_json::json!("cookie-signing-secret")),
1534            ("session_cookie_name", serde_json::json!("a_session")),
1535            ("session_cookie_path", serde_json::json!("/app_a")),
1536            ("session_cookie_lifetime", serde_json::json!(1200)),
1537        ]);
1538        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
1539        let i = plugin.interactive.as_ref().unwrap();
1540        assert_eq!(i.session_cookie, "a_session");
1541        assert_eq!(i.flow_cookie, "a_session_flow");
1542        assert_eq!(i.cookie_path, "/app_a");
1543        assert_eq!(i.session_lifetime, Duration::from_secs(1200));
1544    }
1545
1546    /// A cookie path that does not cover the callback is rejected at load — it
1547    /// would starve the callback of the flow cookie and loop login forever.
1548    #[test]
1549    fn test_interactive_cookie_path_must_cover_callback() {
1550        let c = cfg(&[
1551            (
1552                "discovery",
1553                serde_json::json!("https://idp/.well-known/openid-configuration"),
1554            ),
1555            ("bearer_only", serde_json::json!(false)),
1556            ("client_id", serde_json::json!("app")),
1557            ("client_secret", serde_json::json!("s")),
1558            (
1559                "redirect_uri",
1560                serde_json::json!("https://app.example.com/app_a/callback"),
1561            ),
1562            (
1563                "session",
1564                serde_json::json!({
1565                    "secret": "s",
1566                    "cookie": { "path": "/app_b" }  // callback is under /app_a
1567                }),
1568            ),
1569        ]);
1570        // `.err().unwrap()` (not `unwrap_err()`): the Ok type isn't `Debug`.
1571        let err = OpenidConnectPlugin::from_config(&c, &PluginResources::empty())
1572            .err()
1573            .unwrap();
1574        assert!(
1575            err.contains("session.cookie.path"),
1576            "unexpected error: {err}"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_bearer_only_default_has_no_interactive() {
1582        let c = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
1583        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
1584        assert!(plugin.interactive.is_none());
1585    }
1586
1587    #[test]
1588    fn test_url_path() {
1589        assert_eq!(
1590            url_path("https://app.example.com/oidc/callback"),
1591            "/oidc/callback"
1592        );
1593        assert_eq!(url_path("https://app.example.com/cb?x=1"), "/cb");
1594        assert_eq!(url_path("https://app.example.com"), "/");
1595    }
1596
1597    #[test]
1598    fn test_pkce_challenge_is_stable_and_urlsafe() {
1599        // RFC 7636 test vector: verifier -> S256 challenge.
1600        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1601        let challenge = pkce_challenge(verifier);
1602        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
1603        assert!(!challenge.contains('+') && !challenge.contains('/') && !challenge.contains('='));
1604    }
1605
1606    #[test]
1607    fn test_flow_and_session_seal_round_trip() {
1608        let sealer = CookieSealer::new("k");
1609        let flow = FlowState {
1610            state: "st".into(),
1611            nonce: "nc".into(),
1612            verifier: "vf".into(),
1613            original_uri: "/dashboard?tab=1".into(),
1614        };
1615        let sealed = sealer.seal(
1616            &serde_json::to_vec(&flow).unwrap(),
1617            Duration::from_secs(300),
1618        );
1619        let back: FlowState = serde_json::from_slice(&sealer.open(&sealed).unwrap()).unwrap();
1620        assert_eq!(back.state, "st");
1621        assert_eq!(back.original_uri, "/dashboard?tab=1");
1622
1623        let session = SessionData {
1624            claims: serde_json::json!({ "sub": "u1", "name": "Alice" }),
1625            access_token: Some("at".into()),
1626        };
1627        let sealed = sealer.seal(
1628            &serde_json::to_vec(&session).unwrap(),
1629            Duration::from_secs(3600),
1630        );
1631        let back: SessionData = serde_json::from_slice(&sealer.open(&sealed).unwrap()).unwrap();
1632        assert_eq!(back.claims.get("sub").unwrap(), "u1");
1633        assert_eq!(back.access_token.as_deref(), Some("at"));
1634    }
1635
1636    #[test]
1637    fn test_rejects_no_validation_source() {
1638        let c = cfg(&[("client_id", serde_json::json!("x"))]);
1639        assert!(OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).is_err());
1640    }
1641
1642    #[test]
1643    fn test_accepts_jwks_and_introspection_configs() {
1644        let jwks = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
1645        assert!(OpenidConnectPlugin::from_config(&jwks, &PluginResources::empty()).is_ok());
1646
1647        let introspect = cfg(&[
1648            (
1649                "introspection_endpoint",
1650                serde_json::json!("https://idp/introspect"),
1651            ),
1652            ("client_id", serde_json::json!("id")),
1653            ("client_secret", serde_json::json!("secret")),
1654        ]);
1655        assert!(OpenidConnectPlugin::from_config(&introspect, &PluginResources::empty()).is_ok());
1656    }
1657
1658    #[test]
1659    fn test_rejects_unknown_alg() {
1660        let c = cfg(&[
1661            ("jwks_uri", serde_json::json!("https://idp/jwks")),
1662            (
1663                "token_signing_alg_values_expected",
1664                serde_json::json!("HS256"),
1665            ),
1666        ]);
1667        assert!(OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).is_err());
1668    }
1669
1670    #[test]
1671    fn test_select_jwk_by_kid() {
1672        let keys = vec![test_jwk("k1"), test_jwk("k2")];
1673        assert_eq!(
1674            select_jwk(&keys, Some("k2")).unwrap().kid.as_deref(),
1675            Some("k2")
1676        );
1677        assert!(select_jwk(&keys, Some("nope")).is_none());
1678        // No kid with multiple keys is ambiguous.
1679        assert!(select_jwk(&keys, None).is_none());
1680        // No kid with a single key resolves.
1681        let one = vec![test_jwk("only")];
1682        assert!(select_jwk(&one, None).is_some());
1683    }
1684
1685    #[test]
1686    fn test_jwk_to_decoding_key_rsa() {
1687        let jwk = test_jwk("k1");
1688        assert!(jwk_to_decoding_key(&jwk).is_ok());
1689        // Missing components fail.
1690        let mut bad = test_jwk("k1");
1691        bad.n = None;
1692        assert!(jwk_to_decoding_key(&bad).is_err());
1693    }
1694
1695    /// Regression: the *default* algorithm list spans RSA and EC, and
1696    /// jsonwebtoken rejects a Validation whose list contains any algorithm from a
1697    /// different family than the key. Verifying an RS256 token with the defaults
1698    /// therefore failed with `InvalidAlgorithm` — openid-connect did not work at
1699    /// all out of the box. Every pre-existing test passed a single-family list
1700    /// explicitly, which is exactly why none of them caught it.
1701    #[test]
1702    fn test_default_algs_verify_an_rs256_token() {
1703        let defaults = parse_allowed_algs(None).unwrap();
1704        assert!(
1705            defaults.len() > 1,
1706            "the default list must span families to be a regression test"
1707        );
1708
1709        let keys = vec![test_jwk("k1")];
1710        let token = sign(
1711            serde_json::json!({ "sub": "user-1", "exp": 9999999999u64 }),
1712            "k1",
1713        );
1714        let jwk = select_jwk(&keys, Some("k1")).unwrap();
1715        let key = jwk_to_decoding_key(jwk).unwrap();
1716
1717        // What the plugin now does: narrow the list to the key's family first.
1718        let algs = algs_for_key(&defaults, &jwk.kty).unwrap();
1719        let claims = decode_and_validate(&token, &key, &algs)
1720            .expect("an RS256 token must verify under the default algorithm list");
1721        assert_eq!(claims.get("sub").unwrap(), "user-1");
1722
1723        // Passing the unfiltered default list is what used to fail.
1724        assert!(
1725            decode_and_validate(&token, &key, &defaults).is_err(),
1726            "sanity: the unfiltered mixed-family list is rejected by jsonwebtoken"
1727        );
1728    }
1729
1730    #[test]
1731    fn test_algs_for_key_filters_by_family() {
1732        let defaults = parse_allowed_algs(None).unwrap();
1733
1734        let rsa = algs_for_key(&defaults, "RSA").unwrap();
1735        assert!(rsa.contains(&Algorithm::RS256));
1736        assert!(!rsa.contains(&Algorithm::ES256));
1737
1738        let ec = algs_for_key(&defaults, "EC").unwrap();
1739        assert!(ec.contains(&Algorithm::ES256));
1740        assert!(!ec.contains(&Algorithm::RS256));
1741
1742        // A key type nothing configured can verify is an error, not a silent pass.
1743        assert!(algs_for_key(&[Algorithm::RS256], "EC").is_err());
1744        assert!(algs_for_key(&defaults, "oct").is_err());
1745    }
1746
1747    #[test]
1748    fn test_verify_signed_token_end_to_end() {
1749        let keys = vec![test_jwk("k1")];
1750        let token = sign(
1751            serde_json::json!({ "sub": "user-1", "iss": "https://idp/", "aud": "my-api", "exp": 9999999999u64 }),
1752            "k1",
1753        );
1754        let jwk = select_jwk(&keys, Some("k1")).unwrap();
1755        let key = jwk_to_decoding_key(jwk).unwrap();
1756        let claims = decode_and_validate(&token, &key, &[Algorithm::RS256]).unwrap();
1757        assert_eq!(claims.get("sub").unwrap(), "user-1");
1758
1759        // Tampered signature (wrong kid selects the wrong key would fail; here
1760        // an expired token fails exp validation).
1761        let expired = sign(serde_json::json!({ "sub": "u", "exp": 100u64 }), "k1");
1762        assert!(decode_and_validate(&expired, &key, &[Algorithm::RS256]).is_err());
1763
1764        // Algorithm not in the allowed set is rejected.
1765        assert!(decode_and_validate(&token, &key, &[Algorithm::ES256]).is_err());
1766    }
1767
1768    #[test]
1769    fn test_validate_claims_issuer_and_audience() {
1770        let c = cfg(&[
1771            ("jwks_uri", serde_json::json!("https://idp/jwks")),
1772            ("client_id", serde_json::json!("my-api")),
1773            (
1774                "claim_validator",
1775                serde_json::json!({
1776                    "issuer": { "valid_issuers": ["https://idp/"] },
1777                    "audience": { "required": true, "match_with_client_id": true }
1778                }),
1779            ),
1780        ]);
1781        let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
1782
1783        let good: HashMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
1784            "iss": "https://idp/", "aud": ["my-api", "other"], "sub": "u"
1785        }))
1786        .unwrap();
1787        assert!(plugin.validate_claims(&good).is_ok());
1788
1789        // Wrong issuer.
1790        let bad_iss: HashMap<String, serde_json::Value> =
1791            serde_json::from_value(serde_json::json!({
1792                "iss": "https://evil/", "aud": "my-api"
1793            }))
1794            .unwrap();
1795        assert!(plugin.validate_claims(&bad_iss).is_err());
1796
1797        // Audience does not include client_id.
1798        let bad_aud: HashMap<String, serde_json::Value> =
1799            serde_json::from_value(serde_json::json!({
1800                "iss": "https://idp/", "aud": "someone-else"
1801            }))
1802            .unwrap();
1803        assert!(plugin.validate_claims(&bad_aud).is_err());
1804
1805        // Missing required audience.
1806        let no_aud: HashMap<String, serde_json::Value> =
1807            serde_json::from_value(serde_json::json!({
1808                "iss": "https://idp/"
1809            }))
1810            .unwrap();
1811        assert!(plugin.validate_claims(&no_aud).is_err());
1812    }
1813
1814    #[test]
1815    fn test_parse_introspection() {
1816        let active =
1817            serde_json::to_vec(&serde_json::json!({ "active": true, "sub": "u1" })).unwrap();
1818        let claims = parse_introspection(&active).unwrap();
1819        assert_eq!(claims.get("sub").unwrap(), "u1");
1820
1821        let inactive = serde_json::to_vec(&serde_json::json!({ "active": false })).unwrap();
1822        assert!(parse_introspection(&inactive).is_err());
1823    }
1824
1825    #[test]
1826    fn test_parse_bearer() {
1827        assert_eq!(parse_bearer("Bearer abc.def"), Some("abc.def"));
1828        assert_eq!(parse_bearer("bearer xyz"), Some("xyz"));
1829        assert_eq!(parse_bearer("Basic abc"), None);
1830        assert_eq!(parse_bearer("Bearer "), None);
1831        assert_eq!(parse_bearer("token"), None);
1832    }
1833}