1use async_trait::async_trait;
65use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
66use base64::engine::general_purpose::URL_SAFE_NO_PAD;
67use base64::Engine;
68use bytes::Bytes;
69use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
70use ring::digest::{digest, SHA256};
71use ring::rand::{SecureRandom, SystemRandom};
72use serde::{Deserialize, Serialize};
73use std::collections::HashMap;
74use std::ops::ControlFlow;
75use std::sync::Arc;
76use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
77use tokio::sync::Mutex;
78
79use crate::context::{Context, GatewayError};
80use crate::outbound::{OutboundRequest, OutboundResponse};
81use crate::plugins::resources::PluginResources;
82use crate::plugins::util::cookie_session::{
83 build_set_cookie, delete_cookie, path_covers, read_cookie, CookieAttrs, CookieSealer, SameSite,
84};
85use crate::plugins::util::server_session::{self, SessionBackend};
86use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
87use crate::sessions::{SessionId, StoreError};
88
89#[derive(Serialize, Deserialize)]
93struct FlowState {
94 state: String,
95 nonce: String,
96 verifier: String,
97 original_uri: String,
98}
99
100#[derive(Serialize, Deserialize)]
107struct SessionData {
108 claims: serde_json::Value,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 access_token: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 refresh_token: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 expires_at: Option<u64>,
116}
117
118struct Interactive {
120 sealer: CookieSealer,
121 backend: SessionBackend,
122 authorization_endpoint_cfg: Option<String>,
123 token_endpoint_cfg: Option<String>,
124 redirect_uri: String,
125 redirect_path: String,
127 scope: String,
128 session_cookie: String,
129 flow_cookie: String,
130 cookie_path: String,
134 session_lifetime: Duration,
135 refresh_enabled: bool,
139 logout_path: Option<String>,
140 post_logout_redirect_uri: String,
141 authz_endpoint_resolved: Mutex<Option<String>>,
142 token_endpoint_resolved: Mutex<Option<String>>,
143}
144
145#[derive(Debug, Clone, Deserialize)]
147#[allow(dead_code)]
150struct Jwk {
151 kty: String,
152 kid: Option<String>,
153 alg: Option<String>,
154 n: Option<String>,
156 e: Option<String>,
157 x: Option<String>,
159 y: Option<String>,
160 crv: Option<String>,
161}
162
163#[derive(Debug, Clone, Deserialize)]
165struct JwkSet {
166 keys: Vec<Jwk>,
167}
168
169struct CachedJwks {
171 keys: Vec<Jwk>,
172 fetched_at: Instant,
173}
174
175#[derive(Debug)]
182enum TokenError {
183 Infra(String),
184 Denied(String),
185}
186
187#[derive(Debug)]
193enum RefreshFailure {
194 ReAuth(String),
195 Store(StoreError),
196}
197
198pub struct OpenidConnectPlugin {
201 discovery: Option<String>,
203 jwks_uri_cfg: Option<String>,
205 introspection_endpoint: Option<String>,
207 client_id: Option<String>,
208 client_secret: Option<String>,
209 ssl_verify: bool,
210 timeout: Duration,
211 allowed_algs: Vec<Algorithm>,
213 valid_issuers: Vec<String>,
215 audience_claim: String,
216 audience_required: bool,
217 audience_match_client_id: bool,
218 set_userinfo_header: bool,
219 set_access_token_header: bool,
220 access_token_in_authorization_header: bool,
221 use_jwks: bool,
223 jwk_ttl: Duration,
224 resources: Arc<PluginResources>,
225 jwks_uri_resolved: Mutex<Option<String>>,
227 jwks_cache: Mutex<Option<CachedJwks>>,
228 interactive: Option<Interactive>,
230}
231
232impl OpenidConnectPlugin {
233 pub fn from_config(
279 config: &HashMap<String, serde_json::Value>,
280 resources: &Arc<PluginResources>,
281 ) -> Result<Self, String> {
282 let bearer_only = config
283 .get("bearer_only")
284 .and_then(|v| v.as_bool())
285 .unwrap_or(true);
286
287 let discovery = string_opt(config, "discovery");
288 let jwks_uri_cfg = string_opt(config, "jwks_uri");
289 let introspection_endpoint = string_opt(config, "introspection_endpoint");
290
291 let use_jwks = discovery.is_some() || jwks_uri_cfg.is_some();
292 if !use_jwks && introspection_endpoint.is_none() {
293 return Err(
294 "openid-connect: requires a JWKS source ('discovery' or 'jwks_uri') \
295 or an 'introspection_endpoint'"
296 .to_string(),
297 );
298 }
299
300 let client_id = string_opt(config, "client_id");
301 let client_secret = string_opt(config, "client_secret");
302
303 if !use_jwks && (client_id.is_none() || client_secret.is_none()) {
305 return Err(
306 "openid-connect: introspection requires 'client_id' and 'client_secret'"
307 .to_string(),
308 );
309 }
310
311 let interactive = if bearer_only {
316 None
317 } else {
318 if !use_jwks {
319 return Err("openid-connect: interactive login requires a JWKS source \
320 ('discovery' or 'jwks_uri') to validate the id_token"
321 .to_string());
322 }
323 if client_id.is_none() || client_secret.is_none() {
324 return Err(
325 "openid-connect: interactive login requires 'client_id' and \
326 'client_secret'"
327 .to_string(),
328 );
329 }
330 Some(build_interactive(config, &discovery, resources)?)
331 };
332
333 let allowed_algs = parse_allowed_algs(config.get("token_signing_alg_values_expected"))?;
334
335 let valid_issuers = read_valid_issuers(config);
336 let (audience_claim, audience_required, audience_match_client_id) =
337 read_audience_cfg(config);
338
339 let set_userinfo_header = config
340 .get("set_userinfo_header")
341 .and_then(|v| v.as_bool())
342 .unwrap_or(true);
343 let set_access_token_header = config
344 .get("set_access_token_header")
345 .and_then(|v| v.as_bool())
346 .unwrap_or(true);
347 let access_token_in_authorization_header = config
348 .get("access_token_in_authorization_header")
349 .and_then(|v| v.as_bool())
350 .unwrap_or(false);
351
352 let ssl_verify = config
353 .get("ssl_verify")
354 .and_then(|v| v.as_bool())
355 .unwrap_or(true);
356 let timeout = Duration::from_secs(
357 config
358 .get("timeout")
359 .and_then(|v| v.as_u64())
360 .unwrap_or(3)
361 .max(1),
362 );
363 let jwk_ttl = Duration::from_secs(
364 config
365 .get("jwk_expires_in")
366 .and_then(|v| v.as_u64())
367 .unwrap_or(86400),
368 );
369
370 Ok(Self {
371 discovery,
372 jwks_uri_cfg,
373 introspection_endpoint,
374 client_id,
375 client_secret,
376 ssl_verify,
377 timeout,
378 allowed_algs,
379 valid_issuers,
380 audience_claim,
381 audience_required,
382 audience_match_client_id,
383 set_userinfo_header,
384 set_access_token_header,
385 access_token_in_authorization_header,
386 use_jwks,
387 jwk_ttl,
388 resources: resources.clone(),
389 jwks_uri_resolved: Mutex::new(None),
390 jwks_cache: Mutex::new(None),
391 interactive,
392 })
393 }
394
395 async fn jwks_uri(&self) -> Result<String, TokenError> {
399 if let Some(uri) = &self.jwks_uri_cfg {
400 return Ok(uri.clone());
401 }
402 {
403 let cached = self.jwks_uri_resolved.lock().await;
404 if let Some(uri) = cached.as_ref() {
405 return Ok(uri.clone());
406 }
407 }
408 let discovery = self
409 .discovery
410 .as_ref()
411 .ok_or_else(|| TokenError::Infra("no discovery URL configured".to_string()))?;
412 let resp = self
413 .get(discovery)
414 .await
415 .map_err(|e| TokenError::Infra(format!("discovery fetch failed: {}", e)))?;
416 if resp.status != 200 {
417 return Err(TokenError::Infra(format!(
418 "discovery returned status {}",
419 resp.status
420 )));
421 }
422 let doc: serde_json::Value = serde_json::from_slice(&resp.body)
423 .map_err(|e| TokenError::Infra(format!("failed to parse discovery doc: {}", e)))?;
424 let uri = doc
425 .get("jwks_uri")
426 .and_then(|v| v.as_str())
427 .ok_or_else(|| TokenError::Infra("discovery doc missing jwks_uri".to_string()))?
428 .to_string();
429 *self.jwks_uri_resolved.lock().await = Some(uri.clone());
430 Ok(uri)
431 }
432
433 async fn get_jwks(&self, force: bool) -> Result<Vec<Jwk>, TokenError> {
438 let mut cache = self.jwks_cache.lock().await;
439 if !force {
440 if let Some(c) = cache.as_ref() {
441 if c.fetched_at.elapsed() < self.jwk_ttl {
442 return Ok(c.keys.clone());
443 }
444 }
445 }
446 let uri = self.jwks_uri().await?;
447 let resp = self
448 .get(&uri)
449 .await
450 .map_err(|e| TokenError::Infra(format!("JWKS fetch failed: {}", e)))?;
451 if resp.status != 200 {
452 return Err(TokenError::Infra(format!(
453 "JWKS endpoint returned status {}",
454 resp.status
455 )));
456 }
457 let set: JwkSet = serde_json::from_slice(&resp.body)
458 .map_err(|e| TokenError::Infra(format!("failed to parse JWKS: {}", e)))?;
459 *cache = Some(CachedJwks {
460 keys: set.keys.clone(),
461 fetched_at: Instant::now(),
462 });
463 Ok(set.keys)
464 }
465
466 async fn get(&self, url: &str) -> Result<OutboundResponse, crate::outbound::OutboundError> {
468 let req = OutboundRequest {
469 method: http::Method::GET,
470 url: url.to_string(),
471 headers: Vec::new(),
472 body: Bytes::new(),
473 timeout: self.timeout,
474 ssl_verify: self.ssl_verify,
475 tls: None,
476 };
477 self.resources.outbound.request(req).await
478 }
479
480 async fn validate_via_jwks(
485 &self,
486 token: &str,
487 ) -> Result<HashMap<String, serde_json::Value>, TokenError> {
488 let header = decode_header(token)
489 .map_err(|e| TokenError::Denied(format!("invalid JWT header: {}", e)))?;
490 let kid = header.kid.clone();
491
492 let keys = self.get_jwks(false).await?;
493 let jwk = match select_jwk(&keys, kid.as_deref()) {
494 Some(j) => j.clone(),
495 None => {
496 let keys = self.get_jwks(true).await?;
498 select_jwk(&keys, kid.as_deref()).cloned().ok_or_else(|| {
499 TokenError::Denied("no matching JWK for token kid".to_string())
500 })?
501 }
502 };
503
504 let key = jwk_to_decoding_key(&jwk).map_err(TokenError::Infra)?;
507 let algs = algs_for_key(&self.allowed_algs, &jwk.kty).map_err(TokenError::Infra)?;
513 let claims = decode_and_validate(token, &key, &algs).map_err(TokenError::Denied)?;
514 self.validate_claims(&claims).map_err(TokenError::Denied)?;
515 Ok(claims)
516 }
517
518 async fn validate_via_introspection(
523 &self,
524 token: &str,
525 ) -> Result<HashMap<String, serde_json::Value>, TokenError> {
526 let endpoint = self
527 .introspection_endpoint
528 .as_ref()
529 .ok_or_else(|| TokenError::Infra("no introspection endpoint configured".to_string()))?;
530 let client_id = self.client_id.as_deref().unwrap_or("");
531 let client_secret = self.client_secret.as_deref().unwrap_or("");
532 let basic = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret));
533
534 let body = format!("token={}&token_type_hint=access_token", form_encode(token));
535 let req = OutboundRequest {
536 method: http::Method::POST,
537 url: endpoint.clone(),
538 headers: vec![
539 (
540 "content-type".to_string(),
541 "application/x-www-form-urlencoded".to_string(),
542 ),
543 ("authorization".to_string(), format!("Basic {}", basic)),
544 ("accept".to_string(), "application/json".to_string()),
545 ],
546 body: Bytes::from(body),
547 timeout: self.timeout,
548 ssl_verify: self.ssl_verify,
549 tls: None,
550 };
551 let resp = self
552 .resources
553 .outbound
554 .request(req)
555 .await
556 .map_err(|e| TokenError::Infra(format!("introspection callout failed: {}", e)))?;
557 if resp.status != 200 {
558 return Err(TokenError::Infra(format!(
559 "introspection returned status {}",
560 resp.status
561 )));
562 }
563 let claims = parse_introspection(&resp.body)?;
564 self.validate_claims(&claims).map_err(TokenError::Denied)?;
565 Ok(claims)
566 }
567
568 fn validate_claims(&self, claims: &HashMap<String, serde_json::Value>) -> Result<(), String> {
570 if !self.valid_issuers.is_empty() {
572 let iss = claims.get("iss").and_then(|v| v.as_str());
573 match iss {
574 Some(iss) if self.valid_issuers.iter().any(|v| v == iss) => {}
575 _ => return Err("issuer not in valid_issuers".to_string()),
576 }
577 }
578
579 let aud = claims.get(&self.audience_claim);
581 if self.audience_required && aud.is_none() {
582 return Err(format!(
583 "required audience claim '{}' missing",
584 self.audience_claim
585 ));
586 }
587 if self.audience_match_client_id {
588 if let Some(aud) = aud {
589 let client_id = self.client_id.as_deref().unwrap_or("");
590 if !audience_contains(aud, client_id) {
591 return Err("audience does not match client_id".to_string());
592 }
593 }
594 }
595 Ok(())
596 }
597
598 fn attach(&self, ctx: &mut Context, claims: HashMap<String, serde_json::Value>, token: &str) {
601 let claims_value = serde_json::to_value(&claims).unwrap_or_default();
602 if let Some(sub) = claims.get("sub") {
603 ctx.message.insert("user_id".to_string(), sub.clone());
604 }
605 ctx.message
606 .insert("jwt_claims".to_string(), claims_value.clone());
607
608 if self.set_userinfo_header {
609 if let Ok(raw) = serde_json::to_vec(&claims_value) {
610 ctx.request
611 .headers
612 .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
613 }
614 }
615 if self.set_access_token_header {
616 if self.access_token_in_authorization_header {
617 ctx.request.headers.insert(
618 "authorization".to_string(),
619 vec![format!("Bearer {}", token)],
620 );
621 } else {
622 ctx.request
623 .headers
624 .insert("x-access-token".to_string(), vec![token.to_string()]);
625 }
626 }
627 }
628
629 fn reject(ctx: Context, message: &str) -> PluginResult {
633 let mut ctx = ctx;
634 ctx.response.status_code = 401;
635 ctx.response.body = Bytes::from(format!(
636 r#"{{"error": "unauthorized", "message": "{}"}}"#,
637 message.replace('"', "'")
638 ));
639 ctx.response.headers.insert(
640 "content-type".to_string(),
641 vec!["application/json".to_string()],
642 );
643 ctx.response.headers.insert(
644 "www-authenticate".to_string(),
645 vec!["Bearer error=\"invalid_token\"".to_string()],
646 );
647 Ok(PluginOutput::on_port(ctx, "denied"))
648 }
649
650 fn infra_error(ctx: Context, message: String) -> PluginExecutionError {
662 crate::plugins::util::provider_error::provider_error(ctx, "OIDC_PROVIDER_ERROR", message)
663 }
664
665 fn store_error(mut ctx: Context, e: StoreError) -> PluginExecutionError {
669 ctx.response.status_code = 503;
670 ctx.response.body = Bytes::from(r#"{"error": "session store unavailable"}"#.as_bytes());
671 ctx.response.headers.insert(
672 "content-type".to_string(),
673 vec!["application/json".to_string()],
674 );
675 PluginExecutionError {
676 context: ctx,
677 error: GatewayError {
678 node_id: String::new(),
679 code: "SESSION_STORE_ERROR".to_string(),
680 message: e.to_string(),
681 metadata: HashMap::new(),
682 },
683 }
684 }
685
686 async fn execute_interactive(&self, ctx: Context) -> PluginResult {
691 let flow = self.interactive.as_ref().expect("interactive mode");
692
693 if let Some(logout_path) = &flow.logout_path {
695 if ctx.request.path == *logout_path {
696 let cookie_value = ctx
697 .request
698 .headers
699 .get("cookie")
700 .and_then(|v| v.first())
701 .and_then(|h| read_cookie(h, &flow.session_cookie))
702 .map(str::to_string);
703 let clear = match server_session::destroy(
704 &flow.backend,
705 cookie_value.as_deref(),
706 &flow.session_cookie,
707 &flow.cookie_path,
708 )
709 .await
710 {
711 Ok(c) => c,
712 Err(e) => return Err(Self::store_error(ctx, e)),
713 };
714 return redirect(ctx, &flow.post_logout_redirect_uri, vec![clear]);
715 }
716 }
717
718 if ctx.request.path == flow.redirect_path && ctx.request.query_params.contains_key("code") {
720 return self.handle_callback(ctx).await;
721 }
722
723 let raw_cookie_value = ctx
728 .request
729 .headers
730 .get("cookie")
731 .and_then(|v| v.first())
732 .and_then(|h| read_cookie(h, &flow.session_cookie))
733 .map(str::to_string);
734 let session = match self.read_session(&ctx).await {
735 Ok(s) => s,
736 Err(e) => return Err(Self::store_error(ctx, e)),
737 };
738 if let Some(session) = session {
739 let (mut ctx, session) = match raw_cookie_value.as_deref() {
740 Some(raw) => match self.refresh_if_needed(ctx, flow, raw, session).await {
741 ControlFlow::Continue(pair) => pair,
742 ControlFlow::Break(result) => return result,
743 },
744 None => (ctx, session),
748 };
749 if let Some(sub) = session.claims.get("sub") {
750 ctx.message.insert("user_id".to_string(), sub.clone());
751 }
752 ctx.message
753 .insert("jwt_claims".to_string(), session.claims.clone());
754 if self.set_userinfo_header {
755 if let Ok(raw) = serde_json::to_vec(&session.claims) {
756 ctx.request
757 .headers
758 .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
759 }
760 }
761 if let (true, Some(tok)) = (self.set_access_token_header, &session.access_token) {
762 if self.access_token_in_authorization_header {
763 ctx.request
764 .headers
765 .insert("authorization".to_string(), vec![format!("Bearer {}", tok)]);
766 } else {
767 ctx.request
768 .headers
769 .insert("x-access-token".to_string(), vec![tok.clone()]);
770 }
771 }
772 return Ok(PluginOutput::success(ctx));
773 }
774
775 self.begin_auth(ctx).await
777 }
778
779 async fn read_session(&self, ctx: &Context) -> Result<Option<SessionData>, StoreError> {
782 let Some(flow) = self.interactive.as_ref() else {
783 return Ok(None);
784 };
785 let Some(cookie_header) = ctx.request.headers.get("cookie").and_then(|v| v.first()) else {
786 return Ok(None);
787 };
788 let Some(raw) = read_cookie(cookie_header, &flow.session_cookie) else {
789 return Ok(None);
790 };
791 let bytes = server_session::load(&flow.backend, &flow.sealer, raw).await?;
792 Ok(bytes.and_then(|b| serde_json::from_slice(&b).ok()))
793 }
794
795 async fn refresh_if_needed(
808 &self,
809 ctx: Context,
810 flow: &Interactive,
811 raw: &str,
812 session: SessionData,
813 ) -> ControlFlow<PluginResult, (Context, SessionData)> {
814 if !flow.refresh_enabled || !matches!(flow.backend, SessionBackend::Store { .. }) {
815 return ControlFlow::Continue((ctx, session));
816 }
817 let (Some(expires_at), Some(refresh_token)) =
818 (session.expires_at, session.refresh_token.clone())
819 else {
820 return ControlFlow::Continue((ctx, session));
821 };
822 if now_unix() + 30 < expires_at {
823 return ControlFlow::Continue((ctx, session));
824 }
825 let Some(id) = SessionId::parse(raw) else {
826 return ControlFlow::Continue((ctx, session));
830 };
831 let SessionBackend::Store { store, .. } = &flow.backend else {
832 unreachable!("matches! above guarantees Store");
833 };
834
835 let acquired = match store.try_lock(&id, Duration::from_secs(10)).await {
836 Ok(b) => b,
837 Err(e) => return ControlFlow::Break(Err(Self::store_error(ctx, e))),
838 };
839 if !acquired {
840 return match self.read_session(&ctx).await {
843 Ok(Some(fresh)) => ControlFlow::Continue((ctx, fresh)),
844 Ok(None) => ControlFlow::Break(self.begin_auth(ctx).await),
847 Err(e) => ControlFlow::Break(Err(Self::store_error(ctx, e))),
848 };
849 }
850
851 match self
853 .do_refresh(&ctx, flow, &id, &refresh_token, &session)
854 .await
855 {
856 Ok(updated) => {
857 if let Err(e) = store.unlock(&id).await {
862 tracing::warn!(
863 "openid-connect: post-refresh unlock failed (self-heals via TTL): {e}"
864 );
865 }
866 ControlFlow::Continue((ctx, updated))
867 }
868 Err(RefreshFailure::ReAuth(reason)) => {
869 tracing::debug!(
874 "openid-connect: redis-mode refresh failed, falling back to re-login: {reason}"
875 );
876 let _ = store.unlock(&id).await;
879 ControlFlow::Break(self.begin_auth(ctx).await)
880 }
881 Err(RefreshFailure::Store(e)) => {
882 let _ = store.unlock(&id).await;
885 ControlFlow::Break(Err(Self::store_error(ctx, e)))
886 }
887 }
888 }
889
890 async fn do_refresh(
894 &self,
895 ctx: &Context,
896 flow: &Interactive,
897 id: &SessionId,
898 refresh_token: &str,
899 old: &SessionData,
900 ) -> Result<SessionData, RefreshFailure> {
901 let tokens = self
902 .refresh_tokens(refresh_token)
903 .await
904 .map_err(RefreshFailure::ReAuth)?;
905
906 let claims = match tokens.get("id_token").and_then(|v| v.as_str()) {
907 Some(id_token) => match self.validate_via_jwks(id_token).await {
908 Ok(c) => serde_json::to_value(&c).unwrap_or_default(),
909 Err(TokenError::Infra(e)) | Err(TokenError::Denied(e)) => {
910 return Err(RefreshFailure::ReAuth(format!(
911 "refreshed id_token validation failed: {e}"
912 )))
913 }
914 },
915 None => old.claims.clone(),
916 };
917 let access_token = tokens
918 .get("access_token")
919 .and_then(|v| v.as_str())
920 .map(String::from)
921 .or_else(|| old.access_token.clone());
922 let refresh_token = tokens
924 .get("refresh_token")
925 .and_then(|v| v.as_str())
926 .map(String::from)
927 .or_else(|| old.refresh_token.clone());
928 let expires_at = tokens
929 .get("expires_in")
930 .and_then(|v| v.as_u64())
931 .map(|secs| now_unix() + secs)
932 .or(old.expires_at);
933
934 let updated = SessionData {
935 claims,
936 access_token,
937 refresh_token,
938 expires_at,
939 };
940 let payload = serde_json::to_vec(&updated)
941 .map_err(|e| RefreshFailure::ReAuth(format!("session serialize failed: {e}")))?;
942 let subject = updated
943 .claims
944 .get("sub")
945 .and_then(|v| v.as_str())
946 .unwrap_or("")
947 .to_string();
948
949 let ttl = flow.session_lifetime;
955 let meta = server_session::meta_now(ctx, "openid-connect", &subject, ttl);
956 server_session::update(
957 &flow.backend,
958 &flow.sealer,
959 id.as_str(),
960 &payload,
961 ttl,
962 meta,
963 &flow.session_cookie,
964 &CookieAttrs {
965 path: &flow.cookie_path,
966 max_age: Some(ttl.as_secs()),
967 http_only: true,
968 secure: request_is_https(ctx),
969 same_site: SameSite::Lax,
970 },
971 )
972 .await
973 .map_err(RefreshFailure::Store)?;
974
975 Ok(updated)
976 }
977
978 async fn refresh_tokens(&self, refresh_token: &str) -> Result<serde_json::Value, String> {
982 let token_endpoint = self.token_endpoint().await?;
983 let client_id = self.client_id.as_deref().unwrap_or("");
984 let client_secret = self.client_secret.as_deref().unwrap_or("");
985 let body = format!(
986 "grant_type=refresh_token&refresh_token={}&client_id={}&client_secret={}",
987 form_encode(refresh_token),
988 form_encode(client_id),
989 form_encode(client_secret),
990 );
991 let req = OutboundRequest {
992 method: http::Method::POST,
993 url: token_endpoint,
994 headers: vec![
995 (
996 "content-type".to_string(),
997 "application/x-www-form-urlencoded".to_string(),
998 ),
999 ("accept".to_string(), "application/json".to_string()),
1000 ],
1001 body: Bytes::from(body),
1002 timeout: self.timeout,
1003 ssl_verify: self.ssl_verify,
1004 tls: None,
1005 };
1006 let resp = self
1007 .resources
1008 .outbound
1009 .request(req)
1010 .await
1011 .map_err(|e| format!("token refresh callout failed: {}", e))?;
1012 if resp.status != 200 {
1013 return Err(format!("token endpoint returned status {}", resp.status));
1014 }
1015 serde_json::from_slice(&resp.body).map_err(|e| format!("invalid token response: {}", e))
1016 }
1017
1018 async fn begin_auth(&self, ctx: Context) -> PluginResult {
1021 let flow = self.interactive.as_ref().expect("interactive mode");
1022 let authz = match self.authorization_endpoint().await {
1023 Ok(u) => u,
1024 Err(e) => return Err(Self::infra_error(ctx, e)),
1025 };
1026
1027 let state = random_token();
1028 let nonce = random_token();
1029 let verifier = random_token();
1030 let challenge = pkce_challenge(&verifier);
1031 let original_uri = request_uri(&ctx);
1032
1033 let flow_state = FlowState {
1034 state: state.clone(),
1035 nonce: nonce.clone(),
1036 verifier,
1037 original_uri,
1038 };
1039 let sealed = match serde_json::to_vec(&flow_state) {
1040 Ok(b) => flow.sealer.seal(&b, Duration::from_secs(300)),
1041 Err(e) => {
1042 return Err(Self::infra_error(
1043 ctx,
1044 format!("flow cookie seal failed: {}", e),
1045 ))
1046 }
1047 };
1048 let set_flow = build_set_cookie(
1049 &flow.flow_cookie,
1050 &sealed,
1051 &CookieAttrs {
1052 path: &flow.cookie_path,
1053 max_age: Some(300),
1054 http_only: true,
1055 secure: request_is_https(&ctx),
1056 same_site: SameSite::Lax,
1057 },
1058 );
1059
1060 let url = format!(
1061 "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}\
1062 &code_challenge={}&code_challenge_method=S256",
1063 authz,
1064 form_encode(self.client_id.as_deref().unwrap_or("")),
1065 form_encode(&flow.redirect_uri),
1066 form_encode(&flow.scope),
1067 form_encode(&state),
1068 form_encode(&nonce),
1069 form_encode(&challenge),
1070 );
1071 redirect(ctx, &url, vec![set_flow])
1072 }
1073
1074 async fn handle_callback(&self, ctx: Context) -> PluginResult {
1077 let flow = self.interactive.as_ref().expect("interactive mode");
1078
1079 let code = first_query(&ctx, "code").unwrap_or_default();
1080 let state = first_query(&ctx, "state").unwrap_or_default();
1081
1082 let flow_state = match self.read_flow(&ctx) {
1084 Some(f) => f,
1085 None => return Self::reject(ctx, "missing or invalid login flow cookie"),
1086 };
1087 if flow_state.state != state || state.is_empty() {
1088 return Self::reject(ctx, "state mismatch (possible CSRF)");
1089 }
1090
1091 let token_endpoint = match self.token_endpoint().await {
1096 Ok(u) => u,
1097 Err(e) => return Err(Self::infra_error(ctx, e)),
1098 };
1099 let tokens = match self
1100 .exchange_code(
1101 &token_endpoint,
1102 &code,
1103 &flow_state.verifier,
1104 &flow.redirect_uri,
1105 )
1106 .await
1107 {
1108 Ok(t) => t,
1109 Err(e) => return Err(Self::infra_error(ctx, e)),
1110 };
1111
1112 let id_token = match tokens.get("id_token").and_then(|v| v.as_str()) {
1113 Some(t) => t.to_string(),
1114 None => {
1115 return Err(Self::infra_error(
1116 ctx,
1117 "token response missing id_token".to_string(),
1118 ))
1119 }
1120 };
1121 let access_token = tokens
1122 .get("access_token")
1123 .and_then(|v| v.as_str())
1124 .map(String::from);
1125 let (refresh_token, expires_at) =
1130 if matches!(flow.backend, SessionBackend::Store { .. }) && flow.refresh_enabled {
1131 let refresh_token = tokens
1132 .get("refresh_token")
1133 .and_then(|v| v.as_str())
1134 .map(String::from);
1135 let expires_at = tokens
1136 .get("expires_in")
1137 .and_then(|v| v.as_u64())
1138 .map(|secs| now_unix() + secs);
1139 (refresh_token, expires_at)
1140 } else {
1141 (None, None)
1142 };
1143
1144 let claims = match self.validate_via_jwks(&id_token).await {
1149 Ok(c) => c,
1150 Err(TokenError::Infra(e)) => {
1151 return Err(Self::infra_error(
1152 ctx,
1153 format!("id_token validation failed: {}", e),
1154 ))
1155 }
1156 Err(TokenError::Denied(e)) => {
1157 return Self::reject(ctx, &format!("id_token validation failed: {}", e))
1158 }
1159 };
1160 if claims.get("nonce").and_then(|v| v.as_str()) != Some(flow_state.nonce.as_str()) {
1161 return Self::reject(ctx, "id_token nonce mismatch");
1162 }
1163
1164 let session = SessionData {
1166 claims: serde_json::to_value(&claims).unwrap_or_default(),
1167 access_token,
1168 refresh_token,
1169 expires_at,
1170 };
1171 let payload = match serde_json::to_vec(&session) {
1172 Ok(b) => b,
1173 Err(e) => {
1174 return Err(Self::infra_error(
1175 ctx,
1176 format!("session serialize failed: {}", e),
1177 ))
1178 }
1179 };
1180 let subject = claims
1181 .get("sub")
1182 .and_then(|v| v.as_str())
1183 .unwrap_or("")
1184 .to_string();
1185 let meta =
1186 server_session::meta_now(&ctx, "openid-connect", &subject, flow.session_lifetime);
1187 let set_session = match server_session::establish(
1188 &flow.backend,
1189 &flow.sealer,
1190 &payload,
1191 flow.session_lifetime,
1192 meta,
1193 &flow.session_cookie,
1194 &CookieAttrs {
1195 path: &flow.cookie_path,
1196 max_age: Some(flow.session_lifetime.as_secs()),
1197 http_only: true,
1198 secure: request_is_https(&ctx),
1199 same_site: SameSite::Lax,
1200 },
1201 )
1202 .await
1203 {
1204 Ok(s) => s,
1205 Err(e) => return Err(Self::store_error(ctx, e)),
1206 };
1207 let clear_flow = delete_cookie(&flow.flow_cookie, &flow.cookie_path);
1208 let target = if flow_state.original_uri.is_empty() {
1209 "/".to_string()
1210 } else {
1211 flow_state.original_uri.clone()
1212 };
1213 redirect(ctx, &target, vec![set_session, clear_flow])
1214 }
1215
1216 fn read_flow(&self, ctx: &Context) -> Option<FlowState> {
1218 let flow = self.interactive.as_ref()?;
1219 let cookie_header = ctx.request.headers.get("cookie")?.first()?;
1220 let raw = read_cookie(cookie_header, &flow.flow_cookie)?;
1221 let bytes = flow.sealer.open(raw).ok()?;
1222 serde_json::from_slice(&bytes).ok()
1223 }
1224
1225 async fn exchange_code(
1227 &self,
1228 token_endpoint: &str,
1229 code: &str,
1230 verifier: &str,
1231 redirect_uri: &str,
1232 ) -> Result<serde_json::Value, String> {
1233 let client_id = self.client_id.as_deref().unwrap_or("");
1234 let client_secret = self.client_secret.as_deref().unwrap_or("");
1235 let basic = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret));
1236 let body = format!(
1237 "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}",
1238 form_encode(code),
1239 form_encode(redirect_uri),
1240 form_encode(verifier),
1241 form_encode(client_id),
1242 );
1243 let req = OutboundRequest {
1244 method: http::Method::POST,
1245 url: token_endpoint.to_string(),
1246 headers: vec![
1247 (
1248 "content-type".to_string(),
1249 "application/x-www-form-urlencoded".to_string(),
1250 ),
1251 ("authorization".to_string(), format!("Basic {}", basic)),
1252 ("accept".to_string(), "application/json".to_string()),
1253 ],
1254 body: Bytes::from(body),
1255 timeout: self.timeout,
1256 ssl_verify: self.ssl_verify,
1257 tls: None,
1258 };
1259 let resp = self
1260 .resources
1261 .outbound
1262 .request(req)
1263 .await
1264 .map_err(|e| format!("token exchange callout failed: {}", e))?;
1265 if resp.status != 200 {
1266 return Err(format!("token endpoint returned status {}", resp.status));
1267 }
1268 serde_json::from_slice(&resp.body).map_err(|e| format!("invalid token response: {}", e))
1269 }
1270
1271 async fn authorization_endpoint(&self) -> Result<String, String> {
1273 let flow = self.interactive.as_ref().expect("interactive mode");
1274 if let Some(u) = &flow.authorization_endpoint_cfg {
1275 return Ok(u.clone());
1276 }
1277 self.discovery_field("authorization_endpoint", &flow.authz_endpoint_resolved)
1278 .await
1279 }
1280
1281 async fn token_endpoint(&self) -> Result<String, String> {
1283 let flow = self.interactive.as_ref().expect("interactive mode");
1284 if let Some(u) = &flow.token_endpoint_cfg {
1285 return Ok(u.clone());
1286 }
1287 self.discovery_field("token_endpoint", &flow.token_endpoint_resolved)
1288 .await
1289 }
1290
1291 async fn discovery_field(
1293 &self,
1294 field: &str,
1295 cache: &Mutex<Option<String>>,
1296 ) -> Result<String, String> {
1297 {
1298 if let Some(u) = cache.lock().await.as_ref() {
1299 return Ok(u.clone());
1300 }
1301 }
1302 let discovery = self
1303 .discovery
1304 .as_ref()
1305 .ok_or_else(|| format!("no discovery URL to resolve {}", field))?;
1306 let resp = self
1307 .get(discovery)
1308 .await
1309 .map_err(|e| format!("discovery fetch failed: {}", e))?;
1310 if resp.status != 200 {
1311 return Err(format!("discovery returned status {}", resp.status));
1312 }
1313 let doc: serde_json::Value = serde_json::from_slice(&resp.body)
1314 .map_err(|e| format!("failed to parse discovery doc: {}", e))?;
1315 let uri = doc
1316 .get(field)
1317 .and_then(|v| v.as_str())
1318 .ok_or_else(|| format!("discovery doc missing {}", field))?
1319 .to_string();
1320 *cache.lock().await = Some(uri.clone());
1321 Ok(uri)
1322 }
1323}
1324
1325fn build_interactive(
1327 config: &HashMap<String, serde_json::Value>,
1328 discovery: &Option<String>,
1329 resources: &Arc<PluginResources>,
1330) -> Result<Interactive, String> {
1331 let secret = session_field(config, "secret")
1332 .or_else(|| string_opt(config, "session_secret"))
1333 .ok_or("openid-connect: interactive login requires 'session.secret'")?;
1334 let backend = server_session::parse_backend(config, resources, "openid-connect")?;
1335
1336 let redirect_uri = string_opt(config, "redirect_uri")
1337 .ok_or("openid-connect: interactive login requires 'redirect_uri'")?;
1338 let redirect_path = url_path(&redirect_uri);
1339
1340 let authorization_endpoint_cfg = string_opt(config, "authorization_endpoint");
1341 let token_endpoint_cfg = string_opt(config, "token_endpoint");
1342 if discovery.is_none() && (authorization_endpoint_cfg.is_none() || token_endpoint_cfg.is_none())
1343 {
1344 return Err(
1345 "openid-connect: interactive login requires 'discovery', or both \
1346 'authorization_endpoint' and 'token_endpoint'"
1347 .to_string(),
1348 );
1349 }
1350
1351 let scope = string_opt(config, "scope").unwrap_or_else(|| "openid".to_string());
1352 let session_cookie =
1353 session_cookie_field(config, "name").unwrap_or_else(|| "oidc_session".to_string());
1354 let cookie_path = session_cookie_field(config, "path").unwrap_or_else(|| "/".to_string());
1355 if !path_covers(&cookie_path, &redirect_path) {
1360 return Err(format!(
1361 "openid-connect: session.cookie.path '{}' does not cover the redirect_uri \
1362 path '{}'; the session cookie would not be sent to the callback and login \
1363 would loop. Set session.cookie.path to a prefix of the callback path.",
1364 cookie_path, redirect_path
1365 ));
1366 }
1367 let session_lifetime = Duration::from_secs(
1368 config
1369 .get("session")
1370 .and_then(|s| s.get("cookie"))
1371 .and_then(|c| c.get("lifetime"))
1372 .or_else(|| config.get("session_cookie_lifetime"))
1373 .and_then(|v| v.as_u64())
1374 .unwrap_or(3600),
1375 );
1376
1377 Ok(Interactive {
1378 sealer: CookieSealer::new(&secret),
1379 backend,
1380 authorization_endpoint_cfg,
1381 token_endpoint_cfg,
1382 redirect_uri,
1383 redirect_path,
1384 scope,
1385 flow_cookie: format!("{}_flow", session_cookie),
1386 session_cookie,
1387 cookie_path,
1388 session_lifetime,
1389 refresh_enabled: session_refresh_enabled(config),
1390 logout_path: string_opt(config, "logout_path"),
1391 post_logout_redirect_uri: string_opt(config, "post_logout_redirect_uri")
1392 .unwrap_or_else(|| "/".to_string()),
1393 authz_endpoint_resolved: Mutex::new(None),
1394 token_endpoint_resolved: Mutex::new(None),
1395 })
1396}
1397
1398fn session_field(config: &HashMap<String, serde_json::Value>, field: &str) -> Option<String> {
1400 config
1401 .get("session")
1402 .and_then(|s| s.get(field))
1403 .and_then(|v| v.as_str())
1404 .filter(|s| !s.is_empty())
1405 .map(String::from)
1406}
1407
1408fn session_refresh_enabled(config: &HashMap<String, serde_json::Value>) -> bool {
1412 config
1413 .get("session")
1414 .and_then(|s| s.get("refresh"))
1415 .or_else(|| config.get("session_refresh"))
1416 .and_then(|v| v.as_bool())
1417 .unwrap_or(true)
1418}
1419
1420fn session_cookie_field(
1424 config: &HashMap<String, serde_json::Value>,
1425 field: &str,
1426) -> Option<String> {
1427 config
1428 .get("session")
1429 .and_then(|s| s.get("cookie"))
1430 .and_then(|c| c.get(field))
1431 .or_else(|| config.get(&format!("session_cookie_{field}")))
1432 .and_then(|v| v.as_str())
1433 .filter(|s| !s.is_empty())
1434 .map(String::from)
1435}
1436
1437fn url_path(url: &str) -> String {
1440 let after_scheme = url.split("://").nth(1).unwrap_or(url);
1441 match after_scheme.find('/') {
1442 Some(i) => {
1443 let path = &after_scheme[i..];
1444 path.split(['?', '#']).next().unwrap_or(path).to_string()
1445 }
1446 None => "/".to_string(),
1447 }
1448}
1449
1450fn request_uri(ctx: &Context) -> String {
1453 let mut pairs: Vec<String> = Vec::new();
1454 for (k, values) in &ctx.request.query_params {
1455 for v in values {
1456 pairs.push(format!("{}={}", form_encode(k), form_encode(v)));
1457 }
1458 }
1459 pairs.sort();
1460 if pairs.is_empty() {
1461 ctx.request.path.clone()
1462 } else {
1463 format!("{}?{}", ctx.request.path, pairs.join("&"))
1464 }
1465}
1466
1467fn first_query(ctx: &Context, name: &str) -> Option<String> {
1469 ctx.request
1470 .query_params
1471 .get(name)
1472 .and_then(|v| v.first())
1473 .cloned()
1474}
1475
1476fn request_is_https(ctx: &Context) -> bool {
1478 ctx.request.scheme.eq_ignore_ascii_case("https")
1479}
1480
1481fn random_token() -> String {
1483 let mut bytes = [0u8; 32];
1484 SystemRandom::new()
1485 .fill(&mut bytes)
1486 .expect("system RNG must produce random bytes");
1487 URL_SAFE_NO_PAD.encode(bytes)
1488}
1489
1490fn pkce_challenge(verifier: &str) -> String {
1492 URL_SAFE_NO_PAD.encode(digest(&SHA256, verifier.as_bytes()).as_ref())
1493}
1494
1495fn now_unix() -> u64 {
1498 SystemTime::now()
1499 .duration_since(UNIX_EPOCH)
1500 .map(|d| d.as_secs())
1501 .unwrap_or(0)
1502}
1503
1504fn redirect(mut ctx: Context, location: &str, set_cookies: Vec<String>) -> PluginResult {
1507 ctx.response.status_code = 302;
1508 ctx.response.body = Bytes::new();
1509 ctx.response
1510 .headers
1511 .insert("location".to_string(), vec![location.to_string()]);
1512 if !set_cookies.is_empty() {
1513 ctx.response
1514 .headers
1515 .insert("set-cookie".to_string(), set_cookies);
1516 }
1517 Ok(PluginOutput::on_port(ctx, "redirect"))
1518}
1519
1520fn string_opt(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
1522 config
1523 .get(key)
1524 .and_then(|v| v.as_str())
1525 .filter(|s| !s.is_empty())
1526 .map(String::from)
1527}
1528
1529fn parse_alg(name: &str) -> Option<Algorithm> {
1532 match name {
1533 "RS256" => Some(Algorithm::RS256),
1534 "RS384" => Some(Algorithm::RS384),
1535 "RS512" => Some(Algorithm::RS512),
1536 "PS256" => Some(Algorithm::PS256),
1537 "PS384" => Some(Algorithm::PS384),
1538 "PS512" => Some(Algorithm::PS512),
1539 "ES256" => Some(Algorithm::ES256),
1540 "ES384" => Some(Algorithm::ES384),
1541 _ => None,
1542 }
1543}
1544
1545fn parse_allowed_algs(value: Option<&serde_json::Value>) -> Result<Vec<Algorithm>, String> {
1549 let default = || {
1550 vec![
1551 Algorithm::RS256,
1552 Algorithm::RS384,
1553 Algorithm::RS512,
1554 Algorithm::ES256,
1555 Algorithm::ES384,
1556 ]
1557 };
1558 let names: Vec<String> = match value {
1559 None => return Ok(default()),
1560 Some(serde_json::Value::String(s)) => s
1561 .split([',', ' '])
1562 .map(str::trim)
1563 .filter(|s| !s.is_empty())
1564 .map(String::from)
1565 .collect(),
1566 Some(serde_json::Value::Array(a)) => a
1567 .iter()
1568 .filter_map(|v| v.as_str().map(String::from))
1569 .collect(),
1570 Some(_) => {
1571 return Err("token_signing_alg_values_expected must be a string or array".to_string())
1572 }
1573 };
1574 if names.is_empty() {
1575 return Ok(default());
1576 }
1577 let mut algs = Vec::new();
1578 for name in names {
1579 match parse_alg(&name) {
1580 Some(a) => algs.push(a),
1581 None => {
1582 return Err(format!(
1583 "unsupported token signing algorithm '{}' \
1584 (supported: RS256/384/512, PS256/384/512, ES256/384)",
1585 name
1586 ))
1587 }
1588 }
1589 }
1590 Ok(algs)
1591}
1592
1593fn read_valid_issuers(config: &HashMap<String, serde_json::Value>) -> Vec<String> {
1595 config
1596 .get("claim_validator")
1597 .and_then(|v| v.get("issuer"))
1598 .and_then(|v| v.get("valid_issuers"))
1599 .and_then(|v| v.as_array())
1600 .map(|arr| {
1601 arr.iter()
1602 .filter_map(|v| v.as_str().map(String::from))
1603 .collect()
1604 })
1605 .unwrap_or_default()
1606}
1607
1608fn read_audience_cfg(config: &HashMap<String, serde_json::Value>) -> (String, bool, bool) {
1610 let audience = config
1611 .get("claim_validator")
1612 .and_then(|v| v.get("audience"));
1613 let claim = audience
1614 .and_then(|a| a.get("claim"))
1615 .and_then(|v| v.as_str())
1616 .unwrap_or("aud")
1617 .to_string();
1618 let required = audience
1619 .and_then(|a| a.get("required"))
1620 .and_then(|v| v.as_bool())
1621 .unwrap_or(false);
1622 let match_client = audience
1623 .and_then(|a| a.get("match_with_client_id"))
1624 .and_then(|v| v.as_bool())
1625 .unwrap_or(false);
1626 (claim, required, match_client)
1627}
1628
1629fn select_jwk<'a>(keys: &'a [Jwk], kid: Option<&str>) -> Option<&'a Jwk> {
1631 match kid {
1632 Some(kid) => keys.iter().find(|k| k.kid.as_deref() == Some(kid)),
1633 None => {
1634 if keys.len() == 1 {
1635 keys.first()
1636 } else {
1637 None
1638 }
1639 }
1640 }
1641}
1642
1643fn alg_matches_kty(alg: Algorithm, kty: &str) -> bool {
1645 match alg {
1646 Algorithm::RS256
1647 | Algorithm::RS384
1648 | Algorithm::RS512
1649 | Algorithm::PS256
1650 | Algorithm::PS384
1651 | Algorithm::PS512 => kty == "RSA",
1652 Algorithm::ES256 | Algorithm::ES384 => kty == "EC",
1653 Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => kty == "oct",
1654 Algorithm::EdDSA => kty == "OKP",
1655 }
1656}
1657
1658fn algs_for_key(allowed: &[Algorithm], kty: &str) -> Result<Vec<Algorithm>, String> {
1677 let algs: Vec<Algorithm> = allowed
1678 .iter()
1679 .copied()
1680 .filter(|a| alg_matches_kty(*a, kty))
1681 .collect();
1682 if algs.is_empty() {
1683 return Err(format!(
1684 "no permitted signing algorithm can verify a '{}' key; \
1685 check token_signing_alg_values_expected",
1686 kty
1687 ));
1688 }
1689 Ok(algs)
1690}
1691
1692fn jwk_to_decoding_key(jwk: &Jwk) -> Result<DecodingKey, String> {
1694 match jwk.kty.as_str() {
1695 "RSA" => {
1696 let n = jwk.n.as_deref().ok_or("RSA JWK missing 'n'")?;
1697 let e = jwk.e.as_deref().ok_or("RSA JWK missing 'e'")?;
1698 DecodingKey::from_rsa_components(n, e).map_err(|e| format!("invalid RSA JWK: {}", e))
1699 }
1700 "EC" => {
1701 let x = jwk.x.as_deref().ok_or("EC JWK missing 'x'")?;
1702 let y = jwk.y.as_deref().ok_or("EC JWK missing 'y'")?;
1703 DecodingKey::from_ec_components(x, y).map_err(|e| format!("invalid EC JWK: {}", e))
1704 }
1705 other => Err(format!("unsupported JWK key type '{}'", other)),
1706 }
1707}
1708
1709fn decode_and_validate(
1713 token: &str,
1714 key: &DecodingKey,
1715 allowed_algs: &[Algorithm],
1716) -> Result<HashMap<String, serde_json::Value>, String> {
1717 let first = allowed_algs.first().copied().unwrap_or(Algorithm::RS256);
1718 let mut validation = Validation::new(first);
1719 validation.algorithms = allowed_algs.to_vec();
1720 validation.validate_exp = true;
1721 validation.validate_aud = false;
1723 decode::<HashMap<String, serde_json::Value>>(token, key, &validation)
1724 .map(|data| data.claims)
1725 .map_err(|e| format!("token verification failed: {}", e))
1726}
1727
1728fn parse_introspection(body: &[u8]) -> Result<HashMap<String, serde_json::Value>, TokenError> {
1732 let value: serde_json::Value = serde_json::from_slice(body)
1733 .map_err(|e| TokenError::Infra(format!("invalid introspection response: {}", e)))?;
1734 let active = value
1735 .get("active")
1736 .and_then(|v| v.as_bool())
1737 .unwrap_or(false);
1738 if !active {
1739 return Err(TokenError::Denied("token is not active".to_string()));
1740 }
1741 let map = value
1742 .as_object()
1743 .map(|m| m.clone().into_iter().collect())
1744 .unwrap_or_default();
1745 Ok(map)
1746}
1747
1748fn audience_contains(aud: &serde_json::Value, client_id: &str) -> bool {
1750 match aud {
1751 serde_json::Value::String(s) => s == client_id,
1752 serde_json::Value::Array(arr) => arr.iter().any(|v| v.as_str() == Some(client_id)),
1753 _ => false,
1754 }
1755}
1756
1757fn parse_bearer(header_value: &str) -> Option<&str> {
1759 let mut parts = header_value.splitn(2, ' ');
1760 let scheme = parts.next()?;
1761 let token = parts.next()?.trim();
1762 if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() {
1763 Some(token)
1764 } else {
1765 None
1766 }
1767}
1768
1769fn form_encode(value: &str) -> String {
1771 let mut out = String::with_capacity(value.len());
1772 for b in value.bytes() {
1773 match b {
1774 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1775 out.push(b as char)
1776 }
1777 _ => out.push_str(&format!("%{:02X}", b)),
1778 }
1779 }
1780 out
1781}
1782
1783#[async_trait]
1784impl Plugin for OpenidConnectPlugin {
1785 fn plugin_type(&self) -> &str {
1786 "openid-connect"
1787 }
1788
1789 async fn execute(&self, mut ctx: Context) -> PluginResult {
1790 ctx.request.headers.remove("x-userinfo");
1792
1793 if self.interactive.is_some() {
1795 return self.execute_interactive(ctx).await;
1796 }
1797
1798 let token = ctx
1799 .request
1800 .headers
1801 .get("authorization")
1802 .and_then(|v| v.first())
1803 .and_then(|v| parse_bearer(v))
1804 .map(String::from);
1805
1806 let token = match token {
1807 Some(t) => t,
1808 None => {
1809 return Self::reject(
1810 ctx,
1811 "No bearer token found in request (bearer_only is true; set bearer_only: false for interactive login)",
1812 )
1813 }
1814 };
1815
1816 let result = if self.use_jwks {
1817 self.validate_via_jwks(&token).await
1818 } else {
1819 self.validate_via_introspection(&token).await
1820 };
1821
1822 match result {
1823 Ok(claims) => {
1824 self.attach(&mut ctx, claims, &token);
1825 Ok(PluginOutput::success(ctx))
1826 }
1827 Err(TokenError::Denied(e)) => Self::reject(ctx, &e),
1828 Err(TokenError::Infra(e)) => Err(Self::infra_error(ctx, e)),
1829 }
1830 }
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835 use super::*;
1836 use crate::plugins::util::provider_error::testing::assert_provider_error;
1837 use jsonwebtoken::{encode, EncodingKey, Header};
1838
1839 const PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
1843MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCvciOuri5uG88q\n\
1844rZ3T6qUhTYl7nWDHvVGBBsA8ku3xUfOW97PGpWbTe/Yq/3jovVxAQsAe/QoIMyUU\n\
1845HKCdDKAsIBO9j9OEPs3Le6cThFx+/9Z1U9cw4wCIa4TNtGBhyDgqbqKpOLNnXLI6\n\
1846WEcrykkoV5nUUH/47aS2i9BiqZn6H9eEL1VH82IX/x4fWNIEyXQAxKZtyULgznR4\n\
1847oUz2QPaY/cWtpK85B12scs1IpLnzEdjy69t28ZQnYZ7Nrvl+aFjSkvnqxhoNJ9Ut\n\
1848Lw2/3vld8t3Lh6B4vTM4vdJsue1dum6WnyEKEx/SDuCSDxWONfmdhu/B4XUghaQS\n\
18491wBNiEhvAgMBAAECggEASXDcee8ktWfDsShK9F35MLcd0VaAICxiFUInr1OL8ePt\n\
1850tSjMIt+y6t0tnzMgwEAgATBP7sjabbNHFqOjIgqac84bpVKy5l1J1R9WQWe7NlhO\n\
1851w/9MCYVEgFaNmXQjklr3E+ALDA4VnzNg0eaJKE39kLsWxBbMcv27YMSm/t3i/B2s\n\
1852rwZbzBgxXXR5r7j/Tt+hRJmGHXe0zZvsNLzFNj4CsyngBiY9CIcexroGxd3yGEf7\n\
18530PKHwbZKkH0CPr6QAc4f+tPgIfHB+8+29QPrUTR9e60Sc6dZNUjTr1EWIxyvFxVK\n\
1854dI3ekR5W26a81+yxc2MpRK8wZsv+mJ6okaeVs2+3jQKBgQDr0b3YX4RC9trW+RsE\n\
18559wUXeLr3o9Vb0FTHf/8ALAZ9EWywEmF+sdA8fKs8+H+IyIzX6KGw/UbzqIi2aDuJ\n\
1856q63IPxKyyXr7nfVSUz8qWIGT/WoG/1d4rpFN2sbR/r/oue7uJnaXMIPswVT+zO8q\n\
18575YieEPDwhteJ8bJUC16NWwddBQKBgQC+dcEmNm7MzxI/cuwubkojhayXw1ouACu4\n\
1858giGp3lJywzIAnV1CsJTGTpvHk31j+/L9oB2U/586+65MGklGJ2TGs0IQZs0iAy1H\n\
1859Oq3zzsLp0KiVizyqchgkIWP6KVpx5aPkpJSgPJGyJzuwofZwRzPK7IZr8c4MOtsy\n\
1860M8j8up8p4wKBgGbUxTYvIJuazX7kjXWyydOcX9tQ497vj6iXFflbOVEcYgq9WSpI\n\
1861G4fkzT7/FY3t9gzIcomdSG1D1qnD9gJojJU/e8XeufQywyEtD+RFR+vim3OFsPz9\n\
1862EnuipQQ5VDIFsjzDJP90tnJtM8UQVFKeWN6kgIxCIIcUkDC57HczdJiJAoGASPG4\n\
1863g/YdAXvdNUfChRXgdzJfI9DB3RRbqlLMqc5oLWPs5qdebIhMspawuwMV5xE7wz9r\n\
1864lQFB7sktvB/lKGU2B5PoHXgB4KDu2nTy4omxxPMRXhTxqyX/cPcI32qvJSgaWRtf\n\
1865gO8xrdWw2rltNRtQDsv/v5/glnaENPn4ZDLlepkCgYAqag5Uxj0ps6WNE/D6IEWA\n\
1866eTGicEEJPJQB9bGrElna7WyOjntnO5miRmpM1jH39R417czBURmvZHO2oTnqghZF\n\
1867c/7P2kweQNU7vtM/iLcm8EyFRw2lVB3J/XVTEcPU6ZeZHlVbGtiKx3gukkMBc4Ct\n\
1868CQTyrvDSz5J6MQhLtbNHnQ==\n\
1869-----END PRIVATE KEY-----\n";
1870
1871 const JWK_N: &str = "r3Ijrq4ubhvPKq2d0-qlIU2Je51gx71RgQbAPJLt8VHzlvezxqVm03v2Kv946L1cQELAHv0KCDMlFBygnQygLCATvY_ThD7Ny3unE4Rcfv_WdVPXMOMAiGuEzbRgYcg4Km6iqTizZ1yyOlhHK8pJKFeZ1FB_-O2ktovQYqmZ-h_XhC9VR_NiF_8eH1jSBMl0AMSmbclC4M50eKFM9kD2mP3FraSvOQddrHLNSKS58xHY8uvbdvGUJ2Geza75fmhY0pL56sYaDSfVLS8Nv975XfLdy4egeL0zOL3SbLntXbpulp8hChMf0g7gkg8VjjX5nYbvweF1IIWkEtcATYhIbw";
1872 const JWK_E: &str = "AQAB";
1873
1874 fn test_jwk(kid: &str) -> Jwk {
1875 Jwk {
1876 kty: "RSA".to_string(),
1877 kid: Some(kid.to_string()),
1878 alg: Some("RS256".to_string()),
1879 n: Some(JWK_N.to_string()),
1880 e: Some(JWK_E.to_string()),
1881 x: None,
1882 y: None,
1883 crv: None,
1884 }
1885 }
1886
1887 fn sign(claims: serde_json::Value, kid: &str) -> String {
1888 let mut header = Header::new(Algorithm::RS256);
1889 header.kid = Some(kid.to_string());
1890 encode(
1891 &header,
1892 &claims,
1893 &EncodingKey::from_rsa_pem(PRIV_PEM.as_bytes()).unwrap(),
1894 )
1895 .unwrap()
1896 }
1897
1898 fn cfg(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1899 pairs
1900 .iter()
1901 .map(|(k, v)| (k.to_string(), v.clone()))
1902 .collect()
1903 }
1904
1905 #[test]
1906 fn test_interactive_requires_secret_and_redirect() {
1907 let missing = cfg(&[
1909 (
1910 "discovery",
1911 serde_json::json!("https://idp/.well-known/openid-configuration"),
1912 ),
1913 ("bearer_only", serde_json::json!(false)),
1914 ("client_id", serde_json::json!("app")),
1915 ("client_secret", serde_json::json!("s")),
1916 ]);
1917 assert!(OpenidConnectPlugin::from_config(&missing, &PluginResources::empty()).is_err());
1918
1919 let ok = cfg(&[
1921 (
1922 "discovery",
1923 serde_json::json!("https://idp/.well-known/openid-configuration"),
1924 ),
1925 ("bearer_only", serde_json::json!(false)),
1926 ("client_id", serde_json::json!("app")),
1927 ("client_secret", serde_json::json!("s")),
1928 (
1929 "redirect_uri",
1930 serde_json::json!("https://app.example.com/oidc/callback"),
1931 ),
1932 (
1933 "session",
1934 serde_json::json!({ "secret": "cookie-signing-secret" }),
1935 ),
1936 ]);
1937 let plugin = OpenidConnectPlugin::from_config(&ok, &PluginResources::empty()).unwrap();
1938 let interactive = plugin.interactive.as_ref().unwrap();
1939 assert_eq!(interactive.redirect_path, "/oidc/callback");
1940 assert_eq!(interactive.session_cookie, "oidc_session");
1941 assert_eq!(interactive.flow_cookie, "oidc_session_flow");
1942 assert_eq!(interactive.cookie_path, "/");
1944 assert_eq!(interactive.session_lifetime, Duration::from_secs(3600));
1945 }
1946
1947 #[test]
1950 fn test_interactive_custom_session_cookie() {
1951 let c = cfg(&[
1952 (
1953 "discovery",
1954 serde_json::json!("https://idp/.well-known/openid-configuration"),
1955 ),
1956 ("bearer_only", serde_json::json!(false)),
1957 ("client_id", serde_json::json!("app")),
1958 ("client_secret", serde_json::json!("s")),
1959 (
1960 "redirect_uri",
1961 serde_json::json!("https://app.example.com/app_a/callback"),
1962 ),
1963 (
1964 "session",
1965 serde_json::json!({
1966 "secret": "cookie-signing-secret",
1967 "cookie": { "name": "a_session", "path": "/app_a", "lifetime": 900 }
1968 }),
1969 ),
1970 ]);
1971 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
1972 let i = plugin.interactive.as_ref().unwrap();
1973 assert_eq!(i.session_cookie, "a_session");
1974 assert_eq!(i.flow_cookie, "a_session_flow");
1975 assert_eq!(i.cookie_path, "/app_a");
1976 assert_eq!(i.session_lifetime, Duration::from_secs(900));
1977 }
1978
1979 #[test]
1983 fn test_interactive_flat_ui_session_keys() {
1984 let c = cfg(&[
1985 (
1986 "discovery",
1987 serde_json::json!("https://idp/.well-known/openid-configuration"),
1988 ),
1989 ("bearer_only", serde_json::json!(false)),
1990 ("client_id", serde_json::json!("app")),
1991 ("client_secret", serde_json::json!("s")),
1992 (
1993 "redirect_uri",
1994 serde_json::json!("https://app.example.com/app_a/callback"),
1995 ),
1996 ("session_secret", serde_json::json!("cookie-signing-secret")),
1997 ("session_cookie_name", serde_json::json!("a_session")),
1998 ("session_cookie_path", serde_json::json!("/app_a")),
1999 ("session_cookie_lifetime", serde_json::json!(1200)),
2000 ]);
2001 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2002 let i = plugin.interactive.as_ref().unwrap();
2003 assert_eq!(i.session_cookie, "a_session");
2004 assert_eq!(i.flow_cookie, "a_session_flow");
2005 assert_eq!(i.cookie_path, "/app_a");
2006 assert_eq!(i.session_lifetime, Duration::from_secs(1200));
2007 }
2008
2009 #[test]
2012 fn test_interactive_cookie_path_must_cover_callback() {
2013 let c = cfg(&[
2014 (
2015 "discovery",
2016 serde_json::json!("https://idp/.well-known/openid-configuration"),
2017 ),
2018 ("bearer_only", serde_json::json!(false)),
2019 ("client_id", serde_json::json!("app")),
2020 ("client_secret", serde_json::json!("s")),
2021 (
2022 "redirect_uri",
2023 serde_json::json!("https://app.example.com/app_a/callback"),
2024 ),
2025 (
2026 "session",
2027 serde_json::json!({
2028 "secret": "s",
2029 "cookie": { "path": "/app_b" } }),
2031 ),
2032 ]);
2033 let err = OpenidConnectPlugin::from_config(&c, &PluginResources::empty())
2035 .err()
2036 .unwrap();
2037 assert!(
2038 err.contains("session.cookie.path"),
2039 "unexpected error: {err}"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_bearer_only_default_has_no_interactive() {
2045 let c = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
2046 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2047 assert!(plugin.interactive.is_none());
2048 }
2049
2050 #[test]
2051 fn test_url_path() {
2052 assert_eq!(
2053 url_path("https://app.example.com/oidc/callback"),
2054 "/oidc/callback"
2055 );
2056 assert_eq!(url_path("https://app.example.com/cb?x=1"), "/cb");
2057 assert_eq!(url_path("https://app.example.com"), "/");
2058 }
2059
2060 #[test]
2061 fn test_pkce_challenge_is_stable_and_urlsafe() {
2062 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
2064 let challenge = pkce_challenge(verifier);
2065 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
2066 assert!(!challenge.contains('+') && !challenge.contains('/') && !challenge.contains('='));
2067 }
2068
2069 #[test]
2070 fn test_flow_and_session_seal_round_trip() {
2071 let sealer = CookieSealer::new("k");
2072 let flow = FlowState {
2073 state: "st".into(),
2074 nonce: "nc".into(),
2075 verifier: "vf".into(),
2076 original_uri: "/dashboard?tab=1".into(),
2077 };
2078 let sealed = sealer.seal(
2079 &serde_json::to_vec(&flow).unwrap(),
2080 Duration::from_secs(300),
2081 );
2082 let back: FlowState = serde_json::from_slice(&sealer.open(&sealed).unwrap()).unwrap();
2083 assert_eq!(back.state, "st");
2084 assert_eq!(back.original_uri, "/dashboard?tab=1");
2085
2086 let session = SessionData {
2087 claims: serde_json::json!({ "sub": "u1", "name": "Alice" }),
2088 access_token: Some("at".into()),
2089 refresh_token: None,
2090 expires_at: None,
2091 };
2092 let sealed = sealer.seal(
2093 &serde_json::to_vec(&session).unwrap(),
2094 Duration::from_secs(3600),
2095 );
2096 let back: SessionData = serde_json::from_slice(&sealer.open(&sealed).unwrap()).unwrap();
2097 assert_eq!(back.claims.get("sub").unwrap(), "u1");
2098 assert_eq!(back.access_token.as_deref(), Some("at"));
2099 }
2100
2101 #[test]
2102 fn test_rejects_no_validation_source() {
2103 let c = cfg(&[("client_id", serde_json::json!("x"))]);
2104 assert!(OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).is_err());
2105 }
2106
2107 #[test]
2108 fn test_accepts_jwks_and_introspection_configs() {
2109 let jwks = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
2110 assert!(OpenidConnectPlugin::from_config(&jwks, &PluginResources::empty()).is_ok());
2111
2112 let introspect = cfg(&[
2113 (
2114 "introspection_endpoint",
2115 serde_json::json!("https://idp/introspect"),
2116 ),
2117 ("client_id", serde_json::json!("id")),
2118 ("client_secret", serde_json::json!("secret")),
2119 ]);
2120 assert!(OpenidConnectPlugin::from_config(&introspect, &PluginResources::empty()).is_ok());
2121 }
2122
2123 #[test]
2124 fn test_rejects_unknown_alg() {
2125 let c = cfg(&[
2126 ("jwks_uri", serde_json::json!("https://idp/jwks")),
2127 (
2128 "token_signing_alg_values_expected",
2129 serde_json::json!("HS256"),
2130 ),
2131 ]);
2132 assert!(OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).is_err());
2133 }
2134
2135 #[test]
2136 fn test_select_jwk_by_kid() {
2137 let keys = vec![test_jwk("k1"), test_jwk("k2")];
2138 assert_eq!(
2139 select_jwk(&keys, Some("k2")).unwrap().kid.as_deref(),
2140 Some("k2")
2141 );
2142 assert!(select_jwk(&keys, Some("nope")).is_none());
2143 assert!(select_jwk(&keys, None).is_none());
2145 let one = vec![test_jwk("only")];
2147 assert!(select_jwk(&one, None).is_some());
2148 }
2149
2150 #[test]
2151 fn test_jwk_to_decoding_key_rsa() {
2152 let jwk = test_jwk("k1");
2153 assert!(jwk_to_decoding_key(&jwk).is_ok());
2154 let mut bad = test_jwk("k1");
2156 bad.n = None;
2157 assert!(jwk_to_decoding_key(&bad).is_err());
2158 }
2159
2160 #[test]
2167 fn test_default_algs_verify_an_rs256_token() {
2168 let defaults = parse_allowed_algs(None).unwrap();
2169 assert!(
2170 defaults.len() > 1,
2171 "the default list must span families to be a regression test"
2172 );
2173
2174 let keys = vec![test_jwk("k1")];
2175 let token = sign(
2176 serde_json::json!({ "sub": "user-1", "exp": 9999999999u64 }),
2177 "k1",
2178 );
2179 let jwk = select_jwk(&keys, Some("k1")).unwrap();
2180 let key = jwk_to_decoding_key(jwk).unwrap();
2181
2182 let algs = algs_for_key(&defaults, &jwk.kty).unwrap();
2184 let claims = decode_and_validate(&token, &key, &algs)
2185 .expect("an RS256 token must verify under the default algorithm list");
2186 assert_eq!(claims.get("sub").unwrap(), "user-1");
2187
2188 assert!(
2190 decode_and_validate(&token, &key, &defaults).is_err(),
2191 "sanity: the unfiltered mixed-family list is rejected by jsonwebtoken"
2192 );
2193 }
2194
2195 #[test]
2196 fn test_algs_for_key_filters_by_family() {
2197 let defaults = parse_allowed_algs(None).unwrap();
2198
2199 let rsa = algs_for_key(&defaults, "RSA").unwrap();
2200 assert!(rsa.contains(&Algorithm::RS256));
2201 assert!(!rsa.contains(&Algorithm::ES256));
2202
2203 let ec = algs_for_key(&defaults, "EC").unwrap();
2204 assert!(ec.contains(&Algorithm::ES256));
2205 assert!(!ec.contains(&Algorithm::RS256));
2206
2207 assert!(algs_for_key(&[Algorithm::RS256], "EC").is_err());
2209 assert!(algs_for_key(&defaults, "oct").is_err());
2210 }
2211
2212 #[test]
2213 fn test_verify_signed_token_end_to_end() {
2214 let keys = vec![test_jwk("k1")];
2215 let token = sign(
2216 serde_json::json!({ "sub": "user-1", "iss": "https://idp/", "aud": "my-api", "exp": 9999999999u64 }),
2217 "k1",
2218 );
2219 let jwk = select_jwk(&keys, Some("k1")).unwrap();
2220 let key = jwk_to_decoding_key(jwk).unwrap();
2221 let claims = decode_and_validate(&token, &key, &[Algorithm::RS256]).unwrap();
2222 assert_eq!(claims.get("sub").unwrap(), "user-1");
2223
2224 let expired = sign(serde_json::json!({ "sub": "u", "exp": 100u64 }), "k1");
2227 assert!(decode_and_validate(&expired, &key, &[Algorithm::RS256]).is_err());
2228
2229 assert!(decode_and_validate(&token, &key, &[Algorithm::ES256]).is_err());
2231 }
2232
2233 #[test]
2234 fn test_validate_claims_issuer_and_audience() {
2235 let c = cfg(&[
2236 ("jwks_uri", serde_json::json!("https://idp/jwks")),
2237 ("client_id", serde_json::json!("my-api")),
2238 (
2239 "claim_validator",
2240 serde_json::json!({
2241 "issuer": { "valid_issuers": ["https://idp/"] },
2242 "audience": { "required": true, "match_with_client_id": true }
2243 }),
2244 ),
2245 ]);
2246 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2247
2248 let good: HashMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
2249 "iss": "https://idp/", "aud": ["my-api", "other"], "sub": "u"
2250 }))
2251 .unwrap();
2252 assert!(plugin.validate_claims(&good).is_ok());
2253
2254 let bad_iss: HashMap<String, serde_json::Value> =
2256 serde_json::from_value(serde_json::json!({
2257 "iss": "https://evil/", "aud": "my-api"
2258 }))
2259 .unwrap();
2260 assert!(plugin.validate_claims(&bad_iss).is_err());
2261
2262 let bad_aud: HashMap<String, serde_json::Value> =
2264 serde_json::from_value(serde_json::json!({
2265 "iss": "https://idp/", "aud": "someone-else"
2266 }))
2267 .unwrap();
2268 assert!(plugin.validate_claims(&bad_aud).is_err());
2269
2270 let no_aud: HashMap<String, serde_json::Value> =
2272 serde_json::from_value(serde_json::json!({
2273 "iss": "https://idp/"
2274 }))
2275 .unwrap();
2276 assert!(plugin.validate_claims(&no_aud).is_err());
2277 }
2278
2279 #[test]
2280 fn test_parse_introspection() {
2281 let active =
2282 serde_json::to_vec(&serde_json::json!({ "active": true, "sub": "u1" })).unwrap();
2283 let claims = parse_introspection(&active).unwrap();
2284 assert_eq!(claims.get("sub").unwrap(), "u1");
2285
2286 let inactive = serde_json::to_vec(&serde_json::json!({ "active": false })).unwrap();
2287 assert!(parse_introspection(&inactive).is_err());
2288 }
2289
2290 #[test]
2291 fn test_parse_bearer() {
2292 assert_eq!(parse_bearer("Bearer abc.def"), Some("abc.def"));
2293 assert_eq!(parse_bearer("bearer xyz"), Some("xyz"));
2294 assert_eq!(parse_bearer("Basic abc"), None);
2295 assert_eq!(parse_bearer("Bearer "), None);
2296 assert_eq!(parse_bearer("token"), None);
2297 }
2298
2299 fn req_ctx(path: &str, query: HashMap<String, Vec<String>>) -> crate::context::Context {
2302 crate::context::Context::new(crate::context::GatewayRequest {
2303 method: "GET".into(),
2304 path: path.into(),
2305 host: "app.example.com".into(),
2306 scheme: "https".into(),
2307 headers: HashMap::new(),
2308 query_params: query,
2309 body: Bytes::new(),
2310 remote_addr: "1.2.3.4:5".into(),
2311 protocol: crate::context::Protocol::Http1,
2312 })
2313 }
2314
2315 fn with_bearer(mut ctx: crate::context::Context, token: &str) -> crate::context::Context {
2316 ctx.request
2317 .headers
2318 .insert("authorization".to_string(), vec![format!("Bearer {token}")]);
2319 ctx
2320 }
2321
2322 #[tokio::test]
2323 async fn test_bearer_missing_token_denied() {
2324 let c = cfg(&[("jwks_uri", serde_json::json!("https://idp/jwks"))]);
2325 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2326
2327 let out = plugin.execute(req_ctx("/", HashMap::new())).await.unwrap();
2328 assert_eq!(out.port, Some("denied"));
2329 assert_eq!(out.context.response.status_code, 401);
2330 assert!(out
2331 .context
2332 .response
2333 .headers
2334 .contains_key("www-authenticate"));
2335 let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
2338 assert!(
2339 body.contains("No bearer token found in request")
2340 && body.contains("bearer_only is true")
2341 && body.contains("bearer_only: false"),
2342 "{body}"
2343 );
2344 }
2345
2346 #[tokio::test]
2351 async fn test_bearer_jwks_unreachable_stays_on_error_port() {
2352 let c = cfg(&[
2353 ("jwks_uri", serde_json::json!("http://127.0.0.1:1/jwks")),
2354 ("timeout", serde_json::json!(1)),
2355 ]);
2356 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2357
2358 let token = sign(serde_json::json!({ "sub": "user-1" }), "k1");
2361 let err = plugin
2362 .execute(with_bearer(req_ctx("/", HashMap::new()), &token))
2363 .await
2364 .unwrap_err();
2365 assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2366 }
2367
2368 #[tokio::test]
2371 async fn test_bearer_discovery_unreachable_stays_on_error_port() {
2372 let c = cfg(&[
2373 (
2374 "discovery",
2375 serde_json::json!("http://127.0.0.1:1/.well-known/openid-configuration"),
2376 ),
2377 ("timeout", serde_json::json!(1)),
2378 ]);
2379 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2380
2381 let token = sign(serde_json::json!({ "sub": "user-1" }), "k1");
2382 let err = plugin
2383 .execute(with_bearer(req_ctx("/", HashMap::new()), &token))
2384 .await
2385 .unwrap_err();
2386 assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2387 }
2388
2389 #[tokio::test]
2392 async fn test_bearer_introspection_unreachable_stays_on_error_port() {
2393 let c = cfg(&[
2394 (
2395 "introspection_endpoint",
2396 serde_json::json!("http://127.0.0.1:1/introspect"),
2397 ),
2398 ("client_id", serde_json::json!("id")),
2399 ("client_secret", serde_json::json!("secret")),
2400 ("timeout", serde_json::json!(1)),
2401 ]);
2402 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2403
2404 let err = plugin
2405 .execute(with_bearer(req_ctx("/", HashMap::new()), "opaque-token"))
2406 .await
2407 .unwrap_err();
2408 assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2409 }
2410
2411 #[test]
2415 fn test_parse_introspection_inactive_is_denied_not_infra() {
2416 let inactive = serde_json::to_vec(&serde_json::json!({ "active": false })).unwrap();
2417 match parse_introspection(&inactive) {
2418 Err(TokenError::Denied(_)) => {}
2419 other => panic!("expected Denied, got {other:?}"),
2420 }
2421 let bad_json = b"not json";
2422 match parse_introspection(bad_json) {
2423 Err(TokenError::Infra(_)) => {}
2424 other => panic!("expected Infra, got {other:?}"),
2425 }
2426 }
2427
2428 fn interactive_explicit_cfg() -> HashMap<String, serde_json::Value> {
2429 cfg(&[
2430 (
2431 "authorization_endpoint",
2432 serde_json::json!("https://idp.example.com/authorize"),
2433 ),
2434 (
2435 "token_endpoint",
2436 serde_json::json!("http://127.0.0.1:1/token"),
2437 ),
2438 (
2439 "jwks_uri",
2440 serde_json::json!("https://idp.example.com/jwks"),
2441 ),
2442 ("bearer_only", serde_json::json!(false)),
2443 ("client_id", serde_json::json!("app")),
2444 ("client_secret", serde_json::json!("s")),
2445 (
2446 "redirect_uri",
2447 serde_json::json!("https://app.example.com/oidc/callback"),
2448 ),
2449 (
2450 "session",
2451 serde_json::json!({ "secret": "cookie-signing-secret" }),
2452 ),
2453 ])
2454 }
2455
2456 #[tokio::test]
2457 async fn test_interactive_begin_login_redirects() {
2458 let plugin = OpenidConnectPlugin::from_config(
2459 &interactive_explicit_cfg(),
2460 &PluginResources::empty(),
2461 )
2462 .unwrap();
2463
2464 let out = plugin
2465 .execute(req_ctx("/dashboard", HashMap::new()))
2466 .await
2467 .unwrap();
2468 assert_eq!(out.port, Some("redirect"));
2469 assert_eq!(out.context.response.status_code, 302);
2470 let location = &out.context.response.headers.get("location").unwrap()[0];
2471 assert!(
2472 location.starts_with("https://idp.example.com/authorize?"),
2473 "{location}"
2474 );
2475 let set = &out.context.response.headers.get("set-cookie").unwrap()[0];
2476 assert!(set.starts_with("oidc_session_flow="), "{set}");
2477 }
2478
2479 #[tokio::test]
2484 async fn test_interactive_discovery_unreachable_is_provider_error() {
2485 let c = cfg(&[
2486 (
2487 "discovery",
2488 serde_json::json!("http://127.0.0.1:1/.well-known/openid-configuration"),
2489 ),
2490 ("timeout", serde_json::json!(1)),
2491 ("bearer_only", serde_json::json!(false)),
2492 ("client_id", serde_json::json!("app")),
2493 ("client_secret", serde_json::json!("s")),
2494 (
2495 "redirect_uri",
2496 serde_json::json!("https://app.example.com/oidc/callback"),
2497 ),
2498 (
2499 "session",
2500 serde_json::json!({ "secret": "cookie-signing-secret" }),
2501 ),
2502 ]);
2503 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2504
2505 let err = plugin
2506 .execute(req_ctx("/dashboard", HashMap::new()))
2507 .await
2508 .unwrap_err();
2509 assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2510 assert!(
2511 err.error.message.contains("discovery fetch failed"),
2512 "{}",
2513 err.error.message
2514 );
2515 }
2516
2517 #[tokio::test]
2518 async fn test_interactive_logout_redirects() {
2519 let mut c = interactive_explicit_cfg();
2520 c.insert("logout_path".to_string(), serde_json::json!("/logout"));
2521 let plugin = OpenidConnectPlugin::from_config(&c, &PluginResources::empty()).unwrap();
2522
2523 let out = plugin
2524 .execute(req_ctx("/logout", HashMap::new()))
2525 .await
2526 .unwrap();
2527 assert_eq!(out.port, Some("redirect"));
2528 assert_eq!(out.context.response.status_code, 302);
2529 assert_eq!(
2530 out.context.response.headers.get("location").unwrap()[0],
2531 "/"
2532 );
2533 }
2534
2535 #[tokio::test]
2536 async fn test_interactive_callback_missing_flow_cookie_denied() {
2537 let plugin = OpenidConnectPlugin::from_config(
2538 &interactive_explicit_cfg(),
2539 &PluginResources::empty(),
2540 )
2541 .unwrap();
2542
2543 let mut query = HashMap::new();
2544 query.insert("code".to_string(), vec!["c".to_string()]);
2545 query.insert("state".to_string(), vec!["st".to_string()]);
2546 let out = plugin
2547 .execute(req_ctx("/oidc/callback", query))
2548 .await
2549 .unwrap();
2550 assert_eq!(out.port, Some("denied"));
2551 assert_eq!(out.context.response.status_code, 401);
2552 }
2553
2554 #[tokio::test]
2555 async fn test_interactive_callback_state_mismatch_denied() {
2556 let plugin = OpenidConnectPlugin::from_config(
2557 &interactive_explicit_cfg(),
2558 &PluginResources::empty(),
2559 )
2560 .unwrap();
2561 let flow = FlowState {
2562 state: "expected".into(),
2563 nonce: "nonce".into(),
2564 verifier: "verifier".into(),
2565 original_uri: "/dashboard".into(),
2566 };
2567 let sealer = CookieSealer::new("cookie-signing-secret");
2568 let sealed = sealer.seal(
2569 &serde_json::to_vec(&flow).unwrap(),
2570 Duration::from_secs(300),
2571 );
2572
2573 let mut query = HashMap::new();
2574 query.insert("code".to_string(), vec!["c".to_string()]);
2575 query.insert("state".to_string(), vec!["WRONG".to_string()]);
2576 let mut c = req_ctx("/oidc/callback", query);
2577 c.request.headers.insert(
2578 "cookie".to_string(),
2579 vec![format!("oidc_session_flow={sealed}")],
2580 );
2581
2582 let out = plugin.execute(c).await.unwrap();
2583 assert_eq!(out.port, Some("denied"));
2584 assert_eq!(out.context.response.status_code, 401);
2585 }
2586
2587 #[tokio::test]
2591 async fn test_interactive_callback_token_endpoint_unreachable_stays_on_error_port() {
2592 let plugin = OpenidConnectPlugin::from_config(
2593 &interactive_explicit_cfg(),
2594 &PluginResources::empty(),
2595 )
2596 .unwrap();
2597 let flow = FlowState {
2598 state: "matching".into(),
2599 nonce: "nonce".into(),
2600 verifier: "verifier".into(),
2601 original_uri: "/dashboard".into(),
2602 };
2603 let sealer = CookieSealer::new("cookie-signing-secret");
2604 let sealed = sealer.seal(
2605 &serde_json::to_vec(&flow).unwrap(),
2606 Duration::from_secs(300),
2607 );
2608
2609 let mut query = HashMap::new();
2610 query.insert("code".to_string(), vec!["c".to_string()]);
2611 query.insert("state".to_string(), vec!["matching".to_string()]);
2612 let mut c = req_ctx("/oidc/callback", query);
2613 c.request.headers.insert(
2614 "cookie".to_string(),
2615 vec![format!("oidc_session_flow={sealed}")],
2616 );
2617
2618 let err = plugin.execute(c).await.unwrap_err();
2619 assert_provider_error(&err, "OIDC_PROVIDER_ERROR");
2620 }
2621
2622 #[cfg(feature = "redis-store")]
2625 fn resources_with_fake_store() -> (
2626 std::sync::Arc<crate::plugins::resources::PluginResources>,
2627 std::sync::Arc<crate::sessions::FakeSessionStore>,
2628 ) {
2629 let fake = std::sync::Arc::new(crate::sessions::FakeSessionStore::default());
2630 let resources = crate::plugins::resources::PluginResources::empty();
2631 resources.stores.store(std::sync::Arc::new(
2632 crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
2633 ));
2634 (resources, fake)
2635 }
2636
2637 #[test]
2639 fn test_session_storage_redis_requires_store() {
2640 let mut cfg = interactive_explicit_cfg();
2641 cfg.insert(
2642 "session".to_string(),
2643 serde_json::json!({"secret": "cookie-signing-secret", "storage": "redis"}),
2644 );
2645 let err = OpenidConnectPlugin::from_config(&cfg, &PluginResources::empty())
2647 .err()
2648 .unwrap();
2649 assert!(err.contains("requires 'session.store'"), "{err}");
2650 }
2651
2652 #[cfg(feature = "redis-store")]
2655 #[tokio::test]
2656 async fn test_redis_session_read_and_store_outage_503() {
2657 use crate::sessions::SessionStore as _;
2658
2659 let (resources, fake) = resources_with_fake_store();
2660 let mut cfg = interactive_explicit_cfg();
2661 cfg.insert(
2662 "session".to_string(),
2663 serde_json::json!({
2664 "secret": "cookie-signing-secret",
2665 "storage": "redis",
2666 "store": "s1"
2667 }),
2668 );
2669 let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2670
2671 let sealer = CookieSealer::new("cookie-signing-secret");
2673 let data = serde_json::json!({"claims": {"sub": "u1"}});
2674 let sealed = sealer.seal(&serde_json::to_vec(&data).unwrap(), Duration::from_secs(60));
2675 let id = crate::sessions::SessionId::random();
2676 let meta = crate::sessions::SessionMeta {
2677 id: String::new(),
2678 subject: "u1".to_string(),
2679 plugin: "openid-connect".to_string(),
2680 policy: String::new(),
2681 route: String::new(),
2682 created_at: 0,
2683 expires_at: 0,
2684 };
2685 fake.put(&id, sealed.as_bytes(), Duration::from_secs(60), &meta)
2686 .await
2687 .unwrap();
2688
2689 let mut ctx = req_ctx("/api", HashMap::new());
2690 ctx.request.headers.insert(
2691 "cookie".to_string(),
2692 vec![format!("oidc_session={}", id.as_str())],
2693 );
2694 let out = plugin.execute(ctx).await.unwrap();
2695 assert!(out.port.is_none(), "valid store session must pass");
2696 assert_eq!(out.context.message["user_id"], "u1");
2697
2698 fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
2700 let mut ctx = req_ctx("/api", HashMap::new());
2701 ctx.request.headers.insert(
2702 "cookie".to_string(),
2703 vec![format!("oidc_session={}", id.as_str())],
2704 );
2705 let err = plugin.execute(ctx).await.unwrap_err();
2706 assert_eq!(err.error.code, "SESSION_STORE_ERROR");
2707 assert_eq!(err.context.response.status_code, 503);
2708 }
2709
2710 #[cfg(feature = "redis-store")]
2714 #[tokio::test]
2715 async fn test_redis_refresh_lock_and_fallback() {
2716 use crate::sessions::SessionStore as _;
2717
2718 let (resources, fake) = resources_with_fake_store();
2719 let mut cfg = interactive_explicit_cfg(); cfg.insert(
2721 "session".to_string(),
2722 serde_json::json!({
2723 "secret": "cookie-signing-secret",
2724 "storage": "redis",
2725 "store": "s1"
2726 }),
2727 );
2728 let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2729
2730 let sealer = CookieSealer::new("cookie-signing-secret");
2731 let data = serde_json::json!({
2733 "claims": {"sub": "u1"},
2734 "access_token": "old",
2735 "refresh_token": "rt",
2736 "expires_at": 1
2737 });
2738 let sealed = sealer.seal(
2739 &serde_json::to_vec(&data).unwrap(),
2740 Duration::from_secs(600),
2741 );
2742 let id = crate::sessions::SessionId::random();
2743 let meta = crate::sessions::SessionMeta {
2744 id: String::new(),
2745 subject: "u1".into(),
2746 plugin: "openid-connect".into(),
2747 policy: String::new(),
2748 route: String::new(),
2749 created_at: 0,
2750 expires_at: 0,
2751 };
2752 fake.put(&id, sealed.as_bytes(), Duration::from_secs(600), &meta)
2753 .await
2754 .unwrap();
2755
2756 let mut ctx = req_ctx("/api", HashMap::new());
2757 ctx.request.headers.insert(
2758 "cookie".to_string(),
2759 vec![format!("oidc_session={}", id.as_str())],
2760 );
2761 let out = plugin.execute(ctx).await.unwrap();
2763 assert_eq!(out.port, Some("redirect"), "failed refresh re-enters login");
2764 assert!(fake.try_lock(&id, Duration::from_secs(1)).await.unwrap());
2766 }
2767
2768 #[cfg(feature = "redis-store")]
2772 #[tokio::test]
2773 async fn test_redis_refresh_disabled_passes_stale_session_through() {
2774 use crate::sessions::SessionStore as _;
2775
2776 let (resources, fake) = resources_with_fake_store();
2777 let mut cfg = interactive_explicit_cfg();
2778 cfg.insert(
2779 "session".to_string(),
2780 serde_json::json!({
2781 "secret": "cookie-signing-secret",
2782 "storage": "redis",
2783 "store": "s1",
2784 "refresh": false
2785 }),
2786 );
2787 let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2788
2789 let sealer = CookieSealer::new("cookie-signing-secret");
2790 let data = serde_json::json!({
2791 "claims": {"sub": "u1"},
2792 "access_token": "old",
2793 "refresh_token": "rt",
2794 "expires_at": 1
2795 });
2796 let sealed = sealer.seal(
2797 &serde_json::to_vec(&data).unwrap(),
2798 Duration::from_secs(600),
2799 );
2800 let id = crate::sessions::SessionId::random();
2801 let meta = crate::sessions::SessionMeta {
2802 id: String::new(),
2803 subject: "u1".into(),
2804 plugin: "openid-connect".into(),
2805 policy: String::new(),
2806 route: String::new(),
2807 created_at: 0,
2808 expires_at: 0,
2809 };
2810 fake.put(&id, sealed.as_bytes(), Duration::from_secs(600), &meta)
2811 .await
2812 .unwrap();
2813
2814 let mut ctx = req_ctx("/api", HashMap::new());
2815 ctx.request.headers.insert(
2816 "cookie".to_string(),
2817 vec![format!("oidc_session={}", id.as_str())],
2818 );
2819 let out = plugin.execute(ctx).await.unwrap();
2820 assert!(
2821 out.port.is_none(),
2822 "disabled refresh must pass through as-is"
2823 );
2824 assert_eq!(out.context.message["user_id"], "u1");
2825 assert!(fake.try_lock(&id, Duration::from_secs(1)).await.unwrap());
2827 }
2828
2829 #[cfg(feature = "redis-store")]
2834 async fn spawn_json_server(status_line: &'static str, body: &'static str) -> u16 {
2835 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2836 let port = listener.local_addr().unwrap().port();
2837 tokio::spawn(async move {
2838 if let Ok((mut stream, _)) = listener.accept().await {
2839 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2840 let mut buf = [0u8; 4096];
2841 let _ = stream.read(&mut buf).await;
2842 let _ = stream
2843 .write_all(
2844 format!(
2845 "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\n\
2846 content-length: {}\r\n\r\n{}",
2847 body.len(),
2848 body
2849 )
2850 .as_bytes(),
2851 )
2852 .await;
2853 let _ = stream.shutdown().await;
2854 }
2855 });
2856 port
2857 }
2858
2859 #[cfg(feature = "redis-store")]
2866 #[tokio::test]
2867 async fn test_redis_refresh_winner_success_rewrites_session() {
2868 use crate::sessions::SessionStore as _;
2869
2870 let port = spawn_json_server(
2871 "200 OK",
2872 r#"{"access_token":"new-at","refresh_token":"new-rt","expires_in":3600}"#,
2873 )
2874 .await;
2875
2876 let (resources, fake) = resources_with_fake_store();
2877 let mut cfg = interactive_explicit_cfg();
2878 cfg.insert(
2879 "token_endpoint".to_string(),
2880 serde_json::json!(format!("http://127.0.0.1:{port}/token")),
2881 );
2882 cfg.insert(
2883 "session".to_string(),
2884 serde_json::json!({
2885 "secret": "cookie-signing-secret",
2886 "storage": "redis",
2887 "store": "s1"
2888 }),
2889 );
2890 let plugin = OpenidConnectPlugin::from_config(&cfg, &resources).unwrap();
2891
2892 let sealer = CookieSealer::new("cookie-signing-secret");
2893 let data = serde_json::json!({
2895 "claims": {"sub": "u1"},
2896 "access_token": "old",
2897 "refresh_token": "rt",
2898 "expires_at": 1
2899 });
2900 let sealed = sealer.seal(
2901 &serde_json::to_vec(&data).unwrap(),
2902 Duration::from_secs(600),
2903 );
2904 let id = crate::sessions::SessionId::random();
2905 let meta = crate::sessions::SessionMeta {
2906 id: String::new(),
2907 subject: "u1".into(),
2908 plugin: "openid-connect".into(),
2909 policy: String::new(),
2910 route: String::new(),
2911 created_at: 0,
2912 expires_at: 0,
2913 };
2914 fake.put(&id, sealed.as_bytes(), Duration::from_secs(600), &meta)
2915 .await
2916 .unwrap();
2917
2918 let mut ctx = req_ctx("/api", HashMap::new());
2919 ctx.request.headers.insert(
2920 "cookie".to_string(),
2921 vec![format!("oidc_session={}", id.as_str())],
2922 );
2923 let out = plugin.execute(ctx).await.unwrap();
2924
2925 assert!(out.port.is_none(), "successful refresh must pass through");
2928 assert_eq!(out.context.message["user_id"], "u1");
2929
2930 let stored = fake.get(&id).await.unwrap().unwrap();
2933 let sealed_str = String::from_utf8(stored).unwrap();
2934 let opened = sealer.open(&sealed_str).unwrap();
2935 let value: serde_json::Value = serde_json::from_slice(&opened).unwrap();
2936 assert_eq!(value["access_token"], "new-at");
2937 assert_eq!(value["refresh_token"], "new-rt");
2938 assert!(
2939 value["expires_at"].as_u64().unwrap() > now_unix(),
2940 "expires_at must be in the future: {value}"
2941 );
2942
2943 assert!(fake.try_lock(&id, Duration::from_secs(1)).await.unwrap());
2945 }
2946}