1use std::sync::Arc;
16use std::time::Duration as StdDuration;
17
18use axum::{
19 body::Body,
20 extract::{Query, State},
21 http::{header, StatusCode},
22 response::{IntoResponse, Json, Redirect, Response},
23};
24use axum_extra::extract::cookie::{Cookie, SameSite};
25use pep::oidc_client::OidcClient;
26use pep::oidc_resource_server::ResourceServerClient;
27use pep::oidc::pkce_cookie::PkceCookieManager;
28use pep::session_manager::WebSessionManager;
29use pep::{DevConfig, JwtClaims, JwtValidationOptions, OidcClientConfig};
30use serde::Deserialize;
31use time::Duration as TimeDuration;
32
33use cedar_policy::{Context, Entities, EntityUid, Request};
34use std::str::FromStr;
35
36#[derive(Debug, Clone)]
42pub struct AuthConfig {
43 pub issuer_url: String,
45 pub client_id: String,
47 pub client_secret: Option<String>,
49 pub redirect_uri: String,
51 pub scope: String,
53 pub cookie_name: String,
55 pub dev_config: DevConfig,
57 pub validation_options: JwtValidationOptions,
59 pub pkce_cookie_secret: String,
61}
62
63impl AuthConfig {
64 pub fn from_toml(config_toml: &str) -> Option<Self> {
69 let table: toml::Table = toml::from_str(config_toml).ok()?;
70
71 let dev_config = table.get("dev").and_then(|d| d.as_table()).map(|d| {
73 DevConfig {
74 local_dev_mode: d.get("local_dev_mode").and_then(|v| v.as_bool()).unwrap_or(false),
75 local_dev_email: d.get("local_dev_email").and_then(|v| v.as_str()).map(String::from),
76 local_dev_name: d.get("local_dev_name").and_then(|v| v.as_str()).map(String::from),
77 local_dev_username: d.get("local_dev_username").and_then(|v| v.as_str()).map(String::from),
78 }
79 });
80
81 if let Some(ref dc) = dev_config {
83 if dc.local_dev_mode {
84 let oidc = Self::parse_oidc_section(&table);
86 return Some(Self {
87 issuer_url: oidc.as_ref().map(|o| o.0.clone()).unwrap_or_else(|| "https://auth.example.com".into()),
88 client_id: oidc.as_ref().map(|o| o.1.clone()).unwrap_or_else(|| "trustee".into()),
89 client_secret: oidc.as_ref().and_then(|o| o.2.clone()),
90 redirect_uri: oidc.as_ref().map(|o| o.3.clone()).unwrap_or_else(|| "http://localhost:3000/auth/callback".into()),
91 scope: oidc.as_ref().map(|o| o.4.clone()).unwrap_or_else(|| "openid profile email".into()),
92 cookie_name: "trustee_token".into(),
93 dev_config: dc.clone(),
94 validation_options: JwtValidationOptions::default(),
95 pkce_cookie_secret: oidc.as_ref().map(|o| o.6.clone()).unwrap_or_else(|| "trustee-default-pkce-secret-change-me".into()),
96 });
97 }
98 }
99
100 let (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret) =
102 Self::parse_oidc_section(&table)?;
103
104 Some(Self {
105 issuer_url,
106 client_id,
107 client_secret,
108 redirect_uri,
109 scope,
110 cookie_name: "trustee_token".into(),
111 dev_config: dev_config.unwrap_or_default(),
112 validation_options,
113 pkce_cookie_secret: pkce_secret,
114 })
115 }
116
117 fn parse_oidc_section(
120 table: &toml::Table,
121 ) -> Option<(String, String, Option<String>, String, String, JwtValidationOptions, String)> {
122 let oidc = table.get("oidc")?.as_table()?;
123
124 let issuer_url = oidc.get("issuer_url")?.as_str()?.to_string();
125 let client_id = oidc.get("client_id")?.as_str()?.to_string();
126 let client_secret = oidc.get("client_secret").and_then(|v| v.as_str()).map(String::from);
127 let redirect_uri = oidc
128 .get("redirect_uri")
129 .or_else(|| oidc.get("redirect_url")) .and_then(|v| v.as_str())
131 .unwrap_or("http://localhost:3000/auth/callback")
132 .to_string();
133 let scope = oidc
134 .get("scope")
135 .and_then(|v| v.as_str())
136 .unwrap_or("openid profile email")
137 .to_string();
138
139 let mut validation_options = JwtValidationOptions::default();
140 if let Some(skip) = oidc.get("skip_issuer_validation").and_then(|v| v.as_bool()) {
141 validation_options.skip_issuer_validation = skip;
142 }
143 if let Some(skip) = oidc.get("skip_audience_validation").and_then(|v| v.as_bool()) {
144 validation_options.skip_audience_validation = skip;
145 }
146 validation_options.expected_audience = oidc
147 .get("expected_audience")
148 .and_then(|v| v.as_str())
149 .map(String::from);
150
151 let pkce_secret = oidc
152 .get("pkce_cookie_secret")
153 .and_then(|v| v.as_str())
154 .unwrap_or("trustee-default-pkce-secret-change-me")
155 .to_string();
156
157 Some((issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret))
158 }
159
160 pub fn oidc_client_config(&self) -> OidcClientConfig {
162 OidcClientConfig {
163 issuer_url: self.issuer_url.clone(),
164 client_id: self.client_id.clone(),
165 client_secret: self.client_secret.clone(),
166 redirect_uri: self.redirect_uri.clone(),
167 scope: self.scope.clone(),
168 code_challenge_method: "S256".to_string(),
169 }
170 }
171}
172
173#[derive(Clone)]
175pub struct AuthState {
176 pub oidc_client: OidcClient,
178 pub resource_server: ResourceServerClient,
180 pub client_config: OidcClientConfig,
182 pub config: AuthConfig,
184 pub pkce_manager: PkceCookieManager,
186 pub session_manager: Arc<WebSessionManager>,
188 pub issuer_fallbacks: Vec<String>,
194 pub cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
196}
197
198impl AuthState {
199 pub fn new(config: AuthConfig) -> Self {
201 Self::with_cedar(config, None)
202 }
203
204 pub fn with_cedar(config: AuthConfig, cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>) -> Self {
206 let pkce_manager = PkceCookieManager::new(
207 config.pkce_cookie_secret.as_bytes(),
208 "trustee_pkce_state",
209 StdDuration::from_secs(600),
210 );
211
212 let session_manager = Arc::new(WebSessionManager::new(
213 OidcClient::new(),
214 config.issuer_url.clone(),
215 config.client_id.clone(),
216 config.client_secret.clone(),
217 config.scope.clone(),
218 ));
219
220 Self {
221 oidc_client: OidcClient::new(),
222 resource_server: ResourceServerClient::new(),
223 client_config: config.oidc_client_config(),
224 pkce_manager,
225 session_manager,
226 config,
227 cedar_authorizer,
228 issuer_fallbacks: Vec::new(),
229 }
230 }
231
232 pub fn with_issuer_fallbacks(mut self, issuers: Vec<String>) -> Self {
234 self.issuer_fallbacks = issuers;
235 self
236 }
237
238 pub fn is_dev_mode(&self) -> bool {
240 self.config.dev_config.local_dev_mode
241 }
242
243 pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
245 self.validate_token_on(&self.config.issuer_url, token).await
246 }
247
248 pub async fn validate_token_flexible(&self, token: &str) -> anyhow::Result<JwtClaims> {
255 match self.validate_token(token).await {
256 Ok(claims) => Ok(claims),
257 Err(primary) => {
258 let mut last = primary;
259 for si in &self.issuer_fallbacks {
260 if *si == self.config.issuer_url {
261 continue; }
263 match self.validate_token_on(si, token).await {
264 Ok(claims) => {
265 tracing::info!(
266 "token validated via service-issuer fallback (iss={}) — sub {}",
267 si,
268 claims.sub
269 );
270 return Ok(claims);
271 }
272 Err(e) => last = e,
273 }
274 }
275 Err(anyhow::anyhow!(
276 "primary: {last}; all service-issuer fallbacks exhausted"
277 ))
278 }
279 }
280 }
281
282 pub async fn validate_token_on(
285 &self,
286 issuer_url: &str,
287 token: &str,
288 ) -> anyhow::Result<JwtClaims> {
289 let mut claims = self
290 .resource_server
291 .validate_jwt_with_options(
292 token,
293 issuer_url,
294 &self.config.client_id,
295 &self.config.validation_options,
296 )
297 .await
298 .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
299
300 if let Err(e) = self
305 .resource_server
306 .enrich_claims_with_userinfo(&mut claims, token, issuer_url, None)
307 .await
308 {
309 tracing::error!(
310 "userinfo enrichment FAILED for sub {}: {} — principal carries NO role/groups; \
311 with Cedar enabled every request will be DENIED until enrichment succeeds",
312 claims.sub,
313 e
314 );
315 }
316
317 if claims.name.is_none() || claims.email.is_none() {
320 self.fill_userinfo_fields(&mut claims, token, issuer_url).await;
321 }
322
323 Ok(claims)
324 }
325
326 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str, issuer_url: &str) {
329 let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));
334
335 let client = reqwest::Client::new();
336 let resp = client
337 .get(&userinfo_url)
338 .header("Authorization", format!("Bearer {}", token))
339 .header("Accept", "application/json")
340 .send()
341 .await;
342
343 let Ok(resp) = resp else {
344 tracing::debug!("Userinfo request failed for name/email enrichment");
345 return;
346 };
347
348 if !resp.status().is_success() {
349 tracing::debug!("Userinfo returned {} for name/email enrichment", resp.status());
350 return;
351 }
352
353 let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await else {
354 return;
355 };
356
357 tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
358
359 if claims.name.is_none() {
360 if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
361 claims.name = Some(name.to_string());
362 }
363 }
364 if claims.email.is_none() {
365 if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
366 claims.email = Some(email.to_string());
367 }
368 }
369 if claims.preferred_username.is_none() {
370 if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
371 claims.preferred_username = Some(uname.to_string());
372 }
373 }
374 }
375
376 fn check_cedar_authorized(&self, claims: &JwtClaims, action: &str) -> Result<(), ()> {
381 let Some(ref authorizer) = self.cedar_authorizer else {
382 return Ok(()); };
384
385 let principal_entity = match pep::cedar::build_principal_entity(claims) {
387 Ok(e) => e,
388 Err(e) => {
389 tracing::error!("Cedar: failed to build principal entity: {}", e);
390 return Err(());
391 }
392 };
393
394 let mut entities_vec = vec![principal_entity];
396
397 let app_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
399 Ok(uid) => uid,
400 Err(e) => {
401 tracing::error!("Cedar: failed to build TrusteeApp uid: {}", e);
402 return Err(());
403 }
404 };
405 let app_entity = match cedar_policy::Entity::new(
406 app_uid,
407 std::collections::HashMap::new(),
408 std::collections::HashSet::new(),
409 ) {
410 Ok(e) => e,
411 Err(e) => {
412 tracing::error!("Cedar: failed to build TrusteeApp entity: {}", e);
413 return Err(());
414 }
415 };
416 entities_vec.push(app_entity);
417
418 let entities = match Entities::from_entities(entities_vec, None) {
419 Ok(e) => e,
420 Err(e) => {
421 tracing::error!("Cedar: failed to build entities set: {}", e);
422 return Err(());
423 }
424 };
425
426 let principal_uid = match pep::cedar::build_principal_uid(claims) {
428 Ok(uid) => uid,
429 Err(e) => {
430 tracing::error!("Cedar: failed to build principal uid: {}", e);
431 return Err(());
432 }
433 };
434
435 let action_uid = match EntityUid::from_str(&format!("Action::\"{action}\"")) {
436 Ok(uid) => uid,
437 Err(e) => {
438 tracing::error!("Cedar: failed to build action uid: {}", e);
439 return Err(());
440 }
441 };
442
443 let resource_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
444 Ok(uid) => uid,
445 Err(e) => {
446 tracing::error!("Cedar: failed to build resource uid: {}", e);
447 return Err(());
448 }
449 };
450
451 let request = match Request::new(principal_uid, action_uid, resource_uid, Context::empty(), None) {
452 Ok(r) => r,
453 Err(e) => {
454 tracing::error!("Cedar: failed to build request: {}", e);
455 return Err(());
456 }
457 };
458
459 let response = authorizer.is_allowed_with_entities(&request, &entities);
460
461 if response.allowed() {
462 tracing::debug!(
463 "Cedar: authorized user {} (sub={})",
464 claims.email.as_deref().unwrap_or("unknown"),
465 claims.sub
466 );
467 Ok(())
468 } else {
469 tracing::warn!(
470 "Cedar: DENIED user {} (sub={}) — matched policies: {:?}, errors: {:?}",
471 claims.email.as_deref().unwrap_or("unknown"),
472 claims.sub,
473 response.matched_policies(),
474 response.errors()
475 );
476 Err(())
477 }
478 }
479}
480
481pub mod actions {
492 pub const LIST_MODELS: &str = "ListModels";
493 pub const LIST_SESSIONS: &str = "ListSessions";
494 pub const VIEW_SESSION: &str = "ViewSession";
495 pub const VIEW_HISTORY: &str = "ViewHistory";
496 pub const CREATE_SESSION: &str = "CreateSession";
497 pub const COMMAND_SESSION: &str = "CommandSession";
498 pub const CANCEL_SESSION: &str = "CancelSession";
499 pub const HANDOFF_SESSION: &str = "HandoffSession";
500 pub const RESUME_SESSION: &str = "ResumeSession";
501 pub const UPDATE_SESSION: &str = "UpdateSession";
502 pub const DELETE_SESSION: &str = "DeleteSession";
503 pub const VIEW_MCP_CREDENTIALS: &str = "ViewMcpCredentials";
504 pub const UPDATE_MCP_CREDENTIALS: &str = "UpdateMcpCredentials";
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum PrincipalKind {
512 Human,
513 Agent,
514}
515
516impl PrincipalKind {
517 pub fn from_role(role: Option<&str>) -> Self {
521 match role {
522 Some("agent") => Self::Agent,
523 _ => Self::Human,
524 }
525 }
526}
527
528fn claim_role(claims: &JwtClaims) -> Option<String> {
534 match claims.extra.get("role") {
535 Some(serde_json::Value::String(s)) => Some(s.clone()),
536 Some(serde_json::Value::Array(arr)) => arr
537 .iter()
538 .filter_map(|v| v.as_str())
539 .next()
540 .map(|s| s.to_string()),
541 _ => None,
542 }
543}
544
545#[derive(Debug, Clone)]
547pub struct AuthUser {
548 pub sub: String,
549 pub email: Option<String>,
550 pub name: Option<String>,
551 pub username: Option<String>,
552 pub is_dev: bool,
553 pub role: Option<String>,
555 pub kind: PrincipalKind,
557}
558
559impl From<JwtClaims> for AuthUser {
560 fn from(claims: JwtClaims) -> Self {
561 let role = claim_role(&claims);
562 let kind = PrincipalKind::from_role(role.as_deref());
563 Self {
564 sub: claims.sub,
565 email: claims.email,
566 name: claims.name,
567 username: claims.preferred_username,
568 is_dev: false,
569 role,
570 kind,
571 }
572 }
573}
574
575const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
577
578fn jwt_user_key(claims: &JwtClaims) -> String {
595 if PrincipalKind::from_role(claim_role(claims).as_deref()) == PrincipalKind::Agent {
596 return claims.sub.clone();
597 }
598 match claims.preferred_username.as_deref() {
599 Some(u) if !u.trim().is_empty() => u.trim().to_string(),
600 _ => {
601 tracing::debug!(
602 "user_key: preferred_username missing/empty for sub {} — falling back to sub",
603 claims.sub
604 );
605 claims.sub.clone()
606 }
607 }
608}
609
610fn dev_user_key(token: &str) -> Option<String> {
618 if let Some(name) = token.strip_prefix("dev:agent:") {
619 let name = name.trim();
620 if name.is_empty() || name.contains(':') {
621 return None;
622 }
623 return Some(format!("agent-{name}"));
624 }
625 let parts: Vec<&str> = token.splitn(4, ':').collect();
626 if parts.len() >= 4 {
627 Some(format!("dev:{}", parts[1]))
628 } else {
629 None
630 }
631}
632
633pub(crate) fn dispatch_allowed(kind: PrincipalKind, role: Option<&str>) -> bool {
635 kind == PrincipalKind::Human && role == Some("admin")
636}
637
638pub async fn check_dispatch_admin(
650 auth: &Option<Arc<AuthState>>,
651 headers: &axum::http::HeaderMap,
652) -> Result<(), StatusCode> {
653 let Some(auth) = auth.as_ref() else {
654 return Ok(()); };
656 let Some(token) = headers
657 .get(header::AUTHORIZATION)
658 .and_then(|v| v.to_str().ok())
659 .and_then(|v| v.strip_prefix("Bearer "))
660 .map(|s| s.to_string())
661 else {
662 return Err(StatusCode::UNAUTHORIZED);
663 };
664 if token.starts_with("dev:") {
665 tracing::warn!("xagent dispatch rejected: dev tokens cannot dispatch agents");
666 return Err(StatusCode::FORBIDDEN);
667 }
668 let claims = auth
669 .validate_token_flexible(&token)
670 .await
671 .map_err(|e| {
672 tracing::warn!("xagent dispatch: caller token validation failed: {}", e);
673 StatusCode::UNAUTHORIZED
674 })?;
675 let user = AuthUser::from(claims);
676 if !dispatch_allowed(user.kind, user.role.as_deref()) {
677 tracing::warn!(
678 "xagent dispatch rejected: principal kind={:?} role={:?} — admin required",
679 user.kind,
680 user.role
681 );
682 return Err(StatusCode::FORBIDDEN);
683 }
684 Ok(())
685}
686
687impl AuthState {
688 pub async fn exchange_agent_token(
700 &self,
701 issuer_url: &str,
702 service_token: &str,
703 ) -> Result<(String, u64), StatusCode> {
704 let tr = self
705 .oidc_client
706 .exchange_token(
707 issuer_url,
708 &self.config.client_id,
709 None, service_token,
711 &self.config.client_id,
712 Some("openid groups"),
713 )
714 .await
715 .map_err(|e| {
716 tracing::error!(
717 "xagent dispatch: agent token exchange FAILED at {issuer_url}: {} — impersonation unavailable",
718 e
719 );
720 StatusCode::BAD_GATEWAY
721 })?;
722 Ok((tr.access_token, tr.expires_in.unwrap_or(900)))
723 }
724}
725
726pub async fn check_auth(
747 auth: &Option<Arc<AuthState>>,
748 headers: &axum::http::HeaderMap,
749 action: &str,
750) -> Result<(Option<String>, String), StatusCode> {
751 let Some(auth) = auth.as_ref() else {
752 return Ok((None, "default".to_string())); };
754
755 if let Some(token) = headers
757 .get(header::AUTHORIZATION)
758 .and_then(|v| v.to_str().ok())
759 .and_then(|v| v.strip_prefix("Bearer "))
760 .map(|s| s.to_string())
761 {
762 if token.starts_with("dev:") {
764 if !auth.config.dev_config.local_dev_mode {
765 tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
766 return Err(StatusCode::UNAUTHORIZED);
767 }
768 return match dev_user_key(&token) {
769 Some(key) => Ok((None, key)),
770 None => Err(StatusCode::UNAUTHORIZED),
771 };
772 }
773
774 return match auth.validate_token_flexible(&token).await {
775 Ok(claims) => {
776 if auth.check_cedar_authorized(&claims, action).is_err() {
777 return Err(StatusCode::FORBIDDEN);
778 }
779 Ok((None, jwt_user_key(&claims)))
780 }
781 Err(e) => {
782 tracing::warn!("Bearer token validation failed: {}", e);
783 Err(StatusCode::UNAUTHORIZED)
784 }
785 };
786 }
787
788 let session_id = headers
790 .get(header::COOKIE)
791 .and_then(|v| v.to_str().ok())
792 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
793
794 let Some(session_id) = session_id else {
795 tracing::warn!("No auth token found in request");
796 return Err(StatusCode::UNAUTHORIZED);
797 };
798
799 if session_id.starts_with("dev:") {
801 if !auth.config.dev_config.local_dev_mode {
802 tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
803 return Err(StatusCode::UNAUTHORIZED);
804 }
805 return match dev_user_key(&session_id) {
806 Some(key) => Ok((None, key)),
807 None => Err(StatusCode::UNAUTHORIZED),
808 };
809 }
810
811 match auth.session_manager.get_token(&session_id).await {
813 Ok(access_token) => match auth.validate_token(&access_token).await {
814 Ok(claims) => {
815 if auth.check_cedar_authorized(&claims, action).is_err() {
817 return Err(StatusCode::FORBIDDEN);
818 }
819 let secure = auth.client_config.redirect_uri.starts_with("https");
821 let cookie = create_auth_cookie(
822 &auth.config.cookie_name,
823 &session_id,
824 SESSION_COOKIE_MAX_AGE,
825 secure,
826 );
827 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
828 }
829 Err(e) => {
830 tracing::warn!("Session token validation failed: {} — attempting force-refresh", e);
833 match auth.session_manager.force_refresh(&session_id).await {
834 Ok(new_token) => match auth.validate_token(&new_token).await {
835 Ok(claims) => {
836 if auth.check_cedar_authorized(&claims, action).is_err() {
838 return Err(StatusCode::FORBIDDEN);
839 }
840 let secure = auth.client_config.redirect_uri.starts_with("https");
841 let cookie = create_auth_cookie(
842 &auth.config.cookie_name,
843 &session_id,
844 SESSION_COOKIE_MAX_AGE,
845 secure,
846 );
847 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
848 }
849 Err(e2) => {
850 tracing::warn!("Session token still invalid after force-refresh: {}", e2);
851 Err(StatusCode::UNAUTHORIZED)
852 }
853 },
854 Err(e2) => {
855 tracing::warn!("Force-refresh failed: {}", e2);
856 Err(StatusCode::UNAUTHORIZED)
857 }
858 }
859 }
860 },
861 Err(e) => {
862 tracing::warn!("Session lookup/refresh failed: {}", e);
863 Err(StatusCode::UNAUTHORIZED)
864 }
865 }
866}
867
868async fn resolve_access_token(
874 auth: &AuthState,
875 headers: &axum::http::HeaderMap,
876) -> Result<String, StatusCode> {
877 if let Some(token) = headers
879 .get(header::AUTHORIZATION)
880 .and_then(|v| v.to_str().ok())
881 .and_then(|v| v.strip_prefix("Bearer "))
882 .map(|s| s.to_string())
883 {
884 return Ok(token);
885 }
886
887 let session_id = headers
889 .get(header::COOKIE)
890 .and_then(|v| v.to_str().ok())
891 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
892
893 match session_id {
894 Some(sid) if sid.starts_with("dev:") => {
895 if !auth.config.dev_config.local_dev_mode {
896 tracing::warn!("Dev cookie in resolve_access_token but dev mode is disabled — rejecting");
897 Err(StatusCode::UNAUTHORIZED)
898 } else {
899 Ok(sid)
900 }
901 }
902 Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
903 tracing::warn!("Failed to resolve session token: {}", e);
904 StatusCode::UNAUTHORIZED
905 }),
906 None => Err(StatusCode::UNAUTHORIZED),
907 }
908}
909
910fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
912 for cookie in cookie_header.split(';') {
913 let cookie = cookie.trim();
914 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
915 return Some(value.to_string());
916 }
917 }
918 None
919}
920
921pub fn auth_routes() -> axum::Router<crate::ServerState> {
927 axum::Router::new()
928 .route("/login", axum::routing::get(login_handler))
929 .route("/callback", axum::routing::get(callback_handler))
930 .route("/me", axum::routing::get(me_handler))
931 .route("/logout", axum::routing::post(logout_handler))
932 .route("/mcp/login", axum::routing::get(mcp_login_handler))
933 .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
934 .route("/mcp/status", axum::routing::get(mcp_status_handler))
935 .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
936}
937
938#[derive(Debug, Deserialize)]
940pub struct CallbackQuery {
941 pub code: Option<String>,
942 pub state: Option<String>,
943 pub error: Option<String>,
944 pub error_description: Option<String>,
945}
946
947async fn login_handler(
949 State(state): State<crate::ServerState>,
950) -> Result<Response, AuthError> {
951 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
952
953 if auth.is_dev_mode() {
955 tracing::info!("Dev mode: creating dev session");
956 let dev = &auth.config.dev_config;
957 let dev_token = format!(
958 "dev:{}:{}:{}",
959 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
960 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
961 dev.local_dev_username.as_deref().unwrap_or("dev")
962 );
963 let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
964 return Ok(Response::builder()
965 .status(StatusCode::FOUND)
966 .header(header::LOCATION, "/")
967 .header(header::SET_COOKIE, cookie.to_string())
968 .body(Body::empty())
969 .unwrap());
970 }
971
972 let pkce_session = auth.pkce_manager.create();
974 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
975
976 let auth_url = auth
977 .oidc_client
978 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
979 .await
980 .map_err(|e| AuthError::OidcError(e.to_string()))?;
981
982 let secure = auth.client_config.redirect_uri.starts_with("https");
986 let pkce_cookie = Cookie::build((
987 auth.pkce_manager.cookie_name().to_string(),
988 pkce_session.cookie_value,
989 ))
990 .path("/")
991 .http_only(true)
992 .same_site(SameSite::Lax)
993 .secure(secure)
994 .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
995 .build();
996
997 Ok(Response::builder()
998 .status(StatusCode::TEMPORARY_REDIRECT)
999 .header(header::LOCATION, &auth_url)
1000 .header(header::SET_COOKIE, pkce_cookie.to_string())
1001 .body(Body::empty())
1002 .unwrap())
1003}
1004
1005async fn callback_handler(
1007 State(state): State<crate::ServerState>,
1008 Query(query): Query<CallbackQuery>,
1009 headers: axum::http::HeaderMap,
1010) -> Result<Response, AuthError> {
1011 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1012
1013 if let Some(error) = query.error {
1015 let desc = query.error_description.unwrap_or_default();
1016 tracing::error!("OIDC error: {} - {}", error, desc);
1017 return Ok(Redirect::temporary(&format!(
1018 "/?error={}&error_description={}",
1019 urlencoding::encode(&error),
1020 urlencoding::encode(&desc)
1021 ))
1022 .into_response());
1023 }
1024
1025 let code = query.code.ok_or(AuthError::MissingCode)?;
1026 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1027
1028 let cookie_header = headers
1030 .get(header::COOKIE)
1031 .and_then(|v| v.to_str().ok())
1032 .unwrap_or("");
1033 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
1034 .ok_or(AuthError::InvalidState)?;
1035
1036 let verifier = auth
1038 .pkce_manager
1039 .verify(&pkce_value, &oauth_state)
1040 .ok_or(AuthError::InvalidState)?;
1041
1042 tracing::info!("Exchanging authorization code for tokens");
1044 let token_response = auth
1045 .oidc_client
1046 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
1047 .await
1048 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1049
1050 let session_id = auth
1051 .session_manager
1052 .create_session(&token_response)
1053 .await
1054 .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
1055
1056 let max_age = SESSION_COOKIE_MAX_AGE;
1059
1060 let secure = auth.client_config.redirect_uri.starts_with("https");
1062 let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
1063
1064 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
1066 .path("/")
1067 .http_only(true)
1068 .same_site(SameSite::Lax)
1069 .max_age(TimeDuration::seconds(-1))
1070 .build();
1071
1072 tracing::info!("Authentication successful, redirecting to /");
1073
1074 Ok(Response::builder()
1075 .status(StatusCode::FOUND)
1076 .header(header::LOCATION, "/")
1077 .header(header::SET_COOKIE, cookie.to_string())
1078 .header(header::SET_COOKIE, clear_pkce.to_string())
1079 .body(Body::empty())
1080 .unwrap())
1081}
1082
1083async fn me_handler(
1085 State(state): State<crate::ServerState>,
1086 headers: axum::http::HeaderMap,
1087) -> Response {
1088 let Some(ref auth) = state.auth else {
1089 return axum::Json(serde_json::json!({
1091 "authenticated": true,
1092 "auth_enabled": false
1093 }))
1094 .into_response();
1095 };
1096
1097 let cookie_header = headers
1098 .get(header::COOKIE)
1099 .and_then(|v| v.to_str().ok())
1100 .unwrap_or("");
1101
1102 let bearer = headers
1104 .get(header::AUTHORIZATION)
1105 .and_then(|v| v.to_str().ok())
1106 .and_then(|v| v.strip_prefix("Bearer "))
1107 .map(String::from);
1108
1109 let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
1110
1111 let Some(cookie_value) = token else {
1112 return axum::Json(serde_json::json!({
1113 "authenticated": false,
1114 "auth_enabled": true
1115 }))
1116 .into_response();
1117 };
1118
1119 if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
1122 let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
1123 if parts.len() >= 4 {
1124 return axum::Json(serde_json::json!({
1125 "authenticated": true,
1126 "auth_enabled": true,
1127 "email": parts[1],
1128 "name": parts[2],
1129 "username": parts[3],
1130 "dev_mode": true
1131 }))
1132 .into_response();
1133 }
1134 }
1135
1136 let access_token = if bearer.is_some() {
1138 cookie_value
1140 } else {
1141 match auth.session_manager.get_token(&cookie_value).await {
1143 Ok(token) => token,
1144 Err(e) => {
1145 tracing::debug!("Session token resolution failed for /auth/me: {}", e);
1146 return axum::Json(serde_json::json!({
1147 "authenticated": false,
1148 "auth_enabled": true
1149 }))
1150 .into_response();
1151 }
1152 }
1153 };
1154
1155 match auth.validate_token(&access_token).await {
1157 Ok(claims) => axum::Json(serde_json::json!({
1158 "authenticated": true,
1159 "auth_enabled": true,
1160 "sub": claims.sub,
1161 "email": claims.email,
1162 "name": claims.name,
1163 "username": claims.preferred_username,
1164 "dev_mode": false
1165 }))
1166 .into_response(),
1167 Err(e) => {
1168 tracing::debug!("Token validation failed for /auth/me: {}", e);
1169 axum::Json(serde_json::json!({
1170 "authenticated": false,
1171 "auth_enabled": true
1172 }))
1173 .into_response()
1174 }
1175 }
1176}
1177
1178async fn logout_handler(
1180 State(state): State<crate::ServerState>,
1181 headers: axum::http::HeaderMap,
1182) -> Response {
1183 let cookie_name = state
1184 .auth
1185 .as_ref()
1186 .map(|a| a.config.cookie_name.as_str())
1187 .unwrap_or("trustee_token");
1188
1189 if let Some(ref auth) = state.auth {
1191 if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
1192 if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
1193 if !session_id.starts_with("dev:") {
1194 let _ = auth.session_manager.destroy_session(&session_id);
1195 }
1196 }
1197 }
1198 }
1199
1200 let cookie = Cookie::build((cookie_name.to_string(), ""))
1201 .path("/")
1202 .http_only(true)
1203 .same_site(SameSite::Lax)
1204 .max_age(TimeDuration::seconds(-1))
1205 .build();
1206
1207 Response::builder()
1208 .status(StatusCode::FOUND)
1209 .header(header::LOCATION, "/")
1210 .header(header::SET_COOKIE, cookie.to_string())
1211 .body(Body::empty())
1212 .unwrap()
1213}
1214
1215#[derive(Debug, Deserialize)]
1221pub struct McpLoginQuery {
1222 pub cred: String,
1223}
1224
1225#[derive(Debug, Deserialize)]
1227pub struct McpCallbackQuery {
1228 pub code: Option<String>,
1229 pub state: Option<String>,
1230 pub error: Option<String>,
1231 pub error_description: Option<String>,
1232}
1233
1234async fn mcp_login_handler(
1239 State(state): State<crate::ServerState>,
1240 Query(query): Query<McpLoginQuery>,
1241 headers: axum::http::HeaderMap,
1242) -> Result<Response, AuthError> {
1243 let (_cookie, _user_key) = crate::auth::check_auth(
1245 &state.auth,
1246 &headers,
1247 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1248 )
1249 .await
1250 .map_err(|_| AuthError::AuthNotConfigured)?;
1251
1252 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1253
1254 let cred_config = load_mcp_credential(&state, &query.cred).await?;
1256
1257 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1258 McpCredentialInfo::WebInteractive {
1259 issuer_url,
1260 client_id,
1261 client_secret,
1262 scope,
1263 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1264 _ => {
1265 return Ok(Redirect::temporary(&format!(
1266 "/?mcp_error={}",
1267 urlencoding::encode(&format!("Credential '{}' is not web-interactive type", query.cred))
1268 ))
1269 .into_response());
1270 }
1271 };
1272
1273 let oidc_client = OidcClient::new();
1275 let verifier = OidcClient::generate_code_verifier();
1276 let challenge = OidcClient::generate_code_challenge(&verifier);
1277 let oauth_state = OidcClient::generate_state();
1278
1279 let mcp_redirect_uri = format!(
1281 "{}/auth/mcp/callback",
1282 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1283 );
1284
1285 let mcp_client_config = OidcClientConfig {
1286 issuer_url: issuer_url.clone(),
1287 client_id: client_id.clone(),
1288 client_secret: client_secret.clone(),
1289 redirect_uri: mcp_redirect_uri.clone(),
1290 scope: scope.clone(),
1291 code_challenge_method: "S256".to_string(),
1292 };
1293
1294 let auth_url = oidc_client
1296 .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
1297 .await
1298 .map_err(|e| AuthError::OidcError(e.to_string()))?;
1299
1300 mcp_pkce().insert(oauth_state.clone(), verifier.clone(), query.cred.clone()).await;
1302
1303 tracing::info!(
1304 "Initiating MCP browser login for credential '{}' (issuer={})",
1305 query.cred, issuer_url
1306 );
1307
1308 Ok(Response::builder()
1309 .status(StatusCode::TEMPORARY_REDIRECT)
1310 .header(header::LOCATION, &auth_url)
1311 .body(Body::empty())
1312 .unwrap())
1313}
1314
1315async fn mcp_callback_handler(
1317 State(state): State<crate::ServerState>,
1318 Query(query): Query<McpCallbackQuery>,
1319 headers: axum::http::HeaderMap,
1320) -> Result<Response, AuthError> {
1321 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1322
1323 if let Some(error) = query.error {
1325 let desc = query.error_description.unwrap_or_default();
1326 tracing::error!("MCP OIDC error: {} - {}", error, desc);
1327 return Ok(Redirect::temporary(&format!(
1328 "/?mcp_error={}&error_description={}",
1329 urlencoding::encode(&error),
1330 urlencoding::encode(&desc)
1331 ))
1332 .into_response());
1333 }
1334
1335 let code = query.code.ok_or(AuthError::MissingCode)?;
1336 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1337
1338 let pkce_data = mcp_pkce().take(&oauth_state).await
1340 .ok_or(AuthError::InvalidState)?;
1341
1342 let verifier = pkce_data.verifier;
1343 let cred_name = &pkce_data.cred_name;
1344
1345 let cred_config = load_mcp_credential(&state, cred_name).await?;
1347
1348 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1349 McpCredentialInfo::WebInteractive {
1350 issuer_url,
1351 client_id,
1352 client_secret,
1353 scope,
1354 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1355 _ => {
1356 return Ok(Redirect::temporary(&format!(
1357 "/?mcp_error={}",
1358 urlencoding::encode("Credential is not web-interactive type")
1359 ))
1360 .into_response());
1361 }
1362 };
1363
1364 let mcp_redirect_uri = format!(
1366 "{}/auth/mcp/callback",
1367 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1368 );
1369
1370 let mcp_client_config = OidcClientConfig {
1371 issuer_url: issuer_url.clone(),
1372 client_id: client_id.clone(),
1373 client_secret: client_secret.clone(),
1374 redirect_uri: mcp_redirect_uri,
1375 scope: scope.clone(),
1376 code_challenge_method: "S256".to_string(),
1377 };
1378
1379 tracing::info!("Exchanging MCP authorization code for tokens (credential={})", cred_name);
1381 let oidc_client = OidcClient::new();
1382 let token_response = oidc_client
1383 .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
1384 .await
1385 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1386
1387 let expires_at = {
1389 let now = std::time::SystemTime::now()
1390 .duration_since(std::time::UNIX_EPOCH)
1391 .unwrap_or_default()
1392 .as_secs();
1393 let expires_epoch = now + token_response.expires_in.unwrap_or(900);
1394 let days = expires_epoch / 86400;
1395 let rem = expires_epoch % 86400;
1396 let h = rem / 3600;
1397 let m = (rem % 3600) / 60;
1398 let s = rem % 60;
1399 let z = days as i64 + 719468;
1400 let era = if z >= 0 { z } else { z - 146096 } / 146097;
1401 let doe = (z - era * 146097) as u64;
1402 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1403 let y = yoe as i64 + era * 400;
1404 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1405 let mp = (5 * doy + 2) / 153;
1406 let d = doy - (153 * mp + 2) / 5 + 1;
1407 let mon = if mp < 10 { mp + 3 } else { mp - 9 };
1408 let yr = if mon <= 2 { y + 1 } else { y };
1409 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
1410 };
1411
1412 use pep::{FileTokenStore, StoredToken, TokenStore};
1414
1415 let stored = StoredToken::new(
1416 &token_response.access_token,
1417 token_response.refresh_token.clone(),
1418 "Bearer",
1419 &expires_at,
1420 token_response.scope.clone(),
1421 );
1422
1423 let agent_name = state.config_toml.as_ref().and_then(|t| {
1424 toml::from_str::<toml::Value>(t).ok()
1425 .and_then(|v| v.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()).map(String::from))
1426 }).unwrap_or_else(|| "trustee".to_string());
1427 let token_store = FileTokenStore::new(&agent_name);
1428
1429 if let Err(e) = token_store.save(cred_name, &stored) {
1430 tracing::error!("Failed to store MCP token: {}", e);
1431 return Ok(Redirect::temporary(&format!(
1432 "/?mcp_error={}",
1433 urlencoding::encode(&format!("Failed to store token: {}", e))
1434 ))
1435 .into_response());
1436 }
1437
1438 tracing::info!(
1439 "MCP authentication successful for credential '{}' (expires {})",
1440 cred_name, expires_at
1441 );
1442
1443 Ok(Response::builder()
1444 .status(StatusCode::FOUND)
1445 .header(header::LOCATION, format!("/?mcp_connected={}", urlencoding::encode(cred_name)))
1446 .body(Body::empty())
1447 .unwrap())
1448}
1449
1450async fn mcp_status_handler(
1452 State(state): State<crate::ServerState>,
1453 headers: axum::http::HeaderMap,
1454) -> Response {
1455 use pep::{FileTokenStore, TokenStore};
1456
1457 let (_cookie, user_key) = match crate::auth::check_auth(
1459 &state.auth,
1460 &headers,
1461 crate::auth::actions::VIEW_MCP_CREDENTIALS,
1462 )
1463 .await
1464 {
1465 Ok(result) => result,
1466 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1467 };
1468
1469 let config_toml = {
1471 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1472 let session = session_arc.lock().await;
1473 match &session.config_toml {
1474 Some(t) => t.clone(),
1475 None => return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response(),
1476 }
1477 };
1478
1479 let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1480 Ok(v) => v,
1481 Err(_) => return Json(serde_json::json!([])).into_response(),
1482 };
1483
1484 let agent_name = {
1485 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1486 let session = session_arc.lock().await;
1487 session.agent_name.clone()
1488 };
1489 let token_store = FileTokenStore::new(&agent_name);
1490
1491 let servers = mcp_config
1493 .get("mcp")
1494 .and_then(|m| m.get("servers"))
1495 .and_then(|s| s.as_array());
1496 let credentials = mcp_config
1497 .get("mcp")
1498 .and_then(|m| m.get("credentials"))
1499 .and_then(|c| c.as_table());
1500
1501 let mut cred_servers: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
1502 if let Some(servers) = servers {
1503 for server in servers {
1504 let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1505 let cred_ref = server.get("credentials").and_then(|c| c.as_str()).unwrap_or("");
1506 if !cred_ref.is_empty() {
1507 cred_servers
1508 .entry(cred_ref.to_string())
1509 .or_default()
1510 .push(name.to_string());
1511 }
1512 }
1513 }
1514
1515 let mut result = Vec::new();
1516
1517 if let Some(creds) = credentials {
1518 for (cred_name, cred_config) in creds {
1519 let cred_type = cred_config.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1520 let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1521
1522 if cred_type == "web-session" {
1523 let connected = state.auth.is_some();
1525 result.push(serde_json::json!({
1526 "credential": cred_name,
1527 "type": cred_type,
1528 "connected": connected,
1529 "servers": servers_using,
1530 }));
1531 } else if cred_type == "service-account" {
1532 let token = cred_config
1536 .get("service_token")
1537 .and_then(|t| t.as_str())
1538 .unwrap_or("");
1539 result.push(serde_json::json!({
1540 "credential": cred_name,
1541 "type": cred_type,
1542 "connected": !token.is_empty(),
1543 "servers": servers_using,
1544 }));
1545 } else if cred_type == "static" {
1546 let token = cred_config
1548 .get("token")
1549 .and_then(|t| t.as_str())
1550 .unwrap_or("");
1551 result.push(serde_json::json!({
1552 "credential": cred_name,
1553 "type": cred_type,
1554 "connected": !token.is_empty(),
1555 "servers": servers_using,
1556 }));
1557 } else if cred_type == "web-interactive" || cred_type == "interactive" {
1558 let status = match token_store.load(cred_name) {
1560 Ok(Some(token)) => {
1561 let expired = token.is_expired();
1562 serde_json::json!({
1563 "credential": cred_name,
1564 "type": cred_type,
1565 "connected": !expired,
1566 "expires_at": token.expires_at,
1567 "servers": servers_using,
1568 })
1569 }
1570 _ => serde_json::json!({
1571 "credential": cred_name,
1572 "type": cred_type,
1573 "connected": false,
1574 "servers": servers_using,
1575 }),
1576 };
1577 result.push(status);
1578 }
1579 }
1580 }
1581
1582 Json(serde_json::Value::Array(result)).into_response()
1583}
1584
1585async fn mcp_logout_handler(
1587 State(state): State<crate::ServerState>,
1588 Query(query): Query<McpLoginQuery>,
1589 headers: axum::http::HeaderMap,
1590) -> Response {
1591 use pep::{FileTokenStore, TokenStore};
1592
1593 let (_cookie, user_key) = match crate::auth::check_auth(
1595 &state.auth,
1596 &headers,
1597 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1598 )
1599 .await
1600 {
1601 Ok(result) => result,
1602 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1603 };
1604
1605 let agent_name = {
1606 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1607 let session = session_arc.lock().await;
1608 session.agent_name.clone()
1609 };
1610 let token_store = FileTokenStore::new(&agent_name);
1611
1612 match token_store.delete(&query.cred) {
1613 Ok(()) => {
1614 tracing::info!("Removed MCP credentials for '{}'", query.cred);
1615 Json(serde_json::json!({"success": true})).into_response()
1616 }
1617 Err(e) => {
1618 tracing::error!("Failed to remove MCP credentials: {}", e);
1619 (
1620 StatusCode::INTERNAL_SERVER_ERROR,
1621 Json(serde_json::json!({"error": e.to_string()})),
1622 )
1623 .into_response()
1624 }
1625 }
1626}
1627
1628struct McpPkceStore {
1635 entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1636}
1637
1638struct McpPkceEntry {
1639 verifier: String,
1640 cred_name: String,
1641 created_at: std::time::Instant,
1642}
1643
1644impl McpPkceStore {
1645 fn new() -> Self {
1646 Self {
1647 entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1648 }
1649 }
1650
1651 async fn insert(&self, state: String, verifier: String, cred_name: String) {
1653 let mut map = self.entries.lock().await;
1654 let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1656 map.retain(|_, v| v.created_at > cutoff);
1657 map.insert(state, McpPkceEntry {
1658 verifier,
1659 cred_name,
1660 created_at: std::time::Instant::now(),
1661 });
1662 }
1663
1664 async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1666 let mut map = self.entries.lock().await;
1667 map.remove(state)
1668 }
1669}
1670
1671static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1673
1674fn mcp_pkce() -> &'static McpPkceStore {
1676 MCP_PKCE.get_or_init(McpPkceStore::new)
1677}
1678
1679enum McpCredentialInfo {
1681 WebInteractive {
1682 issuer_url: String,
1683 client_id: String,
1684 client_secret: Option<String>,
1685 scope: String,
1686 },
1687 Other(String),
1688}
1689
1690async fn load_mcp_credential(
1692 state: &crate::ServerState,
1693 cred_name: &str,
1694) -> Result<McpCredentialInfo, AuthError> {
1695 let config_toml = state
1696 .config_toml
1697 .clone()
1698 .ok_or(AuthError::AuthNotConfigured)?;
1699
1700 let config: toml::Value = toml::from_str(&config_toml)
1701 .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1702
1703 let cred = config
1704 .get("mcp")
1705 .and_then(|m| m.get("credentials"))
1706 .and_then(|c| c.as_table())
1707 .and_then(|c| c.get(cred_name))
1708 .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1709
1710 let cred_type = cred.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1711
1712 match cred_type {
1713 "web-interactive" => {
1714 let issuer_url = cred
1715 .get("issuer_url")
1716 .and_then(|v| v.as_str())
1717 .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
1718 .to_string();
1719 let client_id = cred
1720 .get("client_id")
1721 .and_then(|v| v.as_str())
1722 .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
1723 .to_string();
1724 let client_secret = cred
1725 .get("client_secret")
1726 .and_then(|v| v.as_str())
1727 .map(String::from);
1728 let scope = cred
1729 .get("scope")
1730 .and_then(|v| v.as_str())
1731 .unwrap_or("openid profile email")
1732 .to_string();
1733
1734 Ok(McpCredentialInfo::WebInteractive {
1735 issuer_url,
1736 client_id,
1737 client_secret,
1738 scope,
1739 })
1740 }
1741 other => Ok(McpCredentialInfo::Other(other.to_string())),
1742 }
1743}
1744
1745fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
1751 Cookie::build((name.to_string(), value.to_string()))
1752 .path("/")
1753 .http_only(true)
1754 .same_site(SameSite::Lax)
1755 .secure(secure)
1756 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
1757 .build()
1758}
1759
1760#[derive(Debug)]
1766pub enum AuthError {
1767 MissingCode,
1768 MissingState,
1769 InvalidState,
1770 OidcError(String),
1771 TokenExchangeFailed(String),
1772 AuthNotConfigured,
1773}
1774
1775impl IntoResponse for AuthError {
1776 fn into_response(self) -> Response {
1777 let (_status, msg) = match self {
1778 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
1779 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
1780 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
1781 AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
1782 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
1783 AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
1784 };
1785 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
1786 }
1787}
1788
1789#[cfg(test)]
1790mod principal_tests {
1791 use super::*;
1792 use pep::oidc::types::JwtClaims;
1793 use std::collections::HashMap;
1794
1795 fn claims_with_role(role: serde_json::Value) -> JwtClaims {
1796 let mut c = JwtClaims::default();
1797 c.sub = "sub-uuid".to_string();
1798 c.preferred_username = Some("farzan".to_string());
1799 c.extra.insert("role".to_string(), role);
1800 c
1801 }
1802
1803 #[test]
1806 fn dev_agent_token_yields_agent_namespaced_key() {
1807 assert_eq!(
1808 dev_user_key("dev:agent:farzan"),
1809 Some("agent-farzan".to_string())
1810 );
1811 assert_eq!(
1812 dev_user_key("dev:agent:paydar"),
1813 Some("agent-paydar".to_string())
1814 );
1815 }
1816
1817 #[test]
1818 fn dev_agent_token_rejects_empty_and_colon_names() {
1819 assert_eq!(dev_user_key("dev:agent:"), None);
1820 assert_eq!(dev_user_key("dev:agent: "), None);
1821 assert_eq!(
1822 dev_user_key("dev:agent:a:b"),
1823 None,
1824 "name must not contain ':'"
1825 );
1826 }
1827
1828 #[test]
1829 fn dev_human_token_format_unchanged() {
1830 assert_eq!(
1831 dev_user_key("dev:a@b.c:Some Name:someuser"),
1832 Some("dev:a@b.c".to_string())
1833 );
1834 assert_eq!(dev_user_key("dev:only:two"), None);
1835 }
1836
1837 #[test]
1840 fn role_as_string_classifies_agent() {
1841 let c = claims_with_role(serde_json::json!("agent"));
1842 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1843 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1844 }
1845
1846 #[test]
1847 fn role_as_array_takes_first_value() {
1848 let c = claims_with_role(serde_json::json!(["agent", "other"]));
1850 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1851 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1852 }
1853
1854 #[test]
1855 fn non_agent_roles_classify_human() {
1856 for role in ["user", "admin", "service"] {
1857 let c = claims_with_role(serde_json::json!(role));
1858 assert_eq!(claim_role(&c).as_deref(), Some(role));
1859 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Human, "role={role}");
1860 }
1861 }
1862
1863 #[test]
1864 fn missing_or_nonstring_role_classifies_human() {
1865 let mut c = JwtClaims::default();
1866 c.sub = "sub-uuid".to_string();
1867 assert_eq!(claim_role(&c), None);
1868 assert_eq!(AuthUser::from(c.clone()).kind, PrincipalKind::Human);
1869 c.extra.insert("role".to_string(), serde_json::json!(42));
1870 assert_eq!(claim_role(&c), None, "non-string non-array role ignored");
1871 }
1872
1873 #[test]
1876 fn user_key_prefers_preferred_username() {
1877 let mut c = JwtClaims::default();
1878 c.sub = "sub-uuid".to_string();
1879 c.preferred_username = Some("farzan".to_string());
1880 c.email = Some("rebindable@example.com".to_string());
1881 assert_eq!(jwt_user_key(&c), "farzan", "email must never be the key");
1882 }
1883
1884 #[test]
1885 fn user_key_falls_back_to_sub_on_blank_username() {
1886 let mut c = JwtClaims::default();
1887 c.sub = "sub-uuid".to_string();
1888 c.preferred_username = Some(" ".to_string());
1889 assert_eq!(jwt_user_key(&c), "sub-uuid");
1890 c.preferred_username = None;
1891 assert_eq!(jwt_user_key(&c), "sub-uuid");
1892 }
1893
1894 #[test]
1895 fn user_key_agent_pinned_to_sub_even_with_username() {
1896 let mut c = claims_with_role(serde_json::json!("agent"));
1899 c.sub = "agent-sub-uuid".to_string();
1900 c.preferred_username = Some("farzan".to_string());
1901 assert_eq!(jwt_user_key(&c), "agent-sub-uuid");
1902 }
1903
1904 #[test]
1905 fn authuser_carries_role_and_kind() {
1906 let c = claims_with_role(serde_json::json!("agent"));
1907 let u = AuthUser::from(c);
1908 assert_eq!(u.role.as_deref(), Some("agent"));
1909 assert_eq!(u.kind, PrincipalKind::Agent);
1910 assert_eq!(u.username.as_deref(), Some("farzan"));
1911 }
1912}
1913
1914#[cfg(test)]
1915mod cedar_p2_tests {
1916 use super::*;
1917 use cedar_policy::{Context, Entities, EntityUid, Request};
1918 use pep::cedar::{CedarAuthorizer, CedarConfig};
1919 use std::collections::HashMap;
1920
1921 const POLICY: &str = include_str!("../policies/trustee_default.cedar");
1922 const SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
1923
1924 async fn authorizer() -> CedarAuthorizer {
1925 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1927 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1928 let dir = std::env::temp_dir().join(format!("trustee-cedar-p2-{}-{n}", std::process::id()));
1929 std::fs::create_dir_all(&dir).expect("temp dir");
1930 let policy_path = dir.join("trustee_default.cedar");
1931 let schema_path = dir.join("trustee_schema.cedarschema");
1932 std::fs::write(&policy_path, POLICY).expect("write policy");
1933 std::fs::write(&schema_path, SCHEMA).expect("write schema");
1934 let cfg = CedarConfig {
1935 policy_path,
1936 schema_path: Some(schema_path),
1937 entities_path: None,
1938 default_decision: pep::cedar::DefaultDecision::Deny,
1939 validate_on_load: true,
1940 policy_store_url: None,
1941 policy_store_token: None,
1942 embedded_policy: Some(POLICY),
1943 embedded_schema: Some(SCHEMA),
1944 };
1945 CedarAuthorizer::new_with_policy_store(cfg)
1946 .await
1947 .expect("Cedar init from shipped sources")
1948 }
1949
1950 fn claims_with_role(role: Option<&str>) -> JwtClaims {
1951 let mut c = JwtClaims::default();
1952 c.sub = "test-sub".to_string();
1953 if let Some(r) = role {
1954 c.extra.insert("role".to_string(), serde_json::json!(r));
1955 }
1956 c
1957 }
1958
1959 async fn allowed(auth: &CedarAuthorizer, role: Option<&str>, action: &str) -> bool {
1961 let claims = claims_with_role(role);
1962 let principal_entity = pep::cedar::build_principal_entity(&claims).unwrap();
1963 let app_entity = cedar_policy::Entity::new(
1964 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1965 HashMap::new(),
1966 std::collections::HashSet::new(),
1967 )
1968 .unwrap();
1969 let entities = Entities::from_entities(vec![principal_entity, app_entity], None).unwrap();
1970 let request = Request::new(
1971 pep::cedar::build_principal_uid(&claims).unwrap(),
1972 EntityUid::from_str(&format!("Action::\"{action}\"")).unwrap(),
1973 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1974 Context::empty(),
1975 None,
1976 )
1977 .unwrap();
1978 auth.is_allowed_with_entities(&request, &entities).allowed()
1979 }
1980
1981 #[tokio::test]
1982 async fn admin_allowed_including_destructive() {
1983 let auth = authorizer().await;
1984 for action in [
1985 actions::VIEW_SESSION,
1986 actions::COMMAND_SESSION,
1987 actions::DELETE_SESSION,
1988 actions::UPDATE_MCP_CREDENTIALS,
1989 ] {
1990 assert!(
1991 allowed(&auth, Some("admin"), action).await,
1992 "admin {action}"
1993 );
1994 }
1995 }
1996
1997 #[tokio::test]
1998 async fn user_full_session_management() {
1999 let auth = authorizer().await;
2000 for action in [
2001 actions::CREATE_SESSION,
2002 actions::COMMAND_SESSION,
2003 actions::DELETE_SESSION,
2004 actions::VIEW_HISTORY,
2005 actions::UPDATE_MCP_CREDENTIALS,
2006 ] {
2007 assert!(allowed(&auth, Some("user"), action).await, "user {action}");
2008 }
2009 }
2010
2011 #[tokio::test]
2012 async fn agent_working_set_but_delete_denied() {
2013 let auth = authorizer().await;
2014 for action in [
2015 actions::CREATE_SESSION,
2016 actions::COMMAND_SESSION,
2017 actions::CANCEL_SESSION,
2018 actions::RESUME_SESSION,
2019 actions::VIEW_HISTORY,
2020 actions::UPDATE_MCP_CREDENTIALS,
2021 ] {
2022 assert!(
2023 allowed(&auth, Some("agent"), action).await,
2024 "agent {action}"
2025 );
2026 }
2027 assert!(
2028 !allowed(&auth, Some("agent"), actions::DELETE_SESSION).await,
2029 "agent must NOT delete sessions (fail-closed start; revisit at task F)"
2030 );
2031 }
2032
2033 #[tokio::test]
2034 async fn service_read_only() {
2035 let auth = authorizer().await;
2036 for action in [
2037 actions::VIEW_SESSION,
2038 actions::LIST_SESSIONS,
2039 actions::VIEW_HISTORY,
2040 ] {
2041 assert!(allowed(&auth, Some("service"), action).await, "service {action}");
2042 }
2043 for action in [actions::COMMAND_SESSION, actions::DELETE_SESSION] {
2044 assert!(
2045 !allowed(&auth, Some("service"), action).await,
2046 "service {action} denied"
2047 );
2048 }
2049 }
2050
2051 #[tokio::test]
2052 async fn missing_or_unknown_role_denied_everything() {
2053 let auth = authorizer().await;
2054 for role in [None, Some("intern"), Some("Admin")] {
2055 assert!(
2056 !allowed(&auth, role, actions::VIEW_SESSION).await,
2057 "role={role:?} must be denied (fail-closed default)"
2058 );
2059 }
2060 }
2061
2062 #[test]
2063 fn boot_decision_is_fail_closed() {
2064 assert!(crate::cedar_boot_decision(true, false, false).is_err());
2065 assert!(crate::cedar_boot_decision(true, false, true).is_ok());
2066 assert!(crate::cedar_boot_decision(true, true, false).is_ok());
2067 assert!(crate::cedar_boot_decision(false, false, false).is_ok());
2068 }
2069}
2070