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