1use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use std::sync::Mutex;
18use std::time::Duration as StdDuration;
19
20use axum::{
21 body::Body,
22 extract::{Query, State},
23 http::{header, StatusCode},
24 response::{IntoResponse, Json, Redirect, Response},
25};
26use axum_extra::extract::cookie::{Cookie, SameSite};
27use pep::oidc::pkce_cookie::PkceCookieManager;
28use pep::oidc_client::OidcClient;
29use pep::oidc_resource_server::ResourceServerClient;
30use pep::session_manager::WebSessionManager;
31use crate::identity::IdentityRegistry;
32use pep::{DevConfig, JwtClaims, JwtValidationOptions, OidcClientConfig};
33use serde::Deserialize;
34use sha2::{Digest, Sha256};
35use time::Duration as TimeDuration;
36
37use cedar_policy::{Context, Entities, EntityUid, Request};
38use std::str::FromStr;
39
40#[derive(Debug, Clone)]
46pub struct AuthConfig {
47 pub issuer_url: String,
49 pub client_id: String,
51 pub client_secret: Option<String>,
53 pub redirect_uri: String,
55 pub scope: String,
57 pub cookie_name: String,
59 pub dev_config: DevConfig,
61 pub validation_options: JwtValidationOptions,
63 pub pkce_cookie_secret: String,
65}
66
67impl AuthConfig {
68 pub fn from_toml(config_toml: &str) -> Option<Self> {
73 let table: toml::Table = toml::from_str(config_toml).ok()?;
74
75 let dev_config = table
77 .get("dev")
78 .and_then(|d| d.as_table())
79 .map(|d| DevConfig {
80 local_dev_mode: d
81 .get("local_dev_mode")
82 .and_then(|v| v.as_bool())
83 .unwrap_or(false),
84 local_dev_email: d
85 .get("local_dev_email")
86 .and_then(|v| v.as_str())
87 .map(String::from),
88 local_dev_name: d
89 .get("local_dev_name")
90 .and_then(|v| v.as_str())
91 .map(String::from),
92 local_dev_username: d
93 .get("local_dev_username")
94 .and_then(|v| v.as_str())
95 .map(String::from),
96 });
97
98 if let Some(ref dc) = dev_config {
100 if dc.local_dev_mode {
101 let oidc = Self::parse_oidc_section(&table);
103 return Some(Self {
104 issuer_url: oidc
105 .as_ref()
106 .map(|o| o.0.clone())
107 .unwrap_or_else(|| "https://auth.example.com".into()),
108 client_id: oidc
109 .as_ref()
110 .map(|o| o.1.clone())
111 .unwrap_or_else(|| "trustee".into()),
112 client_secret: oidc.as_ref().and_then(|o| o.2.clone()),
113 redirect_uri: oidc
114 .as_ref()
115 .map(|o| o.3.clone())
116 .unwrap_or_else(|| "http://localhost:3000/auth/callback".into()),
117 scope: oidc
118 .as_ref()
119 .map(|o| o.4.clone())
120 .unwrap_or_else(|| "openid profile email".into()),
121 cookie_name: "trustee_token".into(),
122 dev_config: dc.clone(),
123 validation_options: JwtValidationOptions::default(),
124 pkce_cookie_secret: oidc
125 .as_ref()
126 .map(|o| o.6.clone())
127 .unwrap_or_else(|| "trustee-default-pkce-secret-change-me".into()),
128 });
129 }
130 }
131
132 let (
134 issuer_url,
135 client_id,
136 client_secret,
137 redirect_uri,
138 scope,
139 validation_options,
140 pkce_secret,
141 ) = Self::parse_oidc_section(&table)?;
142
143 Some(Self {
144 issuer_url,
145 client_id,
146 client_secret,
147 redirect_uri,
148 scope,
149 cookie_name: "trustee_token".into(),
150 dev_config: dev_config.unwrap_or_default(),
151 validation_options,
152 pkce_cookie_secret: pkce_secret,
153 })
154 }
155
156 fn parse_oidc_section(
159 table: &toml::Table,
160 ) -> Option<(
161 String,
162 String,
163 Option<String>,
164 String,
165 String,
166 JwtValidationOptions,
167 String,
168 )> {
169 let oidc = table.get("oidc")?.as_table()?;
170
171 let issuer_url = oidc.get("issuer_url")?.as_str()?.to_string();
172 let client_id = oidc.get("client_id")?.as_str()?.to_string();
173 let client_secret = oidc
174 .get("client_secret")
175 .and_then(|v| v.as_str())
176 .map(String::from);
177 let redirect_uri = oidc
178 .get("redirect_uri")
179 .or_else(|| oidc.get("redirect_url")) .and_then(|v| v.as_str())
181 .unwrap_or("http://localhost:3000/auth/callback")
182 .to_string();
183 let scope = oidc
184 .get("scope")
185 .and_then(|v| v.as_str())
186 .unwrap_or("openid profile email")
187 .to_string();
188
189 let mut validation_options = JwtValidationOptions::default();
190 if let Some(skip) = oidc.get("skip_issuer_validation").and_then(|v| v.as_bool()) {
191 validation_options.skip_issuer_validation = skip;
192 }
193 if let Some(skip) = oidc
194 .get("skip_audience_validation")
195 .and_then(|v| v.as_bool())
196 {
197 validation_options.skip_audience_validation = skip;
198 }
199 validation_options.expected_audience = oidc
200 .get("expected_audience")
201 .and_then(|v| v.as_str())
202 .map(String::from);
203
204 let pkce_secret = oidc
205 .get("pkce_cookie_secret")
206 .and_then(|v| v.as_str())
207 .unwrap_or("trustee-default-pkce-secret-change-me")
208 .to_string();
209
210 Some((
211 issuer_url,
212 client_id,
213 client_secret,
214 redirect_uri,
215 scope,
216 validation_options,
217 pkce_secret,
218 ))
219 }
220
221 pub fn oidc_client_config(&self) -> OidcClientConfig {
223 OidcClientConfig {
224 issuer_url: self.issuer_url.clone(),
225 client_id: self.client_id.clone(),
226 client_secret: self.client_secret.clone(),
227 redirect_uri: self.redirect_uri.clone(),
228 scope: self.scope.clone(),
229 code_challenge_method: "S256".to_string(),
230 }
231 }
232}
233
234const CACHE_EXP_GRACE_SECS: i64 = 120;
242
243const CACHE_TTL_SECS: i64 = 300;
246
247const CACHE_MAX_ENTRIES: usize = 1024;
249
250fn validation_cache_key(token: &str) -> [u8; 16] {
253 let digest = Sha256::digest(token.as_bytes());
254 let mut key = [0u8; 16];
255 key.copy_from_slice(&digest[..16]);
256 key
257}
258
259fn unix_now() -> i64 {
260 std::time::SystemTime::now()
261 .duration_since(std::time::UNIX_EPOCH)
262 .map(|d| d.as_secs() as i64)
263 .unwrap_or(0)
264}
265
266struct ValidationCache {
276 inner: Mutex<ValidationCacheInner>,
277}
278
279#[derive(Default)]
280struct ValidationCacheInner {
281 entries: HashMap<[u8; 16], (JwtClaims, i64)>,
283 first_sight: HashSet<(String, String)>,
285}
286
287impl ValidationCache {
288 fn new() -> Self {
289 Self {
290 inner: Mutex::new(ValidationCacheInner::default()),
291 }
292 }
293
294 fn get(&self, key: &[u8; 16], now: i64) -> Option<JwtClaims> {
296 let inner = self.inner.lock().expect("validation cache poisoned");
297 inner
298 .entries
299 .get(key)
300 .filter(|(_, until)| *until > now)
301 .map(|(claims, _)| claims.clone())
302 }
303
304 fn put(&self, key: [u8; 16], claims: JwtClaims, now: i64) {
308 let valid_until = (claims.exp - CACHE_EXP_GRACE_SECS).min(now + CACHE_TTL_SECS);
309 let mut inner = self.inner.lock().expect("validation cache poisoned");
310 if inner.entries.len() >= CACHE_MAX_ENTRIES {
311 inner.entries.retain(|_, (_, until)| *until > now);
315 if inner.entries.len() >= CACHE_MAX_ENTRIES {
316 inner.entries.clear();
317 }
318 }
319 inner.entries.insert(key, (claims, valid_until));
320 }
321
322 fn mark_first_sight(&self, sub: &str, issuer: &str) -> bool {
324 let mut inner = self.inner.lock().expect("validation cache poisoned");
325 if inner.first_sight.len() >= CACHE_MAX_ENTRIES {
326 inner.first_sight.clear();
327 }
328 inner
329 .first_sight
330 .insert((sub.to_string(), issuer.to_string()))
331 }
332}
333
334#[derive(Clone)]
336pub struct AuthState {
337 pub oidc_client: OidcClient,
339 pub resource_server: ResourceServerClient,
341 pub client_config: OidcClientConfig,
343 pub config: AuthConfig,
345 pub pkce_manager: PkceCookieManager,
347 pub session_manager: Arc<WebSessionManager>,
349 pub issuer_fallbacks: Vec<String>,
355 pub cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
357 validation_cache: Arc<ValidationCache>,
360 pub identity_registry: IdentityRegistry,
364}
365
366impl AuthState {
367 pub fn new(config: AuthConfig) -> Self {
369 Self::with_cedar(config, None)
370 }
371
372 pub fn with_cedar(
374 config: AuthConfig,
375 cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
376 ) -> Self {
377 let pkce_manager = PkceCookieManager::new(
378 config.pkce_cookie_secret.as_bytes(),
379 "trustee_pkce_state",
380 StdDuration::from_secs(600),
381 );
382
383 let session_manager = Arc::new(WebSessionManager::new(
384 OidcClient::new(),
385 config.issuer_url.clone(),
386 config.client_id.clone(),
387 config.client_secret.clone(),
388 config.scope.clone(),
389 ));
390
391 Self {
392 oidc_client: OidcClient::new(),
393 resource_server: ResourceServerClient::new(),
394 client_config: config.oidc_client_config(),
395 pkce_manager,
396 session_manager,
397 config,
398 cedar_authorizer,
399 issuer_fallbacks: Vec::new(),
400 validation_cache: Arc::new(ValidationCache::new()),
401 identity_registry: IdentityRegistry::from_default_home(),
402 }
403 }
404
405 pub fn resolve_user_key(&self, claims: &JwtClaims) -> String {
411 if PrincipalKind::from_role(claim_role(claims).as_deref()) == PrincipalKind::Agent {
412 return claims.sub.clone();
413 }
414 self.identity_registry.pin(claims.sub.as_str(), &jwt_user_key(claims))
415 }
416
417 pub fn with_issuer_fallbacks(mut self, issuers: Vec<String>) -> Self {
419 self.issuer_fallbacks = issuers;
420 self
421 }
422
423 pub fn is_dev_mode(&self) -> bool {
425 self.config.dev_config.local_dev_mode
426 }
427
428 pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
430 self.validate_token_on(&self.config.issuer_url, token).await
431 }
432
433 pub async fn validate_token_flexible(&self, token: &str) -> anyhow::Result<JwtClaims> {
446 let key = validation_cache_key(token);
447 let now = unix_now();
448 if let Some(cached) = self.validation_cache.get(&key, now) {
449 return Ok(cached);
450 }
451 let claims = self.validate_token_flexible_uncached(token).await?;
452 self.validation_cache.put(key, claims.clone(), now);
453 Ok(claims)
454 }
455
456 async fn validate_token_flexible_uncached(&self, token: &str) -> anyhow::Result<JwtClaims> {
457 match self.validate_token(token).await {
458 Ok(claims) => Ok(claims),
459 Err(primary) => {
460 let mut last = primary;
461 for si in &self.issuer_fallbacks {
462 if *si == self.config.issuer_url {
463 continue; }
465 match self.validate_token_on(si, token).await {
466 Ok(claims) => {
467 let first = self.validation_cache.mark_first_sight(&claims.sub, si);
471 let who = claims.preferred_username.as_deref().unwrap_or(&claims.sub);
472 if first {
473 tracing::info!(
474 "service principal {} authenticating via service-issuer fallback (iss={})",
475 who,
476 si
477 );
478 } else {
479 tracing::debug!(
480 "token validated via service-issuer fallback (iss={}) — principal {}",
481 si,
482 who
483 );
484 }
485 return Ok(claims);
486 }
487 Err(e) => last = e,
488 }
489 }
490 tracing::warn!(
493 "token validation failed — primary: {last}; all service-issuer fallbacks exhausted"
494 );
495 Err(anyhow::anyhow!(
496 "primary: {last}; all service-issuer fallbacks exhausted"
497 ))
498 }
499 }
500 }
501
502 pub async fn validate_token_on(
505 &self,
506 issuer_url: &str,
507 token: &str,
508 ) -> anyhow::Result<JwtClaims> {
509 let mut claims = self
510 .resource_server
511 .validate_jwt_with_options(
512 token,
513 issuer_url,
514 &self.config.client_id,
515 &self.config.validation_options,
516 )
517 .await
518 .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
519
520 if let Err(e) = self
525 .resource_server
526 .enrich_claims_with_userinfo(&mut claims, token, issuer_url, None)
527 .await
528 {
529 tracing::error!(
530 "userinfo enrichment FAILED for sub {}: {} — principal carries NO role/groups; \
531 with Cedar enabled every request will be DENIED until enrichment succeeds",
532 claims.sub,
533 e
534 );
535 }
536
537 if claims.name.is_none() || claims.email.is_none() {
540 self.fill_userinfo_fields(&mut claims, token, issuer_url)
541 .await;
542 }
543
544 Ok(claims)
545 }
546
547 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str, issuer_url: &str) {
550 let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));
555
556 let client = reqwest::Client::new();
557 let resp = client
558 .get(&userinfo_url)
559 .header("Authorization", format!("Bearer {}", token))
560 .header("Accept", "application/json")
561 .send()
562 .await;
563
564 let Ok(resp) = resp else {
565 tracing::debug!("Userinfo request failed for name/email enrichment");
566 return;
567 };
568
569 if !resp.status().is_success() {
570 tracing::debug!(
571 "Userinfo returned {} for name/email enrichment",
572 resp.status()
573 );
574 return;
575 }
576
577 let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await
578 else {
579 return;
580 };
581
582 tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
583
584 if claims.name.is_none() {
585 if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
586 claims.name = Some(name.to_string());
587 }
588 }
589 if claims.email.is_none() {
590 if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
591 claims.email = Some(email.to_string());
592 }
593 }
594 if claims.preferred_username.is_none() {
595 if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
596 claims.preferred_username = Some(uname.to_string());
597 }
598 }
599 }
600
601 fn check_cedar_authorized(&self, claims: &JwtClaims, action: &str) -> Result<(), ()> {
606 let Some(ref authorizer) = self.cedar_authorizer else {
607 return Ok(()); };
609
610 let principal_entity = match pep::cedar::build_principal_entity(claims) {
612 Ok(e) => e,
613 Err(e) => {
614 tracing::error!("Cedar: failed to build principal entity: {}", e);
615 return Err(());
616 }
617 };
618
619 let mut entities_vec = vec![principal_entity];
621
622 let app_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
624 Ok(uid) => uid,
625 Err(e) => {
626 tracing::error!("Cedar: failed to build TrusteeApp uid: {}", e);
627 return Err(());
628 }
629 };
630 let app_entity = match cedar_policy::Entity::new(
631 app_uid,
632 std::collections::HashMap::new(),
633 std::collections::HashSet::new(),
634 ) {
635 Ok(e) => e,
636 Err(e) => {
637 tracing::error!("Cedar: failed to build TrusteeApp entity: {}", e);
638 return Err(());
639 }
640 };
641 entities_vec.push(app_entity);
642
643 let entities = match Entities::from_entities(entities_vec, None) {
644 Ok(e) => e,
645 Err(e) => {
646 tracing::error!("Cedar: failed to build entities set: {}", e);
647 return Err(());
648 }
649 };
650
651 let principal_uid = match pep::cedar::build_principal_uid(claims) {
653 Ok(uid) => uid,
654 Err(e) => {
655 tracing::error!("Cedar: failed to build principal uid: {}", e);
656 return Err(());
657 }
658 };
659
660 let action_uid = match EntityUid::from_str(&format!("Action::\"{action}\"")) {
661 Ok(uid) => uid,
662 Err(e) => {
663 tracing::error!("Cedar: failed to build action uid: {}", e);
664 return Err(());
665 }
666 };
667
668 let resource_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
669 Ok(uid) => uid,
670 Err(e) => {
671 tracing::error!("Cedar: failed to build resource uid: {}", e);
672 return Err(());
673 }
674 };
675
676 let request = match Request::new(
677 principal_uid,
678 action_uid,
679 resource_uid,
680 Context::empty(),
681 None,
682 ) {
683 Ok(r) => r,
684 Err(e) => {
685 tracing::error!("Cedar: failed to build request: {}", e);
686 return Err(());
687 }
688 };
689
690 let response = authorizer.is_allowed_with_entities(&request, &entities);
691
692 if response.allowed() {
693 tracing::debug!(
694 "Cedar: authorized user {} (sub={})",
695 claims.email.as_deref().unwrap_or("unknown"),
696 claims.sub
697 );
698 Ok(())
699 } else {
700 tracing::warn!(
701 "Cedar: DENIED user {} (sub={}) — matched policies: {:?}, errors: {:?}",
702 claims.email.as_deref().unwrap_or("unknown"),
703 claims.sub,
704 response.matched_policies(),
705 response.errors()
706 );
707 Err(())
708 }
709 }
710}
711
712pub mod actions {
723 pub const LIST_MODELS: &str = "ListModels";
724 pub const LIST_SESSIONS: &str = "ListSessions";
725 pub const VIEW_SESSION: &str = "ViewSession";
726 pub const VIEW_HISTORY: &str = "ViewHistory";
727 pub const CREATE_SESSION: &str = "CreateSession";
728 pub const COMMAND_SESSION: &str = "CommandSession";
729 pub const CANCEL_SESSION: &str = "CancelSession";
730 pub const HANDOFF_SESSION: &str = "HandoffSession";
731 pub const RESUME_SESSION: &str = "ResumeSession";
732 pub const UPDATE_SESSION: &str = "UpdateSession";
733 pub const DELETE_SESSION: &str = "DeleteSession";
734 pub const VIEW_MCP_CREDENTIALS: &str = "ViewMcpCredentials";
735 pub const UPDATE_MCP_CREDENTIALS: &str = "UpdateMcpCredentials";
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742pub enum PrincipalKind {
743 Human,
744 Agent,
745}
746
747impl PrincipalKind {
748 pub fn from_role(role: Option<&str>) -> Self {
752 match role {
753 Some("agent") => Self::Agent,
754 _ => Self::Human,
755 }
756 }
757}
758
759fn claim_role(claims: &JwtClaims) -> Option<String> {
765 match claims.extra.get("role") {
766 Some(serde_json::Value::String(s)) => Some(s.clone()),
767 Some(serde_json::Value::Array(arr)) => arr
768 .iter()
769 .filter_map(|v| v.as_str())
770 .next()
771 .map(|s| s.to_string()),
772 _ => None,
773 }
774}
775
776#[derive(Debug, Clone)]
778pub struct AuthUser {
779 pub sub: String,
780 pub email: Option<String>,
781 pub name: Option<String>,
782 pub username: Option<String>,
783 pub is_dev: bool,
784 pub role: Option<String>,
786 pub kind: PrincipalKind,
788}
789
790impl From<JwtClaims> for AuthUser {
791 fn from(claims: JwtClaims) -> Self {
792 let role = claim_role(&claims);
793 let kind = PrincipalKind::from_role(role.as_deref());
794 Self {
795 sub: claims.sub,
796 email: claims.email,
797 name: claims.name,
798 username: claims.preferred_username,
799 is_dev: false,
800 role,
801 kind,
802 }
803 }
804}
805
806const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
808
809fn jwt_user_key(claims: &JwtClaims) -> String {
826 if PrincipalKind::from_role(claim_role(claims).as_deref()) == PrincipalKind::Agent {
827 return claims.sub.clone();
828 }
829 match claims.preferred_username.as_deref() {
830 Some(u) if !u.trim().is_empty() => u.trim().to_string(),
831 _ => {
832 tracing::debug!(
833 "user_key: preferred_username missing/empty for sub {} — falling back to sub",
834 claims.sub
835 );
836 claims.sub.clone()
837 }
838 }
839}
840
841fn dev_user_key(token: &str) -> Option<String> {
849 if let Some(name) = token.strip_prefix("dev:agent:") {
850 let name = name.trim();
851 if name.is_empty() || name.contains(':') {
852 return None;
853 }
854 return Some(format!("agent-{name}"));
855 }
856 let parts: Vec<&str> = token.splitn(4, ':').collect();
857 if parts.len() >= 4 {
858 Some(format!("dev:{}", parts[1]))
859 } else {
860 None
861 }
862}
863
864pub(crate) fn dispatch_allowed(kind: PrincipalKind, role: Option<&str>) -> bool {
866 kind == PrincipalKind::Human && role == Some("admin")
867}
868
869pub async fn check_dispatch_admin(
881 auth: &Option<Arc<AuthState>>,
882 headers: &axum::http::HeaderMap,
883) -> Result<(), StatusCode> {
884 let Some(auth) = auth.as_ref() else {
885 return Ok(()); };
887 let Some(token) = headers
888 .get(header::AUTHORIZATION)
889 .and_then(|v| v.to_str().ok())
890 .and_then(|v| v.strip_prefix("Bearer "))
891 .map(|s| s.to_string())
892 else {
893 return Err(StatusCode::UNAUTHORIZED);
894 };
895 if token.starts_with("dev:") {
896 tracing::warn!("xagent dispatch rejected: dev tokens cannot dispatch agents");
897 return Err(StatusCode::FORBIDDEN);
898 }
899 let claims = auth.validate_token_flexible(&token).await.map_err(|e| {
900 tracing::warn!("xagent dispatch: caller token validation failed: {}", e);
901 StatusCode::UNAUTHORIZED
902 })?;
903 let user = AuthUser::from(claims);
904 if !dispatch_allowed(user.kind, user.role.as_deref()) {
905 tracing::warn!(
906 "xagent dispatch rejected: principal kind={:?} role={:?} — admin required",
907 user.kind,
908 user.role
909 );
910 return Err(StatusCode::FORBIDDEN);
911 }
912 Ok(())
913}
914
915impl AuthState {
916 pub async fn exchange_agent_token(
928 &self,
929 issuer_url: &str,
930 service_token: &str,
931 ) -> Result<(String, u64), StatusCode> {
932 let tr = self
933 .oidc_client
934 .exchange_token(
935 issuer_url,
936 &self.config.client_id,
937 None, service_token,
939 &self.config.client_id,
940 Some("openid groups"),
941 )
942 .await
943 .map_err(|e| {
944 tracing::error!(
945 "xagent dispatch: agent token exchange FAILED at {issuer_url}: {} — impersonation unavailable",
946 e
947 );
948 StatusCode::BAD_GATEWAY
949 })?;
950 Ok((tr.access_token, tr.expires_in.unwrap_or(900)))
951 }
952}
953
954pub async fn check_auth(
975 auth: &Option<Arc<AuthState>>,
976 headers: &axum::http::HeaderMap,
977 action: &str,
978) -> Result<(Option<String>, String), StatusCode> {
979 let Some(auth) = auth.as_ref() else {
980 return Ok((None, "default".to_string())); };
982
983 if let Some(token) = headers
985 .get(header::AUTHORIZATION)
986 .and_then(|v| v.to_str().ok())
987 .and_then(|v| v.strip_prefix("Bearer "))
988 .map(|s| s.to_string())
989 {
990 if token.starts_with("dev:") {
992 if !auth.config.dev_config.local_dev_mode {
993 tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
994 return Err(StatusCode::UNAUTHORIZED);
995 }
996 return match dev_user_key(&token) {
997 Some(key) => Ok((None, key)),
998 None => Err(StatusCode::UNAUTHORIZED),
999 };
1000 }
1001
1002 return match auth.validate_token_flexible(&token).await {
1003 Ok(claims) => {
1004 if auth.check_cedar_authorized(&claims, action).is_err() {
1005 return Err(StatusCode::FORBIDDEN);
1006 }
1007 Ok((None, auth.resolve_user_key(&claims)))
1008 }
1009 Err(e) => {
1010 tracing::warn!("Bearer token validation failed: {}", e);
1011 Err(StatusCode::UNAUTHORIZED)
1012 }
1013 };
1014 }
1015
1016 let session_id = headers
1018 .get(header::COOKIE)
1019 .and_then(|v| v.to_str().ok())
1020 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
1021
1022 let Some(session_id) = session_id else {
1023 tracing::warn!("No auth token found in request");
1024 return Err(StatusCode::UNAUTHORIZED);
1025 };
1026
1027 if session_id.starts_with("dev:") {
1029 if !auth.config.dev_config.local_dev_mode {
1030 tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
1031 return Err(StatusCode::UNAUTHORIZED);
1032 }
1033 return match dev_user_key(&session_id) {
1034 Some(key) => Ok((None, key)),
1035 None => Err(StatusCode::UNAUTHORIZED),
1036 };
1037 }
1038
1039 match auth.session_manager.get_token(&session_id).await {
1041 Ok(access_token) => match auth.validate_token(&access_token).await {
1042 Ok(claims) => {
1043 if auth.check_cedar_authorized(&claims, action).is_err() {
1045 return Err(StatusCode::FORBIDDEN);
1046 }
1047 let secure = auth.client_config.redirect_uri.starts_with("https");
1049 let cookie = create_auth_cookie(
1050 &auth.config.cookie_name,
1051 &session_id,
1052 SESSION_COOKIE_MAX_AGE,
1053 secure,
1054 );
1055 Ok((Some(cookie.to_string()), auth.resolve_user_key(&claims)))
1056 }
1057 Err(e) => {
1058 tracing::warn!(
1061 "Session token validation failed: {} — attempting force-refresh",
1062 e
1063 );
1064 match auth.session_manager.force_refresh(&session_id).await {
1065 Ok(new_token) => match auth.validate_token(&new_token).await {
1066 Ok(claims) => {
1067 if auth.check_cedar_authorized(&claims, action).is_err() {
1069 return Err(StatusCode::FORBIDDEN);
1070 }
1071 let secure = auth.client_config.redirect_uri.starts_with("https");
1072 let cookie = create_auth_cookie(
1073 &auth.config.cookie_name,
1074 &session_id,
1075 SESSION_COOKIE_MAX_AGE,
1076 secure,
1077 );
1078 Ok((Some(cookie.to_string()), auth.resolve_user_key(&claims)))
1079 }
1080 Err(e2) => {
1081 tracing::warn!(
1082 "Session token still invalid after force-refresh: {}",
1083 e2
1084 );
1085 Err(StatusCode::UNAUTHORIZED)
1086 }
1087 },
1088 Err(e2) => {
1089 tracing::warn!("Force-refresh failed: {}", e2);
1090 Err(StatusCode::UNAUTHORIZED)
1091 }
1092 }
1093 }
1094 },
1095 Err(e) => {
1096 tracing::warn!("Session lookup/refresh failed: {}", e);
1097 Err(StatusCode::UNAUTHORIZED)
1098 }
1099 }
1100}
1101
1102async fn resolve_access_token(
1108 auth: &AuthState,
1109 headers: &axum::http::HeaderMap,
1110) -> Result<String, StatusCode> {
1111 if let Some(token) = headers
1113 .get(header::AUTHORIZATION)
1114 .and_then(|v| v.to_str().ok())
1115 .and_then(|v| v.strip_prefix("Bearer "))
1116 .map(|s| s.to_string())
1117 {
1118 return Ok(token);
1119 }
1120
1121 let session_id = headers
1123 .get(header::COOKIE)
1124 .and_then(|v| v.to_str().ok())
1125 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
1126
1127 match session_id {
1128 Some(sid) if sid.starts_with("dev:") => {
1129 if !auth.config.dev_config.local_dev_mode {
1130 tracing::warn!(
1131 "Dev cookie in resolve_access_token but dev mode is disabled — rejecting"
1132 );
1133 Err(StatusCode::UNAUTHORIZED)
1134 } else {
1135 Ok(sid)
1136 }
1137 }
1138 Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
1139 tracing::warn!("Failed to resolve session token: {}", e);
1140 StatusCode::UNAUTHORIZED
1141 }),
1142 None => Err(StatusCode::UNAUTHORIZED),
1143 }
1144}
1145
1146fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
1148 for cookie in cookie_header.split(';') {
1149 let cookie = cookie.trim();
1150 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
1151 return Some(value.to_string());
1152 }
1153 }
1154 None
1155}
1156
1157pub fn auth_routes() -> axum::Router<crate::ServerState> {
1163 axum::Router::new()
1164 .route("/login", axum::routing::get(login_handler))
1165 .route("/callback", axum::routing::get(callback_handler))
1166 .route("/me", axum::routing::get(me_handler))
1167 .route("/logout", axum::routing::post(logout_handler))
1168 .route("/mcp/login", axum::routing::get(mcp_login_handler))
1169 .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
1170 .route("/mcp/status", axum::routing::get(mcp_status_handler))
1171 .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
1172}
1173
1174#[derive(Debug, Deserialize)]
1176pub struct CallbackQuery {
1177 pub code: Option<String>,
1178 pub state: Option<String>,
1179 pub error: Option<String>,
1180 pub error_description: Option<String>,
1181}
1182
1183async fn login_handler(State(state): State<crate::ServerState>) -> Result<Response, AuthError> {
1185 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1186
1187 if auth.is_dev_mode() {
1189 tracing::info!("Dev mode: creating dev session");
1190 let dev = &auth.config.dev_config;
1191 let dev_token = format!(
1192 "dev:{}:{}:{}",
1193 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
1194 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
1195 dev.local_dev_username.as_deref().unwrap_or("dev")
1196 );
1197 let cookie = create_auth_cookie(
1198 &auth.config.cookie_name,
1199 &dev_token,
1200 StdDuration::from_secs(86400),
1201 false,
1202 );
1203 return Ok(Response::builder()
1204 .status(StatusCode::FOUND)
1205 .header(header::LOCATION, "/")
1206 .header(header::SET_COOKIE, cookie.to_string())
1207 .body(Body::empty())
1208 .unwrap());
1209 }
1210
1211 let pkce_session = auth.pkce_manager.create();
1213 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
1214
1215 let auth_url = auth
1216 .oidc_client
1217 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
1218 .await
1219 .map_err(|e| AuthError::OidcError(e.to_string()))?;
1220
1221 let secure = auth.client_config.redirect_uri.starts_with("https");
1225 let pkce_cookie = Cookie::build((
1226 auth.pkce_manager.cookie_name().to_string(),
1227 pkce_session.cookie_value,
1228 ))
1229 .path("/")
1230 .http_only(true)
1231 .same_site(SameSite::Lax)
1232 .secure(secure)
1233 .max_age(TimeDuration::seconds(
1234 auth.pkce_manager.ttl().as_secs() as i64
1235 ))
1236 .build();
1237
1238 Ok(Response::builder()
1239 .status(StatusCode::TEMPORARY_REDIRECT)
1240 .header(header::LOCATION, &auth_url)
1241 .header(header::SET_COOKIE, pkce_cookie.to_string())
1242 .body(Body::empty())
1243 .unwrap())
1244}
1245
1246async fn callback_handler(
1248 State(state): State<crate::ServerState>,
1249 Query(query): Query<CallbackQuery>,
1250 headers: axum::http::HeaderMap,
1251) -> Result<Response, AuthError> {
1252 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1253
1254 if let Some(error) = query.error {
1256 let desc = query.error_description.unwrap_or_default();
1257 tracing::error!("OIDC error: {} - {}", error, desc);
1258 return Ok(Redirect::temporary(&format!(
1259 "/?error={}&error_description={}",
1260 urlencoding::encode(&error),
1261 urlencoding::encode(&desc)
1262 ))
1263 .into_response());
1264 }
1265
1266 let code = query.code.ok_or(AuthError::MissingCode)?;
1267 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1268
1269 let cookie_header = headers
1271 .get(header::COOKIE)
1272 .and_then(|v| v.to_str().ok())
1273 .unwrap_or("");
1274 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
1275 .ok_or(AuthError::InvalidState)?;
1276
1277 let verifier = auth
1279 .pkce_manager
1280 .verify(&pkce_value, &oauth_state)
1281 .ok_or(AuthError::InvalidState)?;
1282
1283 tracing::info!("Exchanging authorization code for tokens");
1285 let token_response = auth
1286 .oidc_client
1287 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
1288 .await
1289 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1290
1291 let session_id = auth
1292 .session_manager
1293 .create_session(&token_response)
1294 .await
1295 .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
1296
1297 let max_age = SESSION_COOKIE_MAX_AGE;
1300
1301 let secure = auth.client_config.redirect_uri.starts_with("https");
1303 let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
1304
1305 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
1307 .path("/")
1308 .http_only(true)
1309 .same_site(SameSite::Lax)
1310 .max_age(TimeDuration::seconds(-1))
1311 .build();
1312
1313 tracing::info!("Authentication successful, redirecting to /");
1314
1315 Ok(Response::builder()
1316 .status(StatusCode::FOUND)
1317 .header(header::LOCATION, "/")
1318 .header(header::SET_COOKIE, cookie.to_string())
1319 .header(header::SET_COOKIE, clear_pkce.to_string())
1320 .body(Body::empty())
1321 .unwrap())
1322}
1323
1324async fn me_handler(
1326 State(state): State<crate::ServerState>,
1327 headers: axum::http::HeaderMap,
1328) -> Response {
1329 let Some(ref auth) = state.auth else {
1330 return axum::Json(serde_json::json!({
1332 "authenticated": true,
1333 "auth_enabled": false
1334 }))
1335 .into_response();
1336 };
1337
1338 let cookie_header = headers
1339 .get(header::COOKIE)
1340 .and_then(|v| v.to_str().ok())
1341 .unwrap_or("");
1342
1343 let bearer = headers
1345 .get(header::AUTHORIZATION)
1346 .and_then(|v| v.to_str().ok())
1347 .and_then(|v| v.strip_prefix("Bearer "))
1348 .map(String::from);
1349
1350 let token = bearer
1351 .clone()
1352 .or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
1353
1354 let Some(cookie_value) = token else {
1355 return axum::Json(serde_json::json!({
1356 "authenticated": false,
1357 "auth_enabled": true
1358 }))
1359 .into_response();
1360 };
1361
1362 if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
1365 let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
1366 if parts.len() >= 4 {
1367 return axum::Json(serde_json::json!({
1368 "authenticated": true,
1369 "auth_enabled": true,
1370 "email": parts[1],
1371 "name": parts[2],
1372 "username": parts[3],
1373 "dev_mode": true
1374 }))
1375 .into_response();
1376 }
1377 }
1378
1379 let access_token = if bearer.is_some() {
1381 cookie_value
1383 } else {
1384 match auth.session_manager.get_token(&cookie_value).await {
1386 Ok(token) => token,
1387 Err(e) => {
1388 tracing::debug!("Session token resolution failed for /auth/me: {}", e);
1389 return axum::Json(serde_json::json!({
1390 "authenticated": false,
1391 "auth_enabled": true
1392 }))
1393 .into_response();
1394 }
1395 }
1396 };
1397
1398 match auth.validate_token(&access_token).await {
1400 Ok(claims) => axum::Json(serde_json::json!({
1401 "authenticated": true,
1402 "auth_enabled": true,
1403 "sub": claims.sub,
1404 "email": claims.email,
1405 "name": claims.name,
1406 "username": claims.preferred_username,
1407 "dev_mode": false
1408 }))
1409 .into_response(),
1410 Err(e) => {
1411 tracing::debug!("Token validation failed for /auth/me: {}", e);
1412 axum::Json(serde_json::json!({
1413 "authenticated": false,
1414 "auth_enabled": true
1415 }))
1416 .into_response()
1417 }
1418 }
1419}
1420
1421async fn logout_handler(
1423 State(state): State<crate::ServerState>,
1424 headers: axum::http::HeaderMap,
1425) -> Response {
1426 let cookie_name = state
1427 .auth
1428 .as_ref()
1429 .map(|a| a.config.cookie_name.as_str())
1430 .unwrap_or("trustee_token");
1431
1432 if let Some(ref auth) = state.auth {
1434 if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
1435 if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
1436 if !session_id.starts_with("dev:") {
1437 let _ = auth.session_manager.destroy_session(&session_id);
1438 }
1439 }
1440 }
1441 }
1442
1443 let cookie = Cookie::build((cookie_name.to_string(), ""))
1444 .path("/")
1445 .http_only(true)
1446 .same_site(SameSite::Lax)
1447 .max_age(TimeDuration::seconds(-1))
1448 .build();
1449
1450 Response::builder()
1451 .status(StatusCode::FOUND)
1452 .header(header::LOCATION, "/")
1453 .header(header::SET_COOKIE, cookie.to_string())
1454 .body(Body::empty())
1455 .unwrap()
1456}
1457
1458#[derive(Debug, Deserialize)]
1464pub struct McpLoginQuery {
1465 pub cred: String,
1466}
1467
1468#[derive(Debug, Deserialize)]
1470pub struct McpCallbackQuery {
1471 pub code: Option<String>,
1472 pub state: Option<String>,
1473 pub error: Option<String>,
1474 pub error_description: Option<String>,
1475}
1476
1477async fn mcp_login_handler(
1482 State(state): State<crate::ServerState>,
1483 Query(query): Query<McpLoginQuery>,
1484 headers: axum::http::HeaderMap,
1485) -> Result<Response, AuthError> {
1486 let (_cookie, _user_key) = crate::auth::check_auth(
1488 &state.auth,
1489 &headers,
1490 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1491 )
1492 .await
1493 .map_err(|_| AuthError::AuthNotConfigured)?;
1494
1495 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1496
1497 let cred_config = load_mcp_credential(&state, &query.cred).await?;
1499
1500 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1501 McpCredentialInfo::WebInteractive {
1502 issuer_url,
1503 client_id,
1504 client_secret,
1505 scope,
1506 } => (
1507 issuer_url.clone(),
1508 client_id.clone(),
1509 client_secret.clone(),
1510 scope.clone(),
1511 ),
1512 _ => {
1513 return Ok(Redirect::temporary(&format!(
1514 "/?mcp_error={}",
1515 urlencoding::encode(&format!(
1516 "Credential '{}' is not web-interactive type",
1517 query.cred
1518 ))
1519 ))
1520 .into_response());
1521 }
1522 };
1523
1524 let oidc_client = OidcClient::new();
1526 let verifier = OidcClient::generate_code_verifier();
1527 let challenge = OidcClient::generate_code_challenge(&verifier);
1528 let oauth_state = OidcClient::generate_state();
1529
1530 let mcp_redirect_uri = format!(
1532 "{}/auth/mcp/callback",
1533 auth.client_config
1534 .redirect_uri
1535 .trim_end_matches('/')
1536 .trim_end_matches("/auth/callback")
1537 );
1538
1539 let mcp_client_config = OidcClientConfig {
1540 issuer_url: issuer_url.clone(),
1541 client_id: client_id.clone(),
1542 client_secret: client_secret.clone(),
1543 redirect_uri: mcp_redirect_uri.clone(),
1544 scope: scope.clone(),
1545 code_challenge_method: "S256".to_string(),
1546 };
1547
1548 let auth_url = oidc_client
1550 .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
1551 .await
1552 .map_err(|e| AuthError::OidcError(e.to_string()))?;
1553
1554 mcp_pkce()
1556 .insert(oauth_state.clone(), verifier.clone(), query.cred.clone())
1557 .await;
1558
1559 tracing::info!(
1560 "Initiating MCP browser login for credential '{}' (issuer={})",
1561 query.cred,
1562 issuer_url
1563 );
1564
1565 Ok(Response::builder()
1566 .status(StatusCode::TEMPORARY_REDIRECT)
1567 .header(header::LOCATION, &auth_url)
1568 .body(Body::empty())
1569 .unwrap())
1570}
1571
1572async fn mcp_callback_handler(
1574 State(state): State<crate::ServerState>,
1575 Query(query): Query<McpCallbackQuery>,
1576 headers: axum::http::HeaderMap,
1577) -> Result<Response, AuthError> {
1578 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1579
1580 if let Some(error) = query.error {
1582 let desc = query.error_description.unwrap_or_default();
1583 tracing::error!("MCP OIDC error: {} - {}", error, desc);
1584 return Ok(Redirect::temporary(&format!(
1585 "/?mcp_error={}&error_description={}",
1586 urlencoding::encode(&error),
1587 urlencoding::encode(&desc)
1588 ))
1589 .into_response());
1590 }
1591
1592 let code = query.code.ok_or(AuthError::MissingCode)?;
1593 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1594
1595 let pkce_data = mcp_pkce()
1597 .take(&oauth_state)
1598 .await
1599 .ok_or(AuthError::InvalidState)?;
1600
1601 let verifier = pkce_data.verifier;
1602 let cred_name = &pkce_data.cred_name;
1603
1604 let cred_config = load_mcp_credential(&state, cred_name).await?;
1606
1607 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1608 McpCredentialInfo::WebInteractive {
1609 issuer_url,
1610 client_id,
1611 client_secret,
1612 scope,
1613 } => (
1614 issuer_url.clone(),
1615 client_id.clone(),
1616 client_secret.clone(),
1617 scope.clone(),
1618 ),
1619 _ => {
1620 return Ok(Redirect::temporary(&format!(
1621 "/?mcp_error={}",
1622 urlencoding::encode("Credential is not web-interactive type")
1623 ))
1624 .into_response());
1625 }
1626 };
1627
1628 let mcp_redirect_uri = format!(
1630 "{}/auth/mcp/callback",
1631 auth.client_config
1632 .redirect_uri
1633 .trim_end_matches('/')
1634 .trim_end_matches("/auth/callback")
1635 );
1636
1637 let mcp_client_config = OidcClientConfig {
1638 issuer_url: issuer_url.clone(),
1639 client_id: client_id.clone(),
1640 client_secret: client_secret.clone(),
1641 redirect_uri: mcp_redirect_uri,
1642 scope: scope.clone(),
1643 code_challenge_method: "S256".to_string(),
1644 };
1645
1646 tracing::info!(
1648 "Exchanging MCP authorization code for tokens (credential={})",
1649 cred_name
1650 );
1651 let oidc_client = OidcClient::new();
1652 let token_response = oidc_client
1653 .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
1654 .await
1655 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1656
1657 let expires_at = {
1659 let now = std::time::SystemTime::now()
1660 .duration_since(std::time::UNIX_EPOCH)
1661 .unwrap_or_default()
1662 .as_secs();
1663 let expires_epoch = now + token_response.expires_in.unwrap_or(900);
1664 let days = expires_epoch / 86400;
1665 let rem = expires_epoch % 86400;
1666 let h = rem / 3600;
1667 let m = (rem % 3600) / 60;
1668 let s = rem % 60;
1669 let z = days as i64 + 719468;
1670 let era = if z >= 0 { z } else { z - 146096 } / 146097;
1671 let doe = (z - era * 146097) as u64;
1672 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1673 let y = yoe as i64 + era * 400;
1674 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1675 let mp = (5 * doy + 2) / 153;
1676 let d = doy - (153 * mp + 2) / 5 + 1;
1677 let mon = if mp < 10 { mp + 3 } else { mp - 9 };
1678 let yr = if mon <= 2 { y + 1 } else { y };
1679 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
1680 };
1681
1682 use pep::{FileTokenStore, StoredToken, TokenStore};
1684
1685 let stored = StoredToken::new(
1686 &token_response.access_token,
1687 token_response.refresh_token.clone(),
1688 "Bearer",
1689 &expires_at,
1690 token_response.scope.clone(),
1691 );
1692
1693 let agent_name = state
1694 .config_toml
1695 .as_ref()
1696 .and_then(|t| {
1697 toml::from_str::<toml::Value>(t).ok().and_then(|v| {
1698 v.get("agent")
1699 .and_then(|a| a.get("name"))
1700 .and_then(|n| n.as_str())
1701 .map(String::from)
1702 })
1703 })
1704 .unwrap_or_else(|| "trustee".to_string());
1705 let token_store = FileTokenStore::new(&agent_name);
1706
1707 if let Err(e) = token_store.save(cred_name, &stored) {
1708 tracing::error!("Failed to store MCP token: {}", e);
1709 return Ok(Redirect::temporary(&format!(
1710 "/?mcp_error={}",
1711 urlencoding::encode(&format!("Failed to store token: {}", e))
1712 ))
1713 .into_response());
1714 }
1715
1716 tracing::info!(
1717 "MCP authentication successful for credential '{}' (expires {})",
1718 cred_name,
1719 expires_at
1720 );
1721
1722 Ok(Response::builder()
1723 .status(StatusCode::FOUND)
1724 .header(
1725 header::LOCATION,
1726 format!("/?mcp_connected={}", urlencoding::encode(cred_name)),
1727 )
1728 .body(Body::empty())
1729 .unwrap())
1730}
1731
1732async fn mcp_status_handler(
1734 State(state): State<crate::ServerState>,
1735 headers: axum::http::HeaderMap,
1736) -> Response {
1737 use pep::{FileTokenStore, TokenStore};
1738
1739 let (_cookie, user_key) = match crate::auth::check_auth(
1741 &state.auth,
1742 &headers,
1743 crate::auth::actions::VIEW_MCP_CREDENTIALS,
1744 )
1745 .await
1746 {
1747 Ok(result) => result,
1748 Err(code) => {
1749 return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response()
1750 }
1751 };
1752
1753 let config_toml = {
1755 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1756 let session = session_arc.lock().await;
1757 match &session.config_toml {
1758 Some(t) => t.clone(),
1759 None => {
1760 return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response()
1761 }
1762 }
1763 };
1764
1765 let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1766 Ok(v) => v,
1767 Err(_) => return Json(serde_json::json!([])).into_response(),
1768 };
1769
1770 let agent_name = {
1771 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1772 let session = session_arc.lock().await;
1773 session.agent_name.clone()
1774 };
1775 let token_store = FileTokenStore::new(&agent_name);
1776
1777 let servers = mcp_config
1779 .get("mcp")
1780 .and_then(|m| m.get("servers"))
1781 .and_then(|s| s.as_array());
1782 let credentials = mcp_config
1783 .get("mcp")
1784 .and_then(|m| m.get("credentials"))
1785 .and_then(|c| c.as_table());
1786
1787 let mut cred_servers: std::collections::HashMap<String, Vec<String>> =
1788 std::collections::HashMap::new();
1789 if let Some(servers) = servers {
1790 for server in servers {
1791 let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1792 let cred_ref = server
1793 .get("credentials")
1794 .and_then(|c| c.as_str())
1795 .unwrap_or("");
1796 if !cred_ref.is_empty() {
1797 cred_servers
1798 .entry(cred_ref.to_string())
1799 .or_default()
1800 .push(name.to_string());
1801 }
1802 }
1803 }
1804
1805 let mut result = Vec::new();
1806
1807 if let Some(creds) = credentials {
1808 for (cred_name, cred_config) in creds {
1809 let cred_type = cred_config
1810 .get("type")
1811 .and_then(|t| t.as_str())
1812 .unwrap_or("unknown");
1813 let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1814
1815 if cred_type == "web-session" {
1816 let connected = state.auth.is_some();
1818 result.push(serde_json::json!({
1819 "credential": cred_name,
1820 "type": cred_type,
1821 "connected": connected,
1822 "servers": servers_using,
1823 }));
1824 } else if cred_type == "service-account" {
1825 let token = cred_config
1829 .get("service_token")
1830 .and_then(|t| t.as_str())
1831 .unwrap_or("");
1832 result.push(serde_json::json!({
1833 "credential": cred_name,
1834 "type": cred_type,
1835 "connected": !token.is_empty(),
1836 "servers": servers_using,
1837 }));
1838 } else if cred_type == "static" {
1839 let token = cred_config
1841 .get("token")
1842 .and_then(|t| t.as_str())
1843 .unwrap_or("");
1844 result.push(serde_json::json!({
1845 "credential": cred_name,
1846 "type": cred_type,
1847 "connected": !token.is_empty(),
1848 "servers": servers_using,
1849 }));
1850 } else if cred_type == "web-interactive" || cred_type == "interactive" {
1851 let status = match token_store.load(cred_name) {
1853 Ok(Some(token)) => {
1854 let expired = token.is_expired();
1855 serde_json::json!({
1856 "credential": cred_name,
1857 "type": cred_type,
1858 "connected": !expired,
1859 "expires_at": token.expires_at,
1860 "servers": servers_using,
1861 })
1862 }
1863 _ => serde_json::json!({
1864 "credential": cred_name,
1865 "type": cred_type,
1866 "connected": false,
1867 "servers": servers_using,
1868 }),
1869 };
1870 result.push(status);
1871 }
1872 }
1873 }
1874
1875 Json(serde_json::Value::Array(result)).into_response()
1876}
1877
1878async fn mcp_logout_handler(
1880 State(state): State<crate::ServerState>,
1881 Query(query): Query<McpLoginQuery>,
1882 headers: axum::http::HeaderMap,
1883) -> Response {
1884 use pep::{FileTokenStore, TokenStore};
1885
1886 let (_cookie, user_key) = match crate::auth::check_auth(
1888 &state.auth,
1889 &headers,
1890 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1891 )
1892 .await
1893 {
1894 Ok(result) => result,
1895 Err(code) => {
1896 return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response()
1897 }
1898 };
1899
1900 let agent_name = {
1901 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1902 let session = session_arc.lock().await;
1903 session.agent_name.clone()
1904 };
1905 let token_store = FileTokenStore::new(&agent_name);
1906
1907 match token_store.delete(&query.cred) {
1908 Ok(()) => {
1909 tracing::info!("Removed MCP credentials for '{}'", query.cred);
1910 Json(serde_json::json!({"success": true})).into_response()
1911 }
1912 Err(e) => {
1913 tracing::error!("Failed to remove MCP credentials: {}", e);
1914 (
1915 StatusCode::INTERNAL_SERVER_ERROR,
1916 Json(serde_json::json!({"error": e.to_string()})),
1917 )
1918 .into_response()
1919 }
1920 }
1921}
1922
1923struct McpPkceStore {
1930 entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1931}
1932
1933struct McpPkceEntry {
1934 verifier: String,
1935 cred_name: String,
1936 created_at: std::time::Instant,
1937}
1938
1939impl McpPkceStore {
1940 fn new() -> Self {
1941 Self {
1942 entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1943 }
1944 }
1945
1946 async fn insert(&self, state: String, verifier: String, cred_name: String) {
1948 let mut map = self.entries.lock().await;
1949 let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1951 map.retain(|_, v| v.created_at > cutoff);
1952 map.insert(
1953 state,
1954 McpPkceEntry {
1955 verifier,
1956 cred_name,
1957 created_at: std::time::Instant::now(),
1958 },
1959 );
1960 }
1961
1962 async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1964 let mut map = self.entries.lock().await;
1965 map.remove(state)
1966 }
1967}
1968
1969static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1971
1972fn mcp_pkce() -> &'static McpPkceStore {
1974 MCP_PKCE.get_or_init(McpPkceStore::new)
1975}
1976
1977enum McpCredentialInfo {
1979 WebInteractive {
1980 issuer_url: String,
1981 client_id: String,
1982 client_secret: Option<String>,
1983 scope: String,
1984 },
1985 Other(String),
1986}
1987
1988async fn load_mcp_credential(
1990 state: &crate::ServerState,
1991 cred_name: &str,
1992) -> Result<McpCredentialInfo, AuthError> {
1993 let config_toml = state
1994 .config_toml
1995 .clone()
1996 .ok_or(AuthError::AuthNotConfigured)?;
1997
1998 let config: toml::Value = toml::from_str(&config_toml)
1999 .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
2000
2001 let cred = config
2002 .get("mcp")
2003 .and_then(|m| m.get("credentials"))
2004 .and_then(|c| c.as_table())
2005 .and_then(|c| c.get(cred_name))
2006 .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
2007
2008 let cred_type = cred
2009 .get("type")
2010 .and_then(|t| t.as_str())
2011 .unwrap_or("unknown");
2012
2013 match cred_type {
2014 "web-interactive" => {
2015 let issuer_url = cred
2016 .get("issuer_url")
2017 .and_then(|v| v.as_str())
2018 .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
2019 .to_string();
2020 let client_id = cred
2021 .get("client_id")
2022 .and_then(|v| v.as_str())
2023 .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
2024 .to_string();
2025 let client_secret = cred
2026 .get("client_secret")
2027 .and_then(|v| v.as_str())
2028 .map(String::from);
2029 let scope = cred
2030 .get("scope")
2031 .and_then(|v| v.as_str())
2032 .unwrap_or("openid profile email")
2033 .to_string();
2034
2035 Ok(McpCredentialInfo::WebInteractive {
2036 issuer_url,
2037 client_id,
2038 client_secret,
2039 scope,
2040 })
2041 }
2042 other => Ok(McpCredentialInfo::Other(other.to_string())),
2043 }
2044}
2045
2046fn create_auth_cookie(
2052 name: &str,
2053 value: &str,
2054 max_age: StdDuration,
2055 secure: bool,
2056) -> Cookie<'static> {
2057 Cookie::build((name.to_string(), value.to_string()))
2058 .path("/")
2059 .http_only(true)
2060 .same_site(SameSite::Lax)
2061 .secure(secure)
2062 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
2063 .build()
2064}
2065
2066#[derive(Debug)]
2072pub enum AuthError {
2073 MissingCode,
2074 MissingState,
2075 InvalidState,
2076 OidcError(String),
2077 TokenExchangeFailed(String),
2078 AuthNotConfigured,
2079}
2080
2081impl IntoResponse for AuthError {
2082 fn into_response(self) -> Response {
2083 let (_status, msg) = match self {
2084 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
2085 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
2086 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
2087 AuthError::OidcError(_) => (
2088 StatusCode::SERVICE_UNAVAILABLE,
2089 "Authentication service error",
2090 ),
2091 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
2092 AuthError::AuthNotConfigured => {
2093 (StatusCode::NOT_IMPLEMENTED, "Authentication not configured")
2094 }
2095 };
2096 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
2097 }
2098}
2099
2100#[cfg(test)]
2101mod principal_tests {
2102 use super::*;
2103 use pep::oidc::types::JwtClaims;
2104 use std::collections::HashMap;
2105
2106 fn claims_with_role(role: serde_json::Value) -> JwtClaims {
2107 let mut c = JwtClaims::default();
2108 c.sub = "sub-uuid".to_string();
2109 c.preferred_username = Some("farzan".to_string());
2110 c.extra.insert("role".to_string(), role);
2111 c
2112 }
2113
2114 #[test]
2117 fn dev_agent_token_yields_agent_namespaced_key() {
2118 assert_eq!(
2119 dev_user_key("dev:agent:farzan"),
2120 Some("agent-farzan".to_string())
2121 );
2122 assert_eq!(
2123 dev_user_key("dev:agent:paydar"),
2124 Some("agent-paydar".to_string())
2125 );
2126 }
2127
2128 #[test]
2129 fn dev_agent_token_rejects_empty_and_colon_names() {
2130 assert_eq!(dev_user_key("dev:agent:"), None);
2131 assert_eq!(dev_user_key("dev:agent: "), None);
2132 assert_eq!(
2133 dev_user_key("dev:agent:a:b"),
2134 None,
2135 "name must not contain ':'"
2136 );
2137 }
2138
2139 #[test]
2140 fn dev_human_token_format_unchanged() {
2141 assert_eq!(
2142 dev_user_key("dev:a@b.c:Some Name:someuser"),
2143 Some("dev:a@b.c".to_string())
2144 );
2145 assert_eq!(dev_user_key("dev:only:two"), None);
2146 }
2147
2148 #[test]
2151 fn role_as_string_classifies_agent() {
2152 let c = claims_with_role(serde_json::json!("agent"));
2153 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
2154 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
2155 }
2156
2157 #[test]
2158 fn role_as_array_takes_first_value() {
2159 let c = claims_with_role(serde_json::json!(["agent", "other"]));
2161 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
2162 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
2163 }
2164
2165 #[test]
2166 fn non_agent_roles_classify_human() {
2167 for role in ["user", "admin", "service"] {
2168 let c = claims_with_role(serde_json::json!(role));
2169 assert_eq!(claim_role(&c).as_deref(), Some(role));
2170 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Human, "role={role}");
2171 }
2172 }
2173
2174 #[test]
2175 fn missing_or_nonstring_role_classifies_human() {
2176 let mut c = JwtClaims::default();
2177 c.sub = "sub-uuid".to_string();
2178 assert_eq!(claim_role(&c), None);
2179 assert_eq!(AuthUser::from(c.clone()).kind, PrincipalKind::Human);
2180 c.extra.insert("role".to_string(), serde_json::json!(42));
2181 assert_eq!(claim_role(&c), None, "non-string non-array role ignored");
2182 }
2183
2184 #[test]
2187 fn user_key_prefers_preferred_username() {
2188 let mut c = JwtClaims::default();
2189 c.sub = "sub-uuid".to_string();
2190 c.preferred_username = Some("farzan".to_string());
2191 c.email = Some("rebindable@example.com".to_string());
2192 assert_eq!(jwt_user_key(&c), "farzan", "email must never be the key");
2193 }
2194
2195 #[test]
2196 fn user_key_falls_back_to_sub_on_blank_username() {
2197 let mut c = JwtClaims::default();
2198 c.sub = "sub-uuid".to_string();
2199 c.preferred_username = Some(" ".to_string());
2200 assert_eq!(jwt_user_key(&c), "sub-uuid");
2201 c.preferred_username = None;
2202 assert_eq!(jwt_user_key(&c), "sub-uuid");
2203 }
2204
2205 #[test]
2206 fn user_key_agent_pinned_to_sub_even_with_username() {
2207 let mut c = claims_with_role(serde_json::json!("agent"));
2210 c.sub = "agent-sub-uuid".to_string();
2211 c.preferred_username = Some("farzan".to_string());
2212 assert_eq!(jwt_user_key(&c), "agent-sub-uuid");
2213 }
2214
2215 #[test]
2216 fn authuser_carries_role_and_kind() {
2217 let c = claims_with_role(serde_json::json!("agent"));
2218 let u = AuthUser::from(c);
2219 assert_eq!(u.role.as_deref(), Some("agent"));
2220 assert_eq!(u.kind, PrincipalKind::Agent);
2221 assert_eq!(u.username.as_deref(), Some("farzan"));
2222 }
2223}
2224
2225#[cfg(test)]
2226mod cedar_p2_tests {
2227 use super::*;
2228 use cedar_policy::{Context, Entities, EntityUid, Request};
2229 use pep::cedar::{CedarAuthorizer, CedarConfig};
2230 use std::collections::HashMap;
2231
2232 const POLICY: &str = include_str!("../policies/trustee_default.cedar");
2233 const SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
2234
2235 async fn authorizer() -> CedarAuthorizer {
2236 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2238 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2239 let dir = std::env::temp_dir().join(format!("trustee-cedar-p2-{}-{n}", std::process::id()));
2240 std::fs::create_dir_all(&dir).expect("temp dir");
2241 let policy_path = dir.join("trustee_default.cedar");
2242 let schema_path = dir.join("trustee_schema.cedarschema");
2243 std::fs::write(&policy_path, POLICY).expect("write policy");
2244 std::fs::write(&schema_path, SCHEMA).expect("write schema");
2245 let cfg = CedarConfig {
2246 policy_path,
2247 schema_path: Some(schema_path),
2248 entities_path: None,
2249 default_decision: pep::cedar::DefaultDecision::Deny,
2250 validate_on_load: true,
2251 policy_store_url: None,
2252 policy_store_token: None,
2253 embedded_policy: Some(POLICY),
2254 embedded_schema: Some(SCHEMA),
2255 };
2256 CedarAuthorizer::new_with_policy_store(cfg)
2257 .await
2258 .expect("Cedar init from shipped sources")
2259 }
2260
2261 fn claims_with_role(role: Option<&str>) -> JwtClaims {
2262 let mut c = JwtClaims::default();
2263 c.sub = "test-sub".to_string();
2264 if let Some(r) = role {
2265 c.extra.insert("role".to_string(), serde_json::json!(r));
2266 }
2267 c
2268 }
2269
2270 async fn allowed(auth: &CedarAuthorizer, role: Option<&str>, action: &str) -> bool {
2272 let claims = claims_with_role(role);
2273 let principal_entity = pep::cedar::build_principal_entity(&claims).unwrap();
2274 let app_entity = cedar_policy::Entity::new(
2275 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
2276 HashMap::new(),
2277 std::collections::HashSet::new(),
2278 )
2279 .unwrap();
2280 let entities = Entities::from_entities(vec![principal_entity, app_entity], None).unwrap();
2281 let request = Request::new(
2282 pep::cedar::build_principal_uid(&claims).unwrap(),
2283 EntityUid::from_str(&format!("Action::\"{action}\"")).unwrap(),
2284 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
2285 Context::empty(),
2286 None,
2287 )
2288 .unwrap();
2289 auth.is_allowed_with_entities(&request, &entities).allowed()
2290 }
2291
2292 #[tokio::test]
2293 async fn admin_allowed_including_destructive() {
2294 let auth = authorizer().await;
2295 for action in [
2296 actions::VIEW_SESSION,
2297 actions::COMMAND_SESSION,
2298 actions::DELETE_SESSION,
2299 actions::UPDATE_MCP_CREDENTIALS,
2300 ] {
2301 assert!(
2302 allowed(&auth, Some("admin"), action).await,
2303 "admin {action}"
2304 );
2305 }
2306 }
2307
2308 #[tokio::test]
2309 async fn user_full_session_management() {
2310 let auth = authorizer().await;
2311 for action in [
2312 actions::CREATE_SESSION,
2313 actions::COMMAND_SESSION,
2314 actions::DELETE_SESSION,
2315 actions::VIEW_HISTORY,
2316 actions::UPDATE_MCP_CREDENTIALS,
2317 ] {
2318 assert!(allowed(&auth, Some("user"), action).await, "user {action}");
2319 }
2320 }
2321
2322 #[tokio::test]
2323 async fn agent_working_set_including_delete() {
2324 let auth = authorizer().await;
2325 for action in [
2326 actions::CREATE_SESSION,
2327 actions::COMMAND_SESSION,
2328 actions::CANCEL_SESSION,
2329 actions::RESUME_SESSION,
2330 actions::VIEW_HISTORY,
2331 actions::UPDATE_MCP_CREDENTIALS,
2332 actions::DELETE_SESSION,
2337 ] {
2338 assert!(
2339 allowed(&auth, Some("agent"), action).await,
2340 "agent {action}"
2341 );
2342 }
2343 }
2344
2345 #[tokio::test]
2346 async fn service_read_only() {
2347 let auth = authorizer().await;
2348 for action in [
2349 actions::VIEW_SESSION,
2350 actions::LIST_SESSIONS,
2351 actions::VIEW_HISTORY,
2352 ] {
2353 assert!(
2354 allowed(&auth, Some("service"), action).await,
2355 "service {action}"
2356 );
2357 }
2358 for action in [actions::COMMAND_SESSION, actions::DELETE_SESSION] {
2359 assert!(
2360 !allowed(&auth, Some("service"), action).await,
2361 "service {action} denied"
2362 );
2363 }
2364 }
2365
2366 #[tokio::test]
2367 async fn missing_or_unknown_role_denied_everything() {
2368 let auth = authorizer().await;
2369 for role in [None, Some("intern"), Some("Admin")] {
2370 assert!(
2371 !allowed(&auth, role, actions::VIEW_SESSION).await,
2372 "role={role:?} must be denied (fail-closed default)"
2373 );
2374 }
2375 }
2376
2377 #[test]
2378 fn boot_decision_is_fail_closed() {
2379 assert!(crate::cedar_boot_decision(true, false, false).is_err());
2380 assert!(crate::cedar_boot_decision(true, false, true).is_ok());
2381 assert!(crate::cedar_boot_decision(true, true, false).is_ok());
2382 assert!(crate::cedar_boot_decision(false, false, false).is_ok());
2383 }
2384}
2385
2386#[cfg(test)]
2389mod validation_cache_tests {
2390 use super::*;
2391 use std::sync::atomic::{AtomicUsize, Ordering};
2392
2393 fn claims_with(exp_in: i64, sub: &str) -> JwtClaims {
2394 let mut c = JwtClaims::default();
2395 c.exp = unix_now() + exp_in;
2396 c.sub = sub.to_string();
2397 c.preferred_username = Some("farzan".to_string());
2398 c
2399 }
2400
2401 #[test]
2403 fn cache_hit_two_polls_one_validation() {
2404 let cache = ValidationCache::new();
2405 let validations = Arc::new(AtomicUsize::new(0));
2406 let key = validation_cache_key("token-A");
2407
2408 for _ in 0..2 {
2409 if cache.get(&key, unix_now()).is_some() {
2410 continue; }
2412 validations.fetch_add(1, Ordering::SeqCst);
2414 cache.put(key, claims_with(3600, "sub-A"), unix_now());
2415 }
2416
2417 assert_eq!(
2418 validations.load(Ordering::SeqCst),
2419 1,
2420 "second poll must hit the cache, not re-validate"
2421 );
2422 }
2423
2424 #[test]
2426 fn expired_entry_revalidates() {
2427 let cache = ValidationCache::new();
2428 let key = validation_cache_key("token-B");
2429 let now = unix_now();
2430 cache.put(key, claims_with(3600, "sub-B"), now);
2431
2432 assert!(cache.get(&key, now).is_some(), "fresh entry must hit");
2433 assert!(
2434 cache.get(&key, now + CACHE_TTL_SECS + 1).is_none(),
2435 "entry past valid_until must miss and force re-validation"
2436 );
2437 }
2438
2439 #[test]
2442 fn near_expiry_token_never_served_from_cache() {
2443 let cache = ValidationCache::new();
2444 let key = validation_cache_key("token-C");
2445 let now = unix_now();
2446 cache.put(key, claims_with(60, "sub-C"), now);
2448 assert!(
2449 cache.get(&key, now).is_none(),
2450 "entry valid_until <= now must be rejected immediately"
2451 );
2452 }
2453
2454 #[test]
2457 fn cap_evicts_instead_of_growing_unbounded() {
2458 let cache = ValidationCache::new();
2459 let now = unix_now();
2460 for i in 0..CACHE_MAX_ENTRIES {
2461 cache.put(
2462 validation_cache_key(&format!("tok-{i}")),
2463 claims_with(3600, "s"),
2464 now,
2465 );
2466 }
2467 let overflow = validation_cache_key("tok-overflow");
2469 cache.put(overflow, claims_with(3600, "s"), now);
2470
2471 let inner = cache.inner.lock().unwrap();
2472 assert!(
2473 inner.entries.len() <= CACHE_MAX_ENTRIES,
2474 "cache must stay bounded"
2475 );
2476 assert!(
2477 inner.entries.contains_key(&overflow),
2478 "newest entry must survive"
2479 );
2480 assert!(
2481 !inner.entries.contains_key(&validation_cache_key("tok-0")),
2482 "pre-overflow entries were reset, not served stale forever"
2483 );
2484 }
2485
2486 #[test]
2488 fn first_sight_fires_once_per_sub_issuer_pair() {
2489 let cache = ValidationCache::new();
2490 assert!(cache.mark_first_sight("sub-A", "https://idp.tanbal.ir"));
2491 assert!(!cache.mark_first_sight("sub-A", "https://idp.tanbal.ir"));
2492 assert!(
2493 cache.mark_first_sight("sub-A", "https://other.tanbal.ir"),
2494 "different issuer = first sight"
2495 );
2496 assert!(
2497 cache.mark_first_sight("sub-B", "https://idp.tanbal.ir"),
2498 "different sub = first sight"
2499 );
2500 }
2501
2502 #[test]
2504 fn distinct_tokens_distinct_keys() {
2505 assert_ne!(validation_cache_key("tok-1"), validation_cache_key("tok-2"));
2506 assert_eq!(
2508 validation_cache_key("secret"),
2509 validation_cache_key("secret")
2510 );
2511 }
2512}