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 cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
190}
191
192impl AuthState {
193 pub fn new(config: AuthConfig) -> Self {
195 Self::with_cedar(config, None)
196 }
197
198 pub fn with_cedar(config: AuthConfig, cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>) -> Self {
200 let pkce_manager = PkceCookieManager::new(
201 config.pkce_cookie_secret.as_bytes(),
202 "trustee_pkce_state",
203 StdDuration::from_secs(600),
204 );
205
206 let session_manager = Arc::new(WebSessionManager::new(
207 OidcClient::new(),
208 config.issuer_url.clone(),
209 config.client_id.clone(),
210 config.client_secret.clone(),
211 config.scope.clone(),
212 ));
213
214 Self {
215 oidc_client: OidcClient::new(),
216 resource_server: ResourceServerClient::new(),
217 client_config: config.oidc_client_config(),
218 pkce_manager,
219 session_manager,
220 config,
221 cedar_authorizer,
222 }
223 }
224
225 pub fn is_dev_mode(&self) -> bool {
227 self.config.dev_config.local_dev_mode
228 }
229
230 pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
232 let mut claims = self
233 .resource_server
234 .validate_jwt_with_options(
235 token,
236 &self.config.issuer_url,
237 &self.config.client_id,
238 &self.config.validation_options,
239 )
240 .await
241 .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
242
243 if let Err(e) = self
248 .resource_server
249 .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
250 .await
251 {
252 tracing::error!(
253 "userinfo enrichment FAILED for sub {}: {} — principal carries NO role/groups; \
254 with Cedar enabled every request will be DENIED until enrichment succeeds",
255 claims.sub,
256 e
257 );
258 }
259
260 if claims.name.is_none() || claims.email.is_none() {
263 self.fill_userinfo_fields(&mut claims, token).await;
264 }
265
266 Ok(claims)
267 }
268
269 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
272 let userinfo_url = format!("{}/userinfo", self.config.issuer_url.trim_end_matches('/'));
276
277 let client = reqwest::Client::new();
278 let resp = client
279 .get(&userinfo_url)
280 .header("Authorization", format!("Bearer {}", token))
281 .header("Accept", "application/json")
282 .send()
283 .await;
284
285 let Ok(resp) = resp else {
286 tracing::debug!("Userinfo request failed for name/email enrichment");
287 return;
288 };
289
290 if !resp.status().is_success() {
291 tracing::debug!("Userinfo returned {} for name/email enrichment", resp.status());
292 return;
293 }
294
295 let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await else {
296 return;
297 };
298
299 tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
300
301 if claims.name.is_none() {
302 if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
303 claims.name = Some(name.to_string());
304 }
305 }
306 if claims.email.is_none() {
307 if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
308 claims.email = Some(email.to_string());
309 }
310 }
311 if claims.preferred_username.is_none() {
312 if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
313 claims.preferred_username = Some(uname.to_string());
314 }
315 }
316 }
317
318 fn check_cedar_authorized(&self, claims: &JwtClaims, action: &str) -> Result<(), ()> {
323 let Some(ref authorizer) = self.cedar_authorizer else {
324 return Ok(()); };
326
327 let principal_entity = match pep::cedar::build_principal_entity(claims) {
329 Ok(e) => e,
330 Err(e) => {
331 tracing::error!("Cedar: failed to build principal entity: {}", e);
332 return Err(());
333 }
334 };
335
336 let mut entities_vec = vec![principal_entity];
338
339 let app_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
341 Ok(uid) => uid,
342 Err(e) => {
343 tracing::error!("Cedar: failed to build TrusteeApp uid: {}", e);
344 return Err(());
345 }
346 };
347 let app_entity = match cedar_policy::Entity::new(
348 app_uid,
349 std::collections::HashMap::new(),
350 std::collections::HashSet::new(),
351 ) {
352 Ok(e) => e,
353 Err(e) => {
354 tracing::error!("Cedar: failed to build TrusteeApp entity: {}", e);
355 return Err(());
356 }
357 };
358 entities_vec.push(app_entity);
359
360 let entities = match Entities::from_entities(entities_vec, None) {
361 Ok(e) => e,
362 Err(e) => {
363 tracing::error!("Cedar: failed to build entities set: {}", e);
364 return Err(());
365 }
366 };
367
368 let principal_uid = match pep::cedar::build_principal_uid(claims) {
370 Ok(uid) => uid,
371 Err(e) => {
372 tracing::error!("Cedar: failed to build principal uid: {}", e);
373 return Err(());
374 }
375 };
376
377 let action_uid = match EntityUid::from_str(&format!("Action::\"{action}\"")) {
378 Ok(uid) => uid,
379 Err(e) => {
380 tracing::error!("Cedar: failed to build action uid: {}", e);
381 return Err(());
382 }
383 };
384
385 let resource_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
386 Ok(uid) => uid,
387 Err(e) => {
388 tracing::error!("Cedar: failed to build resource uid: {}", e);
389 return Err(());
390 }
391 };
392
393 let request = match Request::new(principal_uid, action_uid, resource_uid, Context::empty(), None) {
394 Ok(r) => r,
395 Err(e) => {
396 tracing::error!("Cedar: failed to build request: {}", e);
397 return Err(());
398 }
399 };
400
401 let response = authorizer.is_allowed_with_entities(&request, &entities);
402
403 if response.allowed() {
404 tracing::debug!(
405 "Cedar: authorized user {} (sub={})",
406 claims.email.as_deref().unwrap_or("unknown"),
407 claims.sub
408 );
409 Ok(())
410 } else {
411 tracing::warn!(
412 "Cedar: DENIED user {} (sub={}) — matched policies: {:?}, errors: {:?}",
413 claims.email.as_deref().unwrap_or("unknown"),
414 claims.sub,
415 response.matched_policies(),
416 response.errors()
417 );
418 Err(())
419 }
420 }
421}
422
423pub mod actions {
434 pub const LIST_MODELS: &str = "ListModels";
435 pub const LIST_SESSIONS: &str = "ListSessions";
436 pub const VIEW_SESSION: &str = "ViewSession";
437 pub const VIEW_HISTORY: &str = "ViewHistory";
438 pub const CREATE_SESSION: &str = "CreateSession";
439 pub const COMMAND_SESSION: &str = "CommandSession";
440 pub const CANCEL_SESSION: &str = "CancelSession";
441 pub const HANDOFF_SESSION: &str = "HandoffSession";
442 pub const RESUME_SESSION: &str = "ResumeSession";
443 pub const UPDATE_SESSION: &str = "UpdateSession";
444 pub const DELETE_SESSION: &str = "DeleteSession";
445 pub const VIEW_MCP_CREDENTIALS: &str = "ViewMcpCredentials";
446 pub const UPDATE_MCP_CREDENTIALS: &str = "UpdateMcpCredentials";
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum PrincipalKind {
454 Human,
455 Agent,
456}
457
458impl PrincipalKind {
459 pub fn from_role(role: Option<&str>) -> Self {
463 match role {
464 Some("agent") => Self::Agent,
465 _ => Self::Human,
466 }
467 }
468}
469
470fn claim_role(claims: &JwtClaims) -> Option<String> {
476 match claims.extra.get("role") {
477 Some(serde_json::Value::String(s)) => Some(s.clone()),
478 Some(serde_json::Value::Array(arr)) => arr
479 .iter()
480 .filter_map(|v| v.as_str())
481 .next()
482 .map(|s| s.to_string()),
483 _ => None,
484 }
485}
486
487#[derive(Debug, Clone)]
489pub struct AuthUser {
490 pub sub: String,
491 pub email: Option<String>,
492 pub name: Option<String>,
493 pub username: Option<String>,
494 pub is_dev: bool,
495 pub role: Option<String>,
497 pub kind: PrincipalKind,
499}
500
501impl From<JwtClaims> for AuthUser {
502 fn from(claims: JwtClaims) -> Self {
503 let role = claim_role(&claims);
504 let kind = PrincipalKind::from_role(role.as_deref());
505 Self {
506 sub: claims.sub,
507 email: claims.email,
508 name: claims.name,
509 username: claims.preferred_username,
510 is_dev: false,
511 role,
512 kind,
513 }
514 }
515}
516
517const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
519
520fn jwt_user_key(claims: &JwtClaims) -> String {
537 if PrincipalKind::from_role(claim_role(claims).as_deref()) == PrincipalKind::Agent {
538 return claims.sub.clone();
539 }
540 match claims.preferred_username.as_deref() {
541 Some(u) if !u.trim().is_empty() => u.trim().to_string(),
542 _ => {
543 tracing::debug!(
544 "user_key: preferred_username missing/empty for sub {} — falling back to sub",
545 claims.sub
546 );
547 claims.sub.clone()
548 }
549 }
550}
551
552fn dev_user_key(token: &str) -> Option<String> {
560 if let Some(name) = token.strip_prefix("dev:agent:") {
561 let name = name.trim();
562 if name.is_empty() || name.contains(':') {
563 return None;
564 }
565 return Some(format!("agent-{name}"));
566 }
567 let parts: Vec<&str> = token.splitn(4, ':').collect();
568 if parts.len() >= 4 {
569 Some(format!("dev:{}", parts[1]))
570 } else {
571 None
572 }
573}
574
575pub(crate) fn dispatch_allowed(kind: PrincipalKind, role: Option<&str>) -> bool {
577 kind == PrincipalKind::Human && role == Some("admin")
578}
579
580pub async fn check_dispatch_admin(
592 auth: &Option<Arc<AuthState>>,
593 headers: &axum::http::HeaderMap,
594) -> Result<(), StatusCode> {
595 let Some(auth) = auth.as_ref() else {
596 return Ok(()); };
598 let Some(token) = headers
599 .get(header::AUTHORIZATION)
600 .and_then(|v| v.to_str().ok())
601 .and_then(|v| v.strip_prefix("Bearer "))
602 .map(|s| s.to_string())
603 else {
604 return Err(StatusCode::UNAUTHORIZED);
605 };
606 if token.starts_with("dev:") {
607 tracing::warn!("xagent dispatch rejected: dev tokens cannot dispatch agents");
608 return Err(StatusCode::FORBIDDEN);
609 }
610 let claims = auth
611 .validate_token(&token)
612 .await
613 .map_err(|e| {
614 tracing::warn!("xagent dispatch: caller token validation failed: {}", e);
615 StatusCode::UNAUTHORIZED
616 })?;
617 let user = AuthUser::from(claims);
618 if !dispatch_allowed(user.kind, user.role.as_deref()) {
619 tracing::warn!(
620 "xagent dispatch rejected: principal kind={:?} role={:?} — admin required",
621 user.kind,
622 user.role
623 );
624 return Err(StatusCode::FORBIDDEN);
625 }
626 Ok(())
627}
628
629impl AuthState {
630 pub async fn exchange_agent_token(
642 &self,
643 issuer_url: &str,
644 service_token: &str,
645 ) -> Result<(String, u64), StatusCode> {
646 let tr = self
647 .oidc_client
648 .exchange_token(
649 issuer_url,
650 &self.config.client_id,
651 None, service_token,
653 &self.config.client_id,
654 Some("openid groups"),
655 )
656 .await
657 .map_err(|e| {
658 tracing::error!(
659 "xagent dispatch: agent token exchange FAILED at {issuer_url}: {} — impersonation unavailable",
660 e
661 );
662 StatusCode::BAD_GATEWAY
663 })?;
664 Ok((tr.access_token, tr.expires_in.unwrap_or(900)))
665 }
666}
667
668pub async fn check_auth(
689 auth: &Option<Arc<AuthState>>,
690 headers: &axum::http::HeaderMap,
691 action: &str,
692) -> Result<(Option<String>, String), StatusCode> {
693 let Some(auth) = auth.as_ref() else {
694 return Ok((None, "default".to_string())); };
696
697 if let Some(token) = headers
699 .get(header::AUTHORIZATION)
700 .and_then(|v| v.to_str().ok())
701 .and_then(|v| v.strip_prefix("Bearer "))
702 .map(|s| s.to_string())
703 {
704 if token.starts_with("dev:") {
706 if !auth.config.dev_config.local_dev_mode {
707 tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
708 return Err(StatusCode::UNAUTHORIZED);
709 }
710 return match dev_user_key(&token) {
711 Some(key) => Ok((None, key)),
712 None => Err(StatusCode::UNAUTHORIZED),
713 };
714 }
715
716 return match auth.validate_token(&token).await {
717 Ok(claims) => {
718 if auth.check_cedar_authorized(&claims, action).is_err() {
719 return Err(StatusCode::FORBIDDEN);
720 }
721 Ok((None, jwt_user_key(&claims)))
722 }
723 Err(e) => {
724 tracing::warn!("Bearer token validation failed: {}", e);
725 Err(StatusCode::UNAUTHORIZED)
726 }
727 };
728 }
729
730 let session_id = headers
732 .get(header::COOKIE)
733 .and_then(|v| v.to_str().ok())
734 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
735
736 let Some(session_id) = session_id else {
737 tracing::warn!("No auth token found in request");
738 return Err(StatusCode::UNAUTHORIZED);
739 };
740
741 if session_id.starts_with("dev:") {
743 if !auth.config.dev_config.local_dev_mode {
744 tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
745 return Err(StatusCode::UNAUTHORIZED);
746 }
747 return match dev_user_key(&session_id) {
748 Some(key) => Ok((None, key)),
749 None => Err(StatusCode::UNAUTHORIZED),
750 };
751 }
752
753 match auth.session_manager.get_token(&session_id).await {
755 Ok(access_token) => match auth.validate_token(&access_token).await {
756 Ok(claims) => {
757 if auth.check_cedar_authorized(&claims, action).is_err() {
759 return Err(StatusCode::FORBIDDEN);
760 }
761 let secure = auth.client_config.redirect_uri.starts_with("https");
763 let cookie = create_auth_cookie(
764 &auth.config.cookie_name,
765 &session_id,
766 SESSION_COOKIE_MAX_AGE,
767 secure,
768 );
769 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
770 }
771 Err(e) => {
772 tracing::warn!("Session token validation failed: {} — attempting force-refresh", e);
775 match auth.session_manager.force_refresh(&session_id).await {
776 Ok(new_token) => match auth.validate_token(&new_token).await {
777 Ok(claims) => {
778 if auth.check_cedar_authorized(&claims, action).is_err() {
780 return Err(StatusCode::FORBIDDEN);
781 }
782 let secure = auth.client_config.redirect_uri.starts_with("https");
783 let cookie = create_auth_cookie(
784 &auth.config.cookie_name,
785 &session_id,
786 SESSION_COOKIE_MAX_AGE,
787 secure,
788 );
789 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
790 }
791 Err(e2) => {
792 tracing::warn!("Session token still invalid after force-refresh: {}", e2);
793 Err(StatusCode::UNAUTHORIZED)
794 }
795 },
796 Err(e2) => {
797 tracing::warn!("Force-refresh failed: {}", e2);
798 Err(StatusCode::UNAUTHORIZED)
799 }
800 }
801 }
802 },
803 Err(e) => {
804 tracing::warn!("Session lookup/refresh failed: {}", e);
805 Err(StatusCode::UNAUTHORIZED)
806 }
807 }
808}
809
810async fn resolve_access_token(
816 auth: &AuthState,
817 headers: &axum::http::HeaderMap,
818) -> Result<String, StatusCode> {
819 if let Some(token) = headers
821 .get(header::AUTHORIZATION)
822 .and_then(|v| v.to_str().ok())
823 .and_then(|v| v.strip_prefix("Bearer "))
824 .map(|s| s.to_string())
825 {
826 return Ok(token);
827 }
828
829 let session_id = headers
831 .get(header::COOKIE)
832 .and_then(|v| v.to_str().ok())
833 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
834
835 match session_id {
836 Some(sid) if sid.starts_with("dev:") => {
837 if !auth.config.dev_config.local_dev_mode {
838 tracing::warn!("Dev cookie in resolve_access_token but dev mode is disabled — rejecting");
839 Err(StatusCode::UNAUTHORIZED)
840 } else {
841 Ok(sid)
842 }
843 }
844 Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
845 tracing::warn!("Failed to resolve session token: {}", e);
846 StatusCode::UNAUTHORIZED
847 }),
848 None => Err(StatusCode::UNAUTHORIZED),
849 }
850}
851
852fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
854 for cookie in cookie_header.split(';') {
855 let cookie = cookie.trim();
856 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
857 return Some(value.to_string());
858 }
859 }
860 None
861}
862
863pub fn auth_routes() -> axum::Router<crate::ServerState> {
869 axum::Router::new()
870 .route("/login", axum::routing::get(login_handler))
871 .route("/callback", axum::routing::get(callback_handler))
872 .route("/me", axum::routing::get(me_handler))
873 .route("/logout", axum::routing::post(logout_handler))
874 .route("/mcp/login", axum::routing::get(mcp_login_handler))
875 .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
876 .route("/mcp/status", axum::routing::get(mcp_status_handler))
877 .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
878}
879
880#[derive(Debug, Deserialize)]
882pub struct CallbackQuery {
883 pub code: Option<String>,
884 pub state: Option<String>,
885 pub error: Option<String>,
886 pub error_description: Option<String>,
887}
888
889async fn login_handler(
891 State(state): State<crate::ServerState>,
892) -> Result<Response, AuthError> {
893 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
894
895 if auth.is_dev_mode() {
897 tracing::info!("Dev mode: creating dev session");
898 let dev = &auth.config.dev_config;
899 let dev_token = format!(
900 "dev:{}:{}:{}",
901 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
902 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
903 dev.local_dev_username.as_deref().unwrap_or("dev")
904 );
905 let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
906 return Ok(Response::builder()
907 .status(StatusCode::FOUND)
908 .header(header::LOCATION, "/")
909 .header(header::SET_COOKIE, cookie.to_string())
910 .body(Body::empty())
911 .unwrap());
912 }
913
914 let pkce_session = auth.pkce_manager.create();
916 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
917
918 let auth_url = auth
919 .oidc_client
920 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
921 .await
922 .map_err(|e| AuthError::OidcError(e.to_string()))?;
923
924 let secure = auth.client_config.redirect_uri.starts_with("https");
928 let pkce_cookie = Cookie::build((
929 auth.pkce_manager.cookie_name().to_string(),
930 pkce_session.cookie_value,
931 ))
932 .path("/")
933 .http_only(true)
934 .same_site(SameSite::Lax)
935 .secure(secure)
936 .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
937 .build();
938
939 Ok(Response::builder()
940 .status(StatusCode::TEMPORARY_REDIRECT)
941 .header(header::LOCATION, &auth_url)
942 .header(header::SET_COOKIE, pkce_cookie.to_string())
943 .body(Body::empty())
944 .unwrap())
945}
946
947async fn callback_handler(
949 State(state): State<crate::ServerState>,
950 Query(query): Query<CallbackQuery>,
951 headers: axum::http::HeaderMap,
952) -> Result<Response, AuthError> {
953 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
954
955 if let Some(error) = query.error {
957 let desc = query.error_description.unwrap_or_default();
958 tracing::error!("OIDC error: {} - {}", error, desc);
959 return Ok(Redirect::temporary(&format!(
960 "/?error={}&error_description={}",
961 urlencoding::encode(&error),
962 urlencoding::encode(&desc)
963 ))
964 .into_response());
965 }
966
967 let code = query.code.ok_or(AuthError::MissingCode)?;
968 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
969
970 let cookie_header = headers
972 .get(header::COOKIE)
973 .and_then(|v| v.to_str().ok())
974 .unwrap_or("");
975 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
976 .ok_or(AuthError::InvalidState)?;
977
978 let verifier = auth
980 .pkce_manager
981 .verify(&pkce_value, &oauth_state)
982 .ok_or(AuthError::InvalidState)?;
983
984 tracing::info!("Exchanging authorization code for tokens");
986 let token_response = auth
987 .oidc_client
988 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
989 .await
990 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
991
992 let session_id = auth
993 .session_manager
994 .create_session(&token_response)
995 .await
996 .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
997
998 let max_age = SESSION_COOKIE_MAX_AGE;
1001
1002 let secure = auth.client_config.redirect_uri.starts_with("https");
1004 let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
1005
1006 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
1008 .path("/")
1009 .http_only(true)
1010 .same_site(SameSite::Lax)
1011 .max_age(TimeDuration::seconds(-1))
1012 .build();
1013
1014 tracing::info!("Authentication successful, redirecting to /");
1015
1016 Ok(Response::builder()
1017 .status(StatusCode::FOUND)
1018 .header(header::LOCATION, "/")
1019 .header(header::SET_COOKIE, cookie.to_string())
1020 .header(header::SET_COOKIE, clear_pkce.to_string())
1021 .body(Body::empty())
1022 .unwrap())
1023}
1024
1025async fn me_handler(
1027 State(state): State<crate::ServerState>,
1028 headers: axum::http::HeaderMap,
1029) -> Response {
1030 let Some(ref auth) = state.auth else {
1031 return axum::Json(serde_json::json!({
1033 "authenticated": true,
1034 "auth_enabled": false
1035 }))
1036 .into_response();
1037 };
1038
1039 let cookie_header = headers
1040 .get(header::COOKIE)
1041 .and_then(|v| v.to_str().ok())
1042 .unwrap_or("");
1043
1044 let bearer = headers
1046 .get(header::AUTHORIZATION)
1047 .and_then(|v| v.to_str().ok())
1048 .and_then(|v| v.strip_prefix("Bearer "))
1049 .map(String::from);
1050
1051 let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
1052
1053 let Some(cookie_value) = token else {
1054 return axum::Json(serde_json::json!({
1055 "authenticated": false,
1056 "auth_enabled": true
1057 }))
1058 .into_response();
1059 };
1060
1061 if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
1064 let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
1065 if parts.len() >= 4 {
1066 return axum::Json(serde_json::json!({
1067 "authenticated": true,
1068 "auth_enabled": true,
1069 "email": parts[1],
1070 "name": parts[2],
1071 "username": parts[3],
1072 "dev_mode": true
1073 }))
1074 .into_response();
1075 }
1076 }
1077
1078 let access_token = if bearer.is_some() {
1080 cookie_value
1082 } else {
1083 match auth.session_manager.get_token(&cookie_value).await {
1085 Ok(token) => token,
1086 Err(e) => {
1087 tracing::debug!("Session token resolution failed for /auth/me: {}", e);
1088 return axum::Json(serde_json::json!({
1089 "authenticated": false,
1090 "auth_enabled": true
1091 }))
1092 .into_response();
1093 }
1094 }
1095 };
1096
1097 match auth.validate_token(&access_token).await {
1099 Ok(claims) => axum::Json(serde_json::json!({
1100 "authenticated": true,
1101 "auth_enabled": true,
1102 "sub": claims.sub,
1103 "email": claims.email,
1104 "name": claims.name,
1105 "username": claims.preferred_username,
1106 "dev_mode": false
1107 }))
1108 .into_response(),
1109 Err(e) => {
1110 tracing::debug!("Token validation failed for /auth/me: {}", e);
1111 axum::Json(serde_json::json!({
1112 "authenticated": false,
1113 "auth_enabled": true
1114 }))
1115 .into_response()
1116 }
1117 }
1118}
1119
1120async fn logout_handler(
1122 State(state): State<crate::ServerState>,
1123 headers: axum::http::HeaderMap,
1124) -> Response {
1125 let cookie_name = state
1126 .auth
1127 .as_ref()
1128 .map(|a| a.config.cookie_name.as_str())
1129 .unwrap_or("trustee_token");
1130
1131 if let Some(ref auth) = state.auth {
1133 if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
1134 if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
1135 if !session_id.starts_with("dev:") {
1136 let _ = auth.session_manager.destroy_session(&session_id);
1137 }
1138 }
1139 }
1140 }
1141
1142 let cookie = Cookie::build((cookie_name.to_string(), ""))
1143 .path("/")
1144 .http_only(true)
1145 .same_site(SameSite::Lax)
1146 .max_age(TimeDuration::seconds(-1))
1147 .build();
1148
1149 Response::builder()
1150 .status(StatusCode::FOUND)
1151 .header(header::LOCATION, "/")
1152 .header(header::SET_COOKIE, cookie.to_string())
1153 .body(Body::empty())
1154 .unwrap()
1155}
1156
1157#[derive(Debug, Deserialize)]
1163pub struct McpLoginQuery {
1164 pub cred: String,
1165}
1166
1167#[derive(Debug, Deserialize)]
1169pub struct McpCallbackQuery {
1170 pub code: Option<String>,
1171 pub state: Option<String>,
1172 pub error: Option<String>,
1173 pub error_description: Option<String>,
1174}
1175
1176async fn mcp_login_handler(
1181 State(state): State<crate::ServerState>,
1182 Query(query): Query<McpLoginQuery>,
1183 headers: axum::http::HeaderMap,
1184) -> Result<Response, AuthError> {
1185 let (_cookie, _user_key) = crate::auth::check_auth(
1187 &state.auth,
1188 &headers,
1189 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1190 )
1191 .await
1192 .map_err(|_| AuthError::AuthNotConfigured)?;
1193
1194 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1195
1196 let cred_config = load_mcp_credential(&state, &query.cred).await?;
1198
1199 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1200 McpCredentialInfo::WebInteractive {
1201 issuer_url,
1202 client_id,
1203 client_secret,
1204 scope,
1205 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1206 _ => {
1207 return Ok(Redirect::temporary(&format!(
1208 "/?mcp_error={}",
1209 urlencoding::encode(&format!("Credential '{}' is not web-interactive type", query.cred))
1210 ))
1211 .into_response());
1212 }
1213 };
1214
1215 let oidc_client = OidcClient::new();
1217 let verifier = OidcClient::generate_code_verifier();
1218 let challenge = OidcClient::generate_code_challenge(&verifier);
1219 let oauth_state = OidcClient::generate_state();
1220
1221 let mcp_redirect_uri = format!(
1223 "{}/auth/mcp/callback",
1224 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1225 );
1226
1227 let mcp_client_config = OidcClientConfig {
1228 issuer_url: issuer_url.clone(),
1229 client_id: client_id.clone(),
1230 client_secret: client_secret.clone(),
1231 redirect_uri: mcp_redirect_uri.clone(),
1232 scope: scope.clone(),
1233 code_challenge_method: "S256".to_string(),
1234 };
1235
1236 let auth_url = oidc_client
1238 .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
1239 .await
1240 .map_err(|e| AuthError::OidcError(e.to_string()))?;
1241
1242 mcp_pkce().insert(oauth_state.clone(), verifier.clone(), query.cred.clone()).await;
1244
1245 tracing::info!(
1246 "Initiating MCP browser login for credential '{}' (issuer={})",
1247 query.cred, issuer_url
1248 );
1249
1250 Ok(Response::builder()
1251 .status(StatusCode::TEMPORARY_REDIRECT)
1252 .header(header::LOCATION, &auth_url)
1253 .body(Body::empty())
1254 .unwrap())
1255}
1256
1257async fn mcp_callback_handler(
1259 State(state): State<crate::ServerState>,
1260 Query(query): Query<McpCallbackQuery>,
1261 headers: axum::http::HeaderMap,
1262) -> Result<Response, AuthError> {
1263 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1264
1265 if let Some(error) = query.error {
1267 let desc = query.error_description.unwrap_or_default();
1268 tracing::error!("MCP OIDC error: {} - {}", error, desc);
1269 return Ok(Redirect::temporary(&format!(
1270 "/?mcp_error={}&error_description={}",
1271 urlencoding::encode(&error),
1272 urlencoding::encode(&desc)
1273 ))
1274 .into_response());
1275 }
1276
1277 let code = query.code.ok_or(AuthError::MissingCode)?;
1278 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1279
1280 let pkce_data = mcp_pkce().take(&oauth_state).await
1282 .ok_or(AuthError::InvalidState)?;
1283
1284 let verifier = pkce_data.verifier;
1285 let cred_name = &pkce_data.cred_name;
1286
1287 let cred_config = load_mcp_credential(&state, cred_name).await?;
1289
1290 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1291 McpCredentialInfo::WebInteractive {
1292 issuer_url,
1293 client_id,
1294 client_secret,
1295 scope,
1296 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1297 _ => {
1298 return Ok(Redirect::temporary(&format!(
1299 "/?mcp_error={}",
1300 urlencoding::encode("Credential is not web-interactive type")
1301 ))
1302 .into_response());
1303 }
1304 };
1305
1306 let mcp_redirect_uri = format!(
1308 "{}/auth/mcp/callback",
1309 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1310 );
1311
1312 let mcp_client_config = OidcClientConfig {
1313 issuer_url: issuer_url.clone(),
1314 client_id: client_id.clone(),
1315 client_secret: client_secret.clone(),
1316 redirect_uri: mcp_redirect_uri,
1317 scope: scope.clone(),
1318 code_challenge_method: "S256".to_string(),
1319 };
1320
1321 tracing::info!("Exchanging MCP authorization code for tokens (credential={})", cred_name);
1323 let oidc_client = OidcClient::new();
1324 let token_response = oidc_client
1325 .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
1326 .await
1327 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1328
1329 let expires_at = {
1331 let now = std::time::SystemTime::now()
1332 .duration_since(std::time::UNIX_EPOCH)
1333 .unwrap_or_default()
1334 .as_secs();
1335 let expires_epoch = now + token_response.expires_in.unwrap_or(900);
1336 let days = expires_epoch / 86400;
1337 let rem = expires_epoch % 86400;
1338 let h = rem / 3600;
1339 let m = (rem % 3600) / 60;
1340 let s = rem % 60;
1341 let z = days as i64 + 719468;
1342 let era = if z >= 0 { z } else { z - 146096 } / 146097;
1343 let doe = (z - era * 146097) as u64;
1344 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1345 let y = yoe as i64 + era * 400;
1346 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1347 let mp = (5 * doy + 2) / 153;
1348 let d = doy - (153 * mp + 2) / 5 + 1;
1349 let mon = if mp < 10 { mp + 3 } else { mp - 9 };
1350 let yr = if mon <= 2 { y + 1 } else { y };
1351 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
1352 };
1353
1354 use pep::{FileTokenStore, StoredToken, TokenStore};
1356
1357 let stored = StoredToken::new(
1358 &token_response.access_token,
1359 token_response.refresh_token.clone(),
1360 "Bearer",
1361 &expires_at,
1362 token_response.scope.clone(),
1363 );
1364
1365 let agent_name = state.config_toml.as_ref().and_then(|t| {
1366 toml::from_str::<toml::Value>(t).ok()
1367 .and_then(|v| v.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()).map(String::from))
1368 }).unwrap_or_else(|| "trustee".to_string());
1369 let token_store = FileTokenStore::new(&agent_name);
1370
1371 if let Err(e) = token_store.save(cred_name, &stored) {
1372 tracing::error!("Failed to store MCP token: {}", e);
1373 return Ok(Redirect::temporary(&format!(
1374 "/?mcp_error={}",
1375 urlencoding::encode(&format!("Failed to store token: {}", e))
1376 ))
1377 .into_response());
1378 }
1379
1380 tracing::info!(
1381 "MCP authentication successful for credential '{}' (expires {})",
1382 cred_name, expires_at
1383 );
1384
1385 Ok(Response::builder()
1386 .status(StatusCode::FOUND)
1387 .header(header::LOCATION, format!("/?mcp_connected={}", urlencoding::encode(cred_name)))
1388 .body(Body::empty())
1389 .unwrap())
1390}
1391
1392async fn mcp_status_handler(
1394 State(state): State<crate::ServerState>,
1395 headers: axum::http::HeaderMap,
1396) -> Response {
1397 use pep::{FileTokenStore, TokenStore};
1398
1399 let (_cookie, user_key) = match crate::auth::check_auth(
1401 &state.auth,
1402 &headers,
1403 crate::auth::actions::VIEW_MCP_CREDENTIALS,
1404 )
1405 .await
1406 {
1407 Ok(result) => result,
1408 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1409 };
1410
1411 let config_toml = {
1413 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1414 let session = session_arc.lock().await;
1415 match &session.config_toml {
1416 Some(t) => t.clone(),
1417 None => return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response(),
1418 }
1419 };
1420
1421 let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1422 Ok(v) => v,
1423 Err(_) => return Json(serde_json::json!([])).into_response(),
1424 };
1425
1426 let agent_name = {
1427 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1428 let session = session_arc.lock().await;
1429 session.agent_name.clone()
1430 };
1431 let token_store = FileTokenStore::new(&agent_name);
1432
1433 let servers = mcp_config
1435 .get("mcp")
1436 .and_then(|m| m.get("servers"))
1437 .and_then(|s| s.as_array());
1438 let credentials = mcp_config
1439 .get("mcp")
1440 .and_then(|m| m.get("credentials"))
1441 .and_then(|c| c.as_table());
1442
1443 let mut cred_servers: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
1444 if let Some(servers) = servers {
1445 for server in servers {
1446 let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1447 let cred_ref = server.get("credentials").and_then(|c| c.as_str()).unwrap_or("");
1448 if !cred_ref.is_empty() {
1449 cred_servers
1450 .entry(cred_ref.to_string())
1451 .or_default()
1452 .push(name.to_string());
1453 }
1454 }
1455 }
1456
1457 let mut result = Vec::new();
1458
1459 if let Some(creds) = credentials {
1460 for (cred_name, cred_config) in creds {
1461 let cred_type = cred_config.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1462 let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1463
1464 if cred_type == "web-session" {
1465 let connected = state.auth.is_some();
1467 result.push(serde_json::json!({
1468 "credential": cred_name,
1469 "type": cred_type,
1470 "connected": connected,
1471 "servers": servers_using,
1472 }));
1473 } else if cred_type == "service-account" {
1474 let token = cred_config
1478 .get("service_token")
1479 .and_then(|t| t.as_str())
1480 .unwrap_or("");
1481 result.push(serde_json::json!({
1482 "credential": cred_name,
1483 "type": cred_type,
1484 "connected": !token.is_empty(),
1485 "servers": servers_using,
1486 }));
1487 } else if cred_type == "static" {
1488 let token = cred_config
1490 .get("token")
1491 .and_then(|t| t.as_str())
1492 .unwrap_or("");
1493 result.push(serde_json::json!({
1494 "credential": cred_name,
1495 "type": cred_type,
1496 "connected": !token.is_empty(),
1497 "servers": servers_using,
1498 }));
1499 } else if cred_type == "web-interactive" || cred_type == "interactive" {
1500 let status = match token_store.load(cred_name) {
1502 Ok(Some(token)) => {
1503 let expired = token.is_expired();
1504 serde_json::json!({
1505 "credential": cred_name,
1506 "type": cred_type,
1507 "connected": !expired,
1508 "expires_at": token.expires_at,
1509 "servers": servers_using,
1510 })
1511 }
1512 _ => serde_json::json!({
1513 "credential": cred_name,
1514 "type": cred_type,
1515 "connected": false,
1516 "servers": servers_using,
1517 }),
1518 };
1519 result.push(status);
1520 }
1521 }
1522 }
1523
1524 Json(serde_json::Value::Array(result)).into_response()
1525}
1526
1527async fn mcp_logout_handler(
1529 State(state): State<crate::ServerState>,
1530 Query(query): Query<McpLoginQuery>,
1531 headers: axum::http::HeaderMap,
1532) -> Response {
1533 use pep::{FileTokenStore, TokenStore};
1534
1535 let (_cookie, user_key) = match crate::auth::check_auth(
1537 &state.auth,
1538 &headers,
1539 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1540 )
1541 .await
1542 {
1543 Ok(result) => result,
1544 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1545 };
1546
1547 let agent_name = {
1548 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1549 let session = session_arc.lock().await;
1550 session.agent_name.clone()
1551 };
1552 let token_store = FileTokenStore::new(&agent_name);
1553
1554 match token_store.delete(&query.cred) {
1555 Ok(()) => {
1556 tracing::info!("Removed MCP credentials for '{}'", query.cred);
1557 Json(serde_json::json!({"success": true})).into_response()
1558 }
1559 Err(e) => {
1560 tracing::error!("Failed to remove MCP credentials: {}", e);
1561 (
1562 StatusCode::INTERNAL_SERVER_ERROR,
1563 Json(serde_json::json!({"error": e.to_string()})),
1564 )
1565 .into_response()
1566 }
1567 }
1568}
1569
1570struct McpPkceStore {
1577 entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1578}
1579
1580struct McpPkceEntry {
1581 verifier: String,
1582 cred_name: String,
1583 created_at: std::time::Instant,
1584}
1585
1586impl McpPkceStore {
1587 fn new() -> Self {
1588 Self {
1589 entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1590 }
1591 }
1592
1593 async fn insert(&self, state: String, verifier: String, cred_name: String) {
1595 let mut map = self.entries.lock().await;
1596 let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1598 map.retain(|_, v| v.created_at > cutoff);
1599 map.insert(state, McpPkceEntry {
1600 verifier,
1601 cred_name,
1602 created_at: std::time::Instant::now(),
1603 });
1604 }
1605
1606 async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1608 let mut map = self.entries.lock().await;
1609 map.remove(state)
1610 }
1611}
1612
1613static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1615
1616fn mcp_pkce() -> &'static McpPkceStore {
1618 MCP_PKCE.get_or_init(McpPkceStore::new)
1619}
1620
1621enum McpCredentialInfo {
1623 WebInteractive {
1624 issuer_url: String,
1625 client_id: String,
1626 client_secret: Option<String>,
1627 scope: String,
1628 },
1629 Other(String),
1630}
1631
1632async fn load_mcp_credential(
1634 state: &crate::ServerState,
1635 cred_name: &str,
1636) -> Result<McpCredentialInfo, AuthError> {
1637 let config_toml = state
1638 .config_toml
1639 .clone()
1640 .ok_or(AuthError::AuthNotConfigured)?;
1641
1642 let config: toml::Value = toml::from_str(&config_toml)
1643 .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1644
1645 let cred = config
1646 .get("mcp")
1647 .and_then(|m| m.get("credentials"))
1648 .and_then(|c| c.as_table())
1649 .and_then(|c| c.get(cred_name))
1650 .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1651
1652 let cred_type = cred.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1653
1654 match cred_type {
1655 "web-interactive" => {
1656 let issuer_url = cred
1657 .get("issuer_url")
1658 .and_then(|v| v.as_str())
1659 .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
1660 .to_string();
1661 let client_id = cred
1662 .get("client_id")
1663 .and_then(|v| v.as_str())
1664 .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
1665 .to_string();
1666 let client_secret = cred
1667 .get("client_secret")
1668 .and_then(|v| v.as_str())
1669 .map(String::from);
1670 let scope = cred
1671 .get("scope")
1672 .and_then(|v| v.as_str())
1673 .unwrap_or("openid profile email")
1674 .to_string();
1675
1676 Ok(McpCredentialInfo::WebInteractive {
1677 issuer_url,
1678 client_id,
1679 client_secret,
1680 scope,
1681 })
1682 }
1683 other => Ok(McpCredentialInfo::Other(other.to_string())),
1684 }
1685}
1686
1687fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
1693 Cookie::build((name.to_string(), value.to_string()))
1694 .path("/")
1695 .http_only(true)
1696 .same_site(SameSite::Lax)
1697 .secure(secure)
1698 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
1699 .build()
1700}
1701
1702#[derive(Debug)]
1708pub enum AuthError {
1709 MissingCode,
1710 MissingState,
1711 InvalidState,
1712 OidcError(String),
1713 TokenExchangeFailed(String),
1714 AuthNotConfigured,
1715}
1716
1717impl IntoResponse for AuthError {
1718 fn into_response(self) -> Response {
1719 let (_status, msg) = match self {
1720 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
1721 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
1722 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
1723 AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
1724 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
1725 AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
1726 };
1727 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
1728 }
1729}
1730
1731#[cfg(test)]
1732mod principal_tests {
1733 use super::*;
1734 use pep::oidc::types::JwtClaims;
1735 use std::collections::HashMap;
1736
1737 fn claims_with_role(role: serde_json::Value) -> JwtClaims {
1738 let mut c = JwtClaims::default();
1739 c.sub = "sub-uuid".to_string();
1740 c.preferred_username = Some("farzan".to_string());
1741 c.extra.insert("role".to_string(), role);
1742 c
1743 }
1744
1745 #[test]
1748 fn dev_agent_token_yields_agent_namespaced_key() {
1749 assert_eq!(
1750 dev_user_key("dev:agent:farzan"),
1751 Some("agent-farzan".to_string())
1752 );
1753 assert_eq!(
1754 dev_user_key("dev:agent:paydar"),
1755 Some("agent-paydar".to_string())
1756 );
1757 }
1758
1759 #[test]
1760 fn dev_agent_token_rejects_empty_and_colon_names() {
1761 assert_eq!(dev_user_key("dev:agent:"), None);
1762 assert_eq!(dev_user_key("dev:agent: "), None);
1763 assert_eq!(
1764 dev_user_key("dev:agent:a:b"),
1765 None,
1766 "name must not contain ':'"
1767 );
1768 }
1769
1770 #[test]
1771 fn dev_human_token_format_unchanged() {
1772 assert_eq!(
1773 dev_user_key("dev:a@b.c:Some Name:someuser"),
1774 Some("dev:a@b.c".to_string())
1775 );
1776 assert_eq!(dev_user_key("dev:only:two"), None);
1777 }
1778
1779 #[test]
1782 fn role_as_string_classifies_agent() {
1783 let c = claims_with_role(serde_json::json!("agent"));
1784 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1785 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1786 }
1787
1788 #[test]
1789 fn role_as_array_takes_first_value() {
1790 let c = claims_with_role(serde_json::json!(["agent", "other"]));
1792 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1793 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1794 }
1795
1796 #[test]
1797 fn non_agent_roles_classify_human() {
1798 for role in ["user", "admin", "service"] {
1799 let c = claims_with_role(serde_json::json!(role));
1800 assert_eq!(claim_role(&c).as_deref(), Some(role));
1801 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Human, "role={role}");
1802 }
1803 }
1804
1805 #[test]
1806 fn missing_or_nonstring_role_classifies_human() {
1807 let mut c = JwtClaims::default();
1808 c.sub = "sub-uuid".to_string();
1809 assert_eq!(claim_role(&c), None);
1810 assert_eq!(AuthUser::from(c.clone()).kind, PrincipalKind::Human);
1811 c.extra.insert("role".to_string(), serde_json::json!(42));
1812 assert_eq!(claim_role(&c), None, "non-string non-array role ignored");
1813 }
1814
1815 #[test]
1818 fn user_key_prefers_preferred_username() {
1819 let mut c = JwtClaims::default();
1820 c.sub = "sub-uuid".to_string();
1821 c.preferred_username = Some("farzan".to_string());
1822 c.email = Some("rebindable@example.com".to_string());
1823 assert_eq!(jwt_user_key(&c), "farzan", "email must never be the key");
1824 }
1825
1826 #[test]
1827 fn user_key_falls_back_to_sub_on_blank_username() {
1828 let mut c = JwtClaims::default();
1829 c.sub = "sub-uuid".to_string();
1830 c.preferred_username = Some(" ".to_string());
1831 assert_eq!(jwt_user_key(&c), "sub-uuid");
1832 c.preferred_username = None;
1833 assert_eq!(jwt_user_key(&c), "sub-uuid");
1834 }
1835
1836 #[test]
1837 fn user_key_agent_pinned_to_sub_even_with_username() {
1838 let mut c = claims_with_role(serde_json::json!("agent"));
1841 c.sub = "agent-sub-uuid".to_string();
1842 c.preferred_username = Some("farzan".to_string());
1843 assert_eq!(jwt_user_key(&c), "agent-sub-uuid");
1844 }
1845
1846 #[test]
1847 fn authuser_carries_role_and_kind() {
1848 let c = claims_with_role(serde_json::json!("agent"));
1849 let u = AuthUser::from(c);
1850 assert_eq!(u.role.as_deref(), Some("agent"));
1851 assert_eq!(u.kind, PrincipalKind::Agent);
1852 assert_eq!(u.username.as_deref(), Some("farzan"));
1853 }
1854}
1855
1856#[cfg(test)]
1857mod cedar_p2_tests {
1858 use super::*;
1859 use cedar_policy::{Context, Entities, EntityUid, Request};
1860 use pep::cedar::{CedarAuthorizer, CedarConfig};
1861 use std::collections::HashMap;
1862
1863 const POLICY: &str = include_str!("../policies/trustee_default.cedar");
1864 const SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
1865
1866 async fn authorizer() -> CedarAuthorizer {
1867 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1869 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1870 let dir = std::env::temp_dir().join(format!("trustee-cedar-p2-{}-{n}", std::process::id()));
1871 std::fs::create_dir_all(&dir).expect("temp dir");
1872 let policy_path = dir.join("trustee_default.cedar");
1873 let schema_path = dir.join("trustee_schema.cedarschema");
1874 std::fs::write(&policy_path, POLICY).expect("write policy");
1875 std::fs::write(&schema_path, SCHEMA).expect("write schema");
1876 let cfg = CedarConfig {
1877 policy_path,
1878 schema_path: Some(schema_path),
1879 entities_path: None,
1880 default_decision: pep::cedar::DefaultDecision::Deny,
1881 validate_on_load: true,
1882 policy_store_url: None,
1883 policy_store_token: None,
1884 embedded_policy: Some(POLICY),
1885 embedded_schema: Some(SCHEMA),
1886 };
1887 CedarAuthorizer::new_with_policy_store(cfg)
1888 .await
1889 .expect("Cedar init from shipped sources")
1890 }
1891
1892 fn claims_with_role(role: Option<&str>) -> JwtClaims {
1893 let mut c = JwtClaims::default();
1894 c.sub = "test-sub".to_string();
1895 if let Some(r) = role {
1896 c.extra.insert("role".to_string(), serde_json::json!(r));
1897 }
1898 c
1899 }
1900
1901 async fn allowed(auth: &CedarAuthorizer, role: Option<&str>, action: &str) -> bool {
1903 let claims = claims_with_role(role);
1904 let principal_entity = pep::cedar::build_principal_entity(&claims).unwrap();
1905 let app_entity = cedar_policy::Entity::new(
1906 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1907 HashMap::new(),
1908 std::collections::HashSet::new(),
1909 )
1910 .unwrap();
1911 let entities = Entities::from_entities(vec![principal_entity, app_entity], None).unwrap();
1912 let request = Request::new(
1913 pep::cedar::build_principal_uid(&claims).unwrap(),
1914 EntityUid::from_str(&format!("Action::\"{action}\"")).unwrap(),
1915 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1916 Context::empty(),
1917 None,
1918 )
1919 .unwrap();
1920 auth.is_allowed_with_entities(&request, &entities).allowed()
1921 }
1922
1923 #[tokio::test]
1924 async fn admin_allowed_including_destructive() {
1925 let auth = authorizer().await;
1926 for action in [
1927 actions::VIEW_SESSION,
1928 actions::COMMAND_SESSION,
1929 actions::DELETE_SESSION,
1930 actions::UPDATE_MCP_CREDENTIALS,
1931 ] {
1932 assert!(
1933 allowed(&auth, Some("admin"), action).await,
1934 "admin {action}"
1935 );
1936 }
1937 }
1938
1939 #[tokio::test]
1940 async fn user_full_session_management() {
1941 let auth = authorizer().await;
1942 for action in [
1943 actions::CREATE_SESSION,
1944 actions::COMMAND_SESSION,
1945 actions::DELETE_SESSION,
1946 actions::VIEW_HISTORY,
1947 actions::UPDATE_MCP_CREDENTIALS,
1948 ] {
1949 assert!(allowed(&auth, Some("user"), action).await, "user {action}");
1950 }
1951 }
1952
1953 #[tokio::test]
1954 async fn agent_working_set_but_delete_denied() {
1955 let auth = authorizer().await;
1956 for action in [
1957 actions::CREATE_SESSION,
1958 actions::COMMAND_SESSION,
1959 actions::CANCEL_SESSION,
1960 actions::RESUME_SESSION,
1961 actions::VIEW_HISTORY,
1962 actions::UPDATE_MCP_CREDENTIALS,
1963 ] {
1964 assert!(
1965 allowed(&auth, Some("agent"), action).await,
1966 "agent {action}"
1967 );
1968 }
1969 assert!(
1970 !allowed(&auth, Some("agent"), actions::DELETE_SESSION).await,
1971 "agent must NOT delete sessions (fail-closed start; revisit at task F)"
1972 );
1973 }
1974
1975 #[tokio::test]
1976 async fn service_read_only() {
1977 let auth = authorizer().await;
1978 for action in [
1979 actions::VIEW_SESSION,
1980 actions::LIST_SESSIONS,
1981 actions::VIEW_HISTORY,
1982 ] {
1983 assert!(allowed(&auth, Some("service"), action).await, "service {action}");
1984 }
1985 for action in [actions::COMMAND_SESSION, actions::DELETE_SESSION] {
1986 assert!(
1987 !allowed(&auth, Some("service"), action).await,
1988 "service {action} denied"
1989 );
1990 }
1991 }
1992
1993 #[tokio::test]
1994 async fn missing_or_unknown_role_denied_everything() {
1995 let auth = authorizer().await;
1996 for role in [None, Some("intern"), Some("Admin")] {
1997 assert!(
1998 !allowed(&auth, role, actions::VIEW_SESSION).await,
1999 "role={role:?} must be denied (fail-closed default)"
2000 );
2001 }
2002 }
2003
2004 #[test]
2005 fn boot_decision_is_fail_closed() {
2006 assert!(crate::cedar_boot_decision(true, false, false).is_err());
2007 assert!(crate::cedar_boot_decision(true, false, true).is_ok());
2008 assert!(crate::cedar_boot_decision(true, true, false).is_ok());
2009 assert!(crate::cedar_boot_decision(false, false, false).is_ok());
2010 }
2011}