1use 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#[derive(Serialize, Deserialize)]
72struct FlowState {
73 state: String,
74 nonce: String,
75 verifier: String,
76 original_uri: String,
77}
78
79#[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
87struct Interactive {
89 sealer: CookieSealer,
90 authorization_endpoint_cfg: Option<String>,
91 token_endpoint_cfg: Option<String>,
92 redirect_uri: String,
93 redirect_path: String,
95 scope: String,
96 session_cookie: String,
97 flow_cookie: String,
98 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#[derive(Debug, Clone, Deserialize)]
111#[allow(dead_code)]
114struct Jwk {
115 kty: String,
116 kid: Option<String>,
117 alg: Option<String>,
118 n: Option<String>,
120 e: Option<String>,
121 x: Option<String>,
123 y: Option<String>,
124 crv: Option<String>,
125}
126
127#[derive(Debug, Clone, Deserialize)]
129struct JwkSet {
130 keys: Vec<Jwk>,
131}
132
133struct CachedJwks {
135 keys: Vec<Jwk>,
136 fetched_at: Instant,
137}
138
139pub struct OpenidConnectPlugin {
142 discovery: Option<String>,
144 jwks_uri_cfg: Option<String>,
146 introspection_endpoint: Option<String>,
148 client_id: Option<String>,
149 client_secret: Option<String>,
150 ssl_verify: bool,
151 timeout: Duration,
152 allowed_algs: Vec<Algorithm>,
154 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 use_jwks: bool,
164 jwk_ttl: Duration,
165 resources: Arc<PluginResources>,
166 jwks_uri_resolved: Mutex<Option<String>>,
168 jwks_cache: Mutex<Option<CachedJwks>>,
169 interactive: Option<Interactive>,
171}
172
173impl OpenidConnectPlugin {
174 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 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 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 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 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 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 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 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 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 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 fn validate_claims(&self, claims: &HashMap<String, serde_json::Value>) -> Result<(), String> {
489 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 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 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 async fn execute_interactive(&self, mut ctx: Context) -> PluginResult {
579 let flow = self.interactive.as_ref().expect("interactive mode");
580
581 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 if ctx.request.path == flow.redirect_path && ctx.request.query_params.contains_key("code") {
591 return self.handle_callback(ctx).await;
592 }
593
594 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 self.begin_auth(ctx).await
627 }
628
629 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 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 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 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 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 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 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 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 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 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 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 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
881fn 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 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
950fn 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
960fn 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
977fn 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
990fn 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
1007fn 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
1016fn request_is_https(ctx: &Context) -> bool {
1018 ctx.request.scheme.eq_ignore_ascii_case("https")
1019}
1020
1021fn 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
1030fn pkce_challenge(verifier: &str) -> String {
1032 URL_SAFE_NO_PAD.encode(digest(&SHA256, verifier.as_bytes()).as_ref())
1033}
1034
1035fn 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
1059fn 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
1068fn 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
1084fn 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
1132fn 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
1147fn 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
1168fn 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
1182fn 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
1197fn 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
1231fn 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
1248fn 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 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
1267fn 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
1285fn 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
1294fn 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
1306fn 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 ctx.request.headers.remove("x-userinfo");
1333
1334 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 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 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 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 assert_eq!(interactive.cookie_path, "/");
1481 assert_eq!(interactive.session_lifetime, Duration::from_secs(3600));
1482 }
1483
1484 #[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 #[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 #[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" } }),
1568 ),
1569 ]);
1570 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 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 assert!(select_jwk(&keys, None).is_none());
1680 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 let mut bad = test_jwk("k1");
1691 bad.n = None;
1692 assert!(jwk_to_decoding_key(&bad).is_err());
1693 }
1694
1695 #[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 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 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 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 let expired = sign(serde_json::json!({ "sub": "u", "exp": 100u64 }), "k1");
1762 assert!(decode_and_validate(&expired, &key, &[Algorithm::RS256]).is_err());
1763
1764 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 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 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 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}