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 async fn check_auth(
596 auth: &Option<Arc<AuthState>>,
597 headers: &axum::http::HeaderMap,
598 action: &str,
599) -> Result<(Option<String>, String), StatusCode> {
600 let Some(auth) = auth.as_ref() else {
601 return Ok((None, "default".to_string())); };
603
604 if let Some(token) = headers
606 .get(header::AUTHORIZATION)
607 .and_then(|v| v.to_str().ok())
608 .and_then(|v| v.strip_prefix("Bearer "))
609 .map(|s| s.to_string())
610 {
611 if token.starts_with("dev:") {
613 if !auth.config.dev_config.local_dev_mode {
614 tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
615 return Err(StatusCode::UNAUTHORIZED);
616 }
617 return match dev_user_key(&token) {
618 Some(key) => Ok((None, key)),
619 None => Err(StatusCode::UNAUTHORIZED),
620 };
621 }
622
623 return match auth.validate_token(&token).await {
624 Ok(claims) => {
625 if auth.check_cedar_authorized(&claims, action).is_err() {
626 return Err(StatusCode::FORBIDDEN);
627 }
628 Ok((None, jwt_user_key(&claims)))
629 }
630 Err(e) => {
631 tracing::warn!("Bearer token validation failed: {}", e);
632 Err(StatusCode::UNAUTHORIZED)
633 }
634 };
635 }
636
637 let session_id = headers
639 .get(header::COOKIE)
640 .and_then(|v| v.to_str().ok())
641 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
642
643 let Some(session_id) = session_id else {
644 tracing::warn!("No auth token found in request");
645 return Err(StatusCode::UNAUTHORIZED);
646 };
647
648 if session_id.starts_with("dev:") {
650 if !auth.config.dev_config.local_dev_mode {
651 tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
652 return Err(StatusCode::UNAUTHORIZED);
653 }
654 return match dev_user_key(&session_id) {
655 Some(key) => Ok((None, key)),
656 None => Err(StatusCode::UNAUTHORIZED),
657 };
658 }
659
660 match auth.session_manager.get_token(&session_id).await {
662 Ok(access_token) => match auth.validate_token(&access_token).await {
663 Ok(claims) => {
664 if auth.check_cedar_authorized(&claims, action).is_err() {
666 return Err(StatusCode::FORBIDDEN);
667 }
668 let secure = auth.client_config.redirect_uri.starts_with("https");
670 let cookie = create_auth_cookie(
671 &auth.config.cookie_name,
672 &session_id,
673 SESSION_COOKIE_MAX_AGE,
674 secure,
675 );
676 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
677 }
678 Err(e) => {
679 tracing::warn!("Session token validation failed: {} — attempting force-refresh", e);
682 match auth.session_manager.force_refresh(&session_id).await {
683 Ok(new_token) => match auth.validate_token(&new_token).await {
684 Ok(claims) => {
685 if auth.check_cedar_authorized(&claims, action).is_err() {
687 return Err(StatusCode::FORBIDDEN);
688 }
689 let secure = auth.client_config.redirect_uri.starts_with("https");
690 let cookie = create_auth_cookie(
691 &auth.config.cookie_name,
692 &session_id,
693 SESSION_COOKIE_MAX_AGE,
694 secure,
695 );
696 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
697 }
698 Err(e2) => {
699 tracing::warn!("Session token still invalid after force-refresh: {}", e2);
700 Err(StatusCode::UNAUTHORIZED)
701 }
702 },
703 Err(e2) => {
704 tracing::warn!("Force-refresh failed: {}", e2);
705 Err(StatusCode::UNAUTHORIZED)
706 }
707 }
708 }
709 },
710 Err(e) => {
711 tracing::warn!("Session lookup/refresh failed: {}", e);
712 Err(StatusCode::UNAUTHORIZED)
713 }
714 }
715}
716
717async fn resolve_access_token(
723 auth: &AuthState,
724 headers: &axum::http::HeaderMap,
725) -> Result<String, StatusCode> {
726 if let Some(token) = headers
728 .get(header::AUTHORIZATION)
729 .and_then(|v| v.to_str().ok())
730 .and_then(|v| v.strip_prefix("Bearer "))
731 .map(|s| s.to_string())
732 {
733 return Ok(token);
734 }
735
736 let session_id = headers
738 .get(header::COOKIE)
739 .and_then(|v| v.to_str().ok())
740 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
741
742 match session_id {
743 Some(sid) if sid.starts_with("dev:") => {
744 if !auth.config.dev_config.local_dev_mode {
745 tracing::warn!("Dev cookie in resolve_access_token but dev mode is disabled — rejecting");
746 Err(StatusCode::UNAUTHORIZED)
747 } else {
748 Ok(sid)
749 }
750 }
751 Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
752 tracing::warn!("Failed to resolve session token: {}", e);
753 StatusCode::UNAUTHORIZED
754 }),
755 None => Err(StatusCode::UNAUTHORIZED),
756 }
757}
758
759fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
761 for cookie in cookie_header.split(';') {
762 let cookie = cookie.trim();
763 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
764 return Some(value.to_string());
765 }
766 }
767 None
768}
769
770pub fn auth_routes() -> axum::Router<crate::ServerState> {
776 axum::Router::new()
777 .route("/login", axum::routing::get(login_handler))
778 .route("/callback", axum::routing::get(callback_handler))
779 .route("/me", axum::routing::get(me_handler))
780 .route("/logout", axum::routing::post(logout_handler))
781 .route("/mcp/login", axum::routing::get(mcp_login_handler))
782 .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
783 .route("/mcp/status", axum::routing::get(mcp_status_handler))
784 .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
785}
786
787#[derive(Debug, Deserialize)]
789pub struct CallbackQuery {
790 pub code: Option<String>,
791 pub state: Option<String>,
792 pub error: Option<String>,
793 pub error_description: Option<String>,
794}
795
796async fn login_handler(
798 State(state): State<crate::ServerState>,
799) -> Result<Response, AuthError> {
800 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
801
802 if auth.is_dev_mode() {
804 tracing::info!("Dev mode: creating dev session");
805 let dev = &auth.config.dev_config;
806 let dev_token = format!(
807 "dev:{}:{}:{}",
808 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
809 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
810 dev.local_dev_username.as_deref().unwrap_or("dev")
811 );
812 let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
813 return Ok(Response::builder()
814 .status(StatusCode::FOUND)
815 .header(header::LOCATION, "/")
816 .header(header::SET_COOKIE, cookie.to_string())
817 .body(Body::empty())
818 .unwrap());
819 }
820
821 let pkce_session = auth.pkce_manager.create();
823 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
824
825 let auth_url = auth
826 .oidc_client
827 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
828 .await
829 .map_err(|e| AuthError::OidcError(e.to_string()))?;
830
831 let secure = auth.client_config.redirect_uri.starts_with("https");
835 let pkce_cookie = Cookie::build((
836 auth.pkce_manager.cookie_name().to_string(),
837 pkce_session.cookie_value,
838 ))
839 .path("/")
840 .http_only(true)
841 .same_site(SameSite::Lax)
842 .secure(secure)
843 .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
844 .build();
845
846 Ok(Response::builder()
847 .status(StatusCode::TEMPORARY_REDIRECT)
848 .header(header::LOCATION, &auth_url)
849 .header(header::SET_COOKIE, pkce_cookie.to_string())
850 .body(Body::empty())
851 .unwrap())
852}
853
854async fn callback_handler(
856 State(state): State<crate::ServerState>,
857 Query(query): Query<CallbackQuery>,
858 headers: axum::http::HeaderMap,
859) -> Result<Response, AuthError> {
860 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
861
862 if let Some(error) = query.error {
864 let desc = query.error_description.unwrap_or_default();
865 tracing::error!("OIDC error: {} - {}", error, desc);
866 return Ok(Redirect::temporary(&format!(
867 "/?error={}&error_description={}",
868 urlencoding::encode(&error),
869 urlencoding::encode(&desc)
870 ))
871 .into_response());
872 }
873
874 let code = query.code.ok_or(AuthError::MissingCode)?;
875 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
876
877 let cookie_header = headers
879 .get(header::COOKIE)
880 .and_then(|v| v.to_str().ok())
881 .unwrap_or("");
882 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
883 .ok_or(AuthError::InvalidState)?;
884
885 let verifier = auth
887 .pkce_manager
888 .verify(&pkce_value, &oauth_state)
889 .ok_or(AuthError::InvalidState)?;
890
891 tracing::info!("Exchanging authorization code for tokens");
893 let token_response = auth
894 .oidc_client
895 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
896 .await
897 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
898
899 let session_id = auth
900 .session_manager
901 .create_session(&token_response)
902 .await
903 .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
904
905 let max_age = SESSION_COOKIE_MAX_AGE;
908
909 let secure = auth.client_config.redirect_uri.starts_with("https");
911 let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
912
913 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
915 .path("/")
916 .http_only(true)
917 .same_site(SameSite::Lax)
918 .max_age(TimeDuration::seconds(-1))
919 .build();
920
921 tracing::info!("Authentication successful, redirecting to /");
922
923 Ok(Response::builder()
924 .status(StatusCode::FOUND)
925 .header(header::LOCATION, "/")
926 .header(header::SET_COOKIE, cookie.to_string())
927 .header(header::SET_COOKIE, clear_pkce.to_string())
928 .body(Body::empty())
929 .unwrap())
930}
931
932async fn me_handler(
934 State(state): State<crate::ServerState>,
935 headers: axum::http::HeaderMap,
936) -> Response {
937 let Some(ref auth) = state.auth else {
938 return axum::Json(serde_json::json!({
940 "authenticated": true,
941 "auth_enabled": false
942 }))
943 .into_response();
944 };
945
946 let cookie_header = headers
947 .get(header::COOKIE)
948 .and_then(|v| v.to_str().ok())
949 .unwrap_or("");
950
951 let bearer = headers
953 .get(header::AUTHORIZATION)
954 .and_then(|v| v.to_str().ok())
955 .and_then(|v| v.strip_prefix("Bearer "))
956 .map(String::from);
957
958 let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
959
960 let Some(cookie_value) = token else {
961 return axum::Json(serde_json::json!({
962 "authenticated": false,
963 "auth_enabled": true
964 }))
965 .into_response();
966 };
967
968 if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
971 let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
972 if parts.len() >= 4 {
973 return axum::Json(serde_json::json!({
974 "authenticated": true,
975 "auth_enabled": true,
976 "email": parts[1],
977 "name": parts[2],
978 "username": parts[3],
979 "dev_mode": true
980 }))
981 .into_response();
982 }
983 }
984
985 let access_token = if bearer.is_some() {
987 cookie_value
989 } else {
990 match auth.session_manager.get_token(&cookie_value).await {
992 Ok(token) => token,
993 Err(e) => {
994 tracing::debug!("Session token resolution failed for /auth/me: {}", e);
995 return axum::Json(serde_json::json!({
996 "authenticated": false,
997 "auth_enabled": true
998 }))
999 .into_response();
1000 }
1001 }
1002 };
1003
1004 match auth.validate_token(&access_token).await {
1006 Ok(claims) => axum::Json(serde_json::json!({
1007 "authenticated": true,
1008 "auth_enabled": true,
1009 "sub": claims.sub,
1010 "email": claims.email,
1011 "name": claims.name,
1012 "username": claims.preferred_username,
1013 "dev_mode": false
1014 }))
1015 .into_response(),
1016 Err(e) => {
1017 tracing::debug!("Token validation failed for /auth/me: {}", e);
1018 axum::Json(serde_json::json!({
1019 "authenticated": false,
1020 "auth_enabled": true
1021 }))
1022 .into_response()
1023 }
1024 }
1025}
1026
1027async fn logout_handler(
1029 State(state): State<crate::ServerState>,
1030 headers: axum::http::HeaderMap,
1031) -> Response {
1032 let cookie_name = state
1033 .auth
1034 .as_ref()
1035 .map(|a| a.config.cookie_name.as_str())
1036 .unwrap_or("trustee_token");
1037
1038 if let Some(ref auth) = state.auth {
1040 if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
1041 if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
1042 if !session_id.starts_with("dev:") {
1043 let _ = auth.session_manager.destroy_session(&session_id);
1044 }
1045 }
1046 }
1047 }
1048
1049 let cookie = Cookie::build((cookie_name.to_string(), ""))
1050 .path("/")
1051 .http_only(true)
1052 .same_site(SameSite::Lax)
1053 .max_age(TimeDuration::seconds(-1))
1054 .build();
1055
1056 Response::builder()
1057 .status(StatusCode::FOUND)
1058 .header(header::LOCATION, "/")
1059 .header(header::SET_COOKIE, cookie.to_string())
1060 .body(Body::empty())
1061 .unwrap()
1062}
1063
1064#[derive(Debug, Deserialize)]
1070pub struct McpLoginQuery {
1071 pub cred: String,
1072}
1073
1074#[derive(Debug, Deserialize)]
1076pub struct McpCallbackQuery {
1077 pub code: Option<String>,
1078 pub state: Option<String>,
1079 pub error: Option<String>,
1080 pub error_description: Option<String>,
1081}
1082
1083async fn mcp_login_handler(
1088 State(state): State<crate::ServerState>,
1089 Query(query): Query<McpLoginQuery>,
1090 headers: axum::http::HeaderMap,
1091) -> Result<Response, AuthError> {
1092 let (_cookie, _user_key) = crate::auth::check_auth(
1094 &state.auth,
1095 &headers,
1096 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1097 )
1098 .await
1099 .map_err(|_| AuthError::AuthNotConfigured)?;
1100
1101 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1102
1103 let cred_config = load_mcp_credential(&state, &query.cred).await?;
1105
1106 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1107 McpCredentialInfo::WebInteractive {
1108 issuer_url,
1109 client_id,
1110 client_secret,
1111 scope,
1112 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1113 _ => {
1114 return Ok(Redirect::temporary(&format!(
1115 "/?mcp_error={}",
1116 urlencoding::encode(&format!("Credential '{}' is not web-interactive type", query.cred))
1117 ))
1118 .into_response());
1119 }
1120 };
1121
1122 let oidc_client = OidcClient::new();
1124 let verifier = OidcClient::generate_code_verifier();
1125 let challenge = OidcClient::generate_code_challenge(&verifier);
1126 let oauth_state = OidcClient::generate_state();
1127
1128 let mcp_redirect_uri = format!(
1130 "{}/auth/mcp/callback",
1131 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1132 );
1133
1134 let mcp_client_config = OidcClientConfig {
1135 issuer_url: issuer_url.clone(),
1136 client_id: client_id.clone(),
1137 client_secret: client_secret.clone(),
1138 redirect_uri: mcp_redirect_uri.clone(),
1139 scope: scope.clone(),
1140 code_challenge_method: "S256".to_string(),
1141 };
1142
1143 let auth_url = oidc_client
1145 .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
1146 .await
1147 .map_err(|e| AuthError::OidcError(e.to_string()))?;
1148
1149 mcp_pkce().insert(oauth_state.clone(), verifier.clone(), query.cred.clone()).await;
1151
1152 tracing::info!(
1153 "Initiating MCP browser login for credential '{}' (issuer={})",
1154 query.cred, issuer_url
1155 );
1156
1157 Ok(Response::builder()
1158 .status(StatusCode::TEMPORARY_REDIRECT)
1159 .header(header::LOCATION, &auth_url)
1160 .body(Body::empty())
1161 .unwrap())
1162}
1163
1164async fn mcp_callback_handler(
1166 State(state): State<crate::ServerState>,
1167 Query(query): Query<McpCallbackQuery>,
1168 headers: axum::http::HeaderMap,
1169) -> Result<Response, AuthError> {
1170 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1171
1172 if let Some(error) = query.error {
1174 let desc = query.error_description.unwrap_or_default();
1175 tracing::error!("MCP OIDC error: {} - {}", error, desc);
1176 return Ok(Redirect::temporary(&format!(
1177 "/?mcp_error={}&error_description={}",
1178 urlencoding::encode(&error),
1179 urlencoding::encode(&desc)
1180 ))
1181 .into_response());
1182 }
1183
1184 let code = query.code.ok_or(AuthError::MissingCode)?;
1185 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1186
1187 let pkce_data = mcp_pkce().take(&oauth_state).await
1189 .ok_or(AuthError::InvalidState)?;
1190
1191 let verifier = pkce_data.verifier;
1192 let cred_name = &pkce_data.cred_name;
1193
1194 let cred_config = load_mcp_credential(&state, cred_name).await?;
1196
1197 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1198 McpCredentialInfo::WebInteractive {
1199 issuer_url,
1200 client_id,
1201 client_secret,
1202 scope,
1203 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1204 _ => {
1205 return Ok(Redirect::temporary(&format!(
1206 "/?mcp_error={}",
1207 urlencoding::encode("Credential is not web-interactive type")
1208 ))
1209 .into_response());
1210 }
1211 };
1212
1213 let mcp_redirect_uri = format!(
1215 "{}/auth/mcp/callback",
1216 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1217 );
1218
1219 let mcp_client_config = OidcClientConfig {
1220 issuer_url: issuer_url.clone(),
1221 client_id: client_id.clone(),
1222 client_secret: client_secret.clone(),
1223 redirect_uri: mcp_redirect_uri,
1224 scope: scope.clone(),
1225 code_challenge_method: "S256".to_string(),
1226 };
1227
1228 tracing::info!("Exchanging MCP authorization code for tokens (credential={})", cred_name);
1230 let oidc_client = OidcClient::new();
1231 let token_response = oidc_client
1232 .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
1233 .await
1234 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1235
1236 let expires_at = {
1238 let now = std::time::SystemTime::now()
1239 .duration_since(std::time::UNIX_EPOCH)
1240 .unwrap_or_default()
1241 .as_secs();
1242 let expires_epoch = now + token_response.expires_in.unwrap_or(900);
1243 let days = expires_epoch / 86400;
1244 let rem = expires_epoch % 86400;
1245 let h = rem / 3600;
1246 let m = (rem % 3600) / 60;
1247 let s = rem % 60;
1248 let z = days as i64 + 719468;
1249 let era = if z >= 0 { z } else { z - 146096 } / 146097;
1250 let doe = (z - era * 146097) as u64;
1251 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1252 let y = yoe as i64 + era * 400;
1253 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1254 let mp = (5 * doy + 2) / 153;
1255 let d = doy - (153 * mp + 2) / 5 + 1;
1256 let mon = if mp < 10 { mp + 3 } else { mp - 9 };
1257 let yr = if mon <= 2 { y + 1 } else { y };
1258 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
1259 };
1260
1261 use pep::{FileTokenStore, StoredToken, TokenStore};
1263
1264 let stored = StoredToken::new(
1265 &token_response.access_token,
1266 token_response.refresh_token.clone(),
1267 "Bearer",
1268 &expires_at,
1269 token_response.scope.clone(),
1270 );
1271
1272 let agent_name = state.config_toml.as_ref().and_then(|t| {
1273 toml::from_str::<toml::Value>(t).ok()
1274 .and_then(|v| v.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()).map(String::from))
1275 }).unwrap_or_else(|| "trustee".to_string());
1276 let token_store = FileTokenStore::new(&agent_name);
1277
1278 if let Err(e) = token_store.save(cred_name, &stored) {
1279 tracing::error!("Failed to store MCP token: {}", e);
1280 return Ok(Redirect::temporary(&format!(
1281 "/?mcp_error={}",
1282 urlencoding::encode(&format!("Failed to store token: {}", e))
1283 ))
1284 .into_response());
1285 }
1286
1287 tracing::info!(
1288 "MCP authentication successful for credential '{}' (expires {})",
1289 cred_name, expires_at
1290 );
1291
1292 Ok(Response::builder()
1293 .status(StatusCode::FOUND)
1294 .header(header::LOCATION, format!("/?mcp_connected={}", urlencoding::encode(cred_name)))
1295 .body(Body::empty())
1296 .unwrap())
1297}
1298
1299async fn mcp_status_handler(
1301 State(state): State<crate::ServerState>,
1302 headers: axum::http::HeaderMap,
1303) -> Response {
1304 use pep::{FileTokenStore, TokenStore};
1305
1306 let (_cookie, user_key) = match crate::auth::check_auth(
1308 &state.auth,
1309 &headers,
1310 crate::auth::actions::VIEW_MCP_CREDENTIALS,
1311 )
1312 .await
1313 {
1314 Ok(result) => result,
1315 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1316 };
1317
1318 let config_toml = {
1320 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1321 let session = session_arc.lock().await;
1322 match &session.config_toml {
1323 Some(t) => t.clone(),
1324 None => return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response(),
1325 }
1326 };
1327
1328 let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1329 Ok(v) => v,
1330 Err(_) => return Json(serde_json::json!([])).into_response(),
1331 };
1332
1333 let agent_name = {
1334 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1335 let session = session_arc.lock().await;
1336 session.agent_name.clone()
1337 };
1338 let token_store = FileTokenStore::new(&agent_name);
1339
1340 let servers = mcp_config
1342 .get("mcp")
1343 .and_then(|m| m.get("servers"))
1344 .and_then(|s| s.as_array());
1345 let credentials = mcp_config
1346 .get("mcp")
1347 .and_then(|m| m.get("credentials"))
1348 .and_then(|c| c.as_table());
1349
1350 let mut cred_servers: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
1351 if let Some(servers) = servers {
1352 for server in servers {
1353 let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1354 let cred_ref = server.get("credentials").and_then(|c| c.as_str()).unwrap_or("");
1355 if !cred_ref.is_empty() {
1356 cred_servers
1357 .entry(cred_ref.to_string())
1358 .or_default()
1359 .push(name.to_string());
1360 }
1361 }
1362 }
1363
1364 let mut result = Vec::new();
1365
1366 if let Some(creds) = credentials {
1367 for (cred_name, cred_config) in creds {
1368 let cred_type = cred_config.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1369 let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1370
1371 if cred_type == "web-session" {
1372 let connected = state.auth.is_some();
1374 result.push(serde_json::json!({
1375 "credential": cred_name,
1376 "type": cred_type,
1377 "connected": connected,
1378 "servers": servers_using,
1379 }));
1380 } else if cred_type == "service-account" {
1381 let token = cred_config
1385 .get("service_token")
1386 .and_then(|t| t.as_str())
1387 .unwrap_or("");
1388 result.push(serde_json::json!({
1389 "credential": cred_name,
1390 "type": cred_type,
1391 "connected": !token.is_empty(),
1392 "servers": servers_using,
1393 }));
1394 } else if cred_type == "static" {
1395 let token = cred_config
1397 .get("token")
1398 .and_then(|t| t.as_str())
1399 .unwrap_or("");
1400 result.push(serde_json::json!({
1401 "credential": cred_name,
1402 "type": cred_type,
1403 "connected": !token.is_empty(),
1404 "servers": servers_using,
1405 }));
1406 } else if cred_type == "web-interactive" || cred_type == "interactive" {
1407 let status = match token_store.load(cred_name) {
1409 Ok(Some(token)) => {
1410 let expired = token.is_expired();
1411 serde_json::json!({
1412 "credential": cred_name,
1413 "type": cred_type,
1414 "connected": !expired,
1415 "expires_at": token.expires_at,
1416 "servers": servers_using,
1417 })
1418 }
1419 _ => serde_json::json!({
1420 "credential": cred_name,
1421 "type": cred_type,
1422 "connected": false,
1423 "servers": servers_using,
1424 }),
1425 };
1426 result.push(status);
1427 }
1428 }
1429 }
1430
1431 Json(serde_json::Value::Array(result)).into_response()
1432}
1433
1434async fn mcp_logout_handler(
1436 State(state): State<crate::ServerState>,
1437 Query(query): Query<McpLoginQuery>,
1438 headers: axum::http::HeaderMap,
1439) -> Response {
1440 use pep::{FileTokenStore, TokenStore};
1441
1442 let (_cookie, user_key) = match crate::auth::check_auth(
1444 &state.auth,
1445 &headers,
1446 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1447 )
1448 .await
1449 {
1450 Ok(result) => result,
1451 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1452 };
1453
1454 let agent_name = {
1455 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1456 let session = session_arc.lock().await;
1457 session.agent_name.clone()
1458 };
1459 let token_store = FileTokenStore::new(&agent_name);
1460
1461 match token_store.delete(&query.cred) {
1462 Ok(()) => {
1463 tracing::info!("Removed MCP credentials for '{}'", query.cred);
1464 Json(serde_json::json!({"success": true})).into_response()
1465 }
1466 Err(e) => {
1467 tracing::error!("Failed to remove MCP credentials: {}", e);
1468 (
1469 StatusCode::INTERNAL_SERVER_ERROR,
1470 Json(serde_json::json!({"error": e.to_string()})),
1471 )
1472 .into_response()
1473 }
1474 }
1475}
1476
1477struct McpPkceStore {
1484 entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1485}
1486
1487struct McpPkceEntry {
1488 verifier: String,
1489 cred_name: String,
1490 created_at: std::time::Instant,
1491}
1492
1493impl McpPkceStore {
1494 fn new() -> Self {
1495 Self {
1496 entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1497 }
1498 }
1499
1500 async fn insert(&self, state: String, verifier: String, cred_name: String) {
1502 let mut map = self.entries.lock().await;
1503 let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1505 map.retain(|_, v| v.created_at > cutoff);
1506 map.insert(state, McpPkceEntry {
1507 verifier,
1508 cred_name,
1509 created_at: std::time::Instant::now(),
1510 });
1511 }
1512
1513 async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1515 let mut map = self.entries.lock().await;
1516 map.remove(state)
1517 }
1518}
1519
1520static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1522
1523fn mcp_pkce() -> &'static McpPkceStore {
1525 MCP_PKCE.get_or_init(McpPkceStore::new)
1526}
1527
1528enum McpCredentialInfo {
1530 WebInteractive {
1531 issuer_url: String,
1532 client_id: String,
1533 client_secret: Option<String>,
1534 scope: String,
1535 },
1536 Other(String),
1537}
1538
1539async fn load_mcp_credential(
1541 state: &crate::ServerState,
1542 cred_name: &str,
1543) -> Result<McpCredentialInfo, AuthError> {
1544 let config_toml = state
1545 .config_toml
1546 .clone()
1547 .ok_or(AuthError::AuthNotConfigured)?;
1548
1549 let config: toml::Value = toml::from_str(&config_toml)
1550 .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1551
1552 let cred = config
1553 .get("mcp")
1554 .and_then(|m| m.get("credentials"))
1555 .and_then(|c| c.as_table())
1556 .and_then(|c| c.get(cred_name))
1557 .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1558
1559 let cred_type = cred.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1560
1561 match cred_type {
1562 "web-interactive" => {
1563 let issuer_url = cred
1564 .get("issuer_url")
1565 .and_then(|v| v.as_str())
1566 .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
1567 .to_string();
1568 let client_id = cred
1569 .get("client_id")
1570 .and_then(|v| v.as_str())
1571 .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
1572 .to_string();
1573 let client_secret = cred
1574 .get("client_secret")
1575 .and_then(|v| v.as_str())
1576 .map(String::from);
1577 let scope = cred
1578 .get("scope")
1579 .and_then(|v| v.as_str())
1580 .unwrap_or("openid profile email")
1581 .to_string();
1582
1583 Ok(McpCredentialInfo::WebInteractive {
1584 issuer_url,
1585 client_id,
1586 client_secret,
1587 scope,
1588 })
1589 }
1590 other => Ok(McpCredentialInfo::Other(other.to_string())),
1591 }
1592}
1593
1594fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
1600 Cookie::build((name.to_string(), value.to_string()))
1601 .path("/")
1602 .http_only(true)
1603 .same_site(SameSite::Lax)
1604 .secure(secure)
1605 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
1606 .build()
1607}
1608
1609#[derive(Debug)]
1615pub enum AuthError {
1616 MissingCode,
1617 MissingState,
1618 InvalidState,
1619 OidcError(String),
1620 TokenExchangeFailed(String),
1621 AuthNotConfigured,
1622}
1623
1624impl IntoResponse for AuthError {
1625 fn into_response(self) -> Response {
1626 let (_status, msg) = match self {
1627 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
1628 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
1629 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
1630 AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
1631 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
1632 AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
1633 };
1634 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
1635 }
1636}
1637
1638#[cfg(test)]
1639mod principal_tests {
1640 use super::*;
1641 use pep::oidc::types::JwtClaims;
1642 use std::collections::HashMap;
1643
1644 fn claims_with_role(role: serde_json::Value) -> JwtClaims {
1645 let mut c = JwtClaims::default();
1646 c.sub = "sub-uuid".to_string();
1647 c.preferred_username = Some("farzan".to_string());
1648 c.extra.insert("role".to_string(), role);
1649 c
1650 }
1651
1652 #[test]
1655 fn dev_agent_token_yields_agent_namespaced_key() {
1656 assert_eq!(
1657 dev_user_key("dev:agent:farzan"),
1658 Some("agent-farzan".to_string())
1659 );
1660 assert_eq!(
1661 dev_user_key("dev:agent:paydar"),
1662 Some("agent-paydar".to_string())
1663 );
1664 }
1665
1666 #[test]
1667 fn dev_agent_token_rejects_empty_and_colon_names() {
1668 assert_eq!(dev_user_key("dev:agent:"), None);
1669 assert_eq!(dev_user_key("dev:agent: "), None);
1670 assert_eq!(
1671 dev_user_key("dev:agent:a:b"),
1672 None,
1673 "name must not contain ':'"
1674 );
1675 }
1676
1677 #[test]
1678 fn dev_human_token_format_unchanged() {
1679 assert_eq!(
1680 dev_user_key("dev:a@b.c:Some Name:someuser"),
1681 Some("dev:a@b.c".to_string())
1682 );
1683 assert_eq!(dev_user_key("dev:only:two"), None);
1684 }
1685
1686 #[test]
1689 fn role_as_string_classifies_agent() {
1690 let c = claims_with_role(serde_json::json!("agent"));
1691 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1692 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1693 }
1694
1695 #[test]
1696 fn role_as_array_takes_first_value() {
1697 let c = claims_with_role(serde_json::json!(["agent", "other"]));
1699 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1700 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1701 }
1702
1703 #[test]
1704 fn non_agent_roles_classify_human() {
1705 for role in ["user", "admin", "service"] {
1706 let c = claims_with_role(serde_json::json!(role));
1707 assert_eq!(claim_role(&c).as_deref(), Some(role));
1708 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Human, "role={role}");
1709 }
1710 }
1711
1712 #[test]
1713 fn missing_or_nonstring_role_classifies_human() {
1714 let mut c = JwtClaims::default();
1715 c.sub = "sub-uuid".to_string();
1716 assert_eq!(claim_role(&c), None);
1717 assert_eq!(AuthUser::from(c.clone()).kind, PrincipalKind::Human);
1718 c.extra.insert("role".to_string(), serde_json::json!(42));
1719 assert_eq!(claim_role(&c), None, "non-string non-array role ignored");
1720 }
1721
1722 #[test]
1725 fn user_key_prefers_preferred_username() {
1726 let mut c = JwtClaims::default();
1727 c.sub = "sub-uuid".to_string();
1728 c.preferred_username = Some("farzan".to_string());
1729 c.email = Some("rebindable@example.com".to_string());
1730 assert_eq!(jwt_user_key(&c), "farzan", "email must never be the key");
1731 }
1732
1733 #[test]
1734 fn user_key_falls_back_to_sub_on_blank_username() {
1735 let mut c = JwtClaims::default();
1736 c.sub = "sub-uuid".to_string();
1737 c.preferred_username = Some(" ".to_string());
1738 assert_eq!(jwt_user_key(&c), "sub-uuid");
1739 c.preferred_username = None;
1740 assert_eq!(jwt_user_key(&c), "sub-uuid");
1741 }
1742
1743 #[test]
1744 fn user_key_agent_pinned_to_sub_even_with_username() {
1745 let mut c = claims_with_role(serde_json::json!("agent"));
1748 c.sub = "agent-sub-uuid".to_string();
1749 c.preferred_username = Some("farzan".to_string());
1750 assert_eq!(jwt_user_key(&c), "agent-sub-uuid");
1751 }
1752
1753 #[test]
1754 fn authuser_carries_role_and_kind() {
1755 let c = claims_with_role(serde_json::json!("agent"));
1756 let u = AuthUser::from(c);
1757 assert_eq!(u.role.as_deref(), Some("agent"));
1758 assert_eq!(u.kind, PrincipalKind::Agent);
1759 assert_eq!(u.username.as_deref(), Some("farzan"));
1760 }
1761}
1762
1763#[cfg(test)]
1764mod cedar_p2_tests {
1765 use super::*;
1766 use cedar_policy::{Context, Entities, EntityUid, Request};
1767 use pep::cedar::{CedarAuthorizer, CedarConfig};
1768 use std::collections::HashMap;
1769
1770 const POLICY: &str = include_str!("../policies/trustee_default.cedar");
1771 const SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
1772
1773 async fn authorizer() -> CedarAuthorizer {
1774 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1776 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1777 let dir = std::env::temp_dir().join(format!("trustee-cedar-p2-{}-{n}", std::process::id()));
1778 std::fs::create_dir_all(&dir).expect("temp dir");
1779 let policy_path = dir.join("trustee_default.cedar");
1780 let schema_path = dir.join("trustee_schema.cedarschema");
1781 std::fs::write(&policy_path, POLICY).expect("write policy");
1782 std::fs::write(&schema_path, SCHEMA).expect("write schema");
1783 let cfg = CedarConfig {
1784 policy_path,
1785 schema_path: Some(schema_path),
1786 entities_path: None,
1787 default_decision: pep::cedar::DefaultDecision::Deny,
1788 validate_on_load: true,
1789 policy_store_url: None,
1790 policy_store_token: None,
1791 embedded_policy: Some(POLICY),
1792 embedded_schema: Some(SCHEMA),
1793 };
1794 CedarAuthorizer::new_with_policy_store(cfg)
1795 .await
1796 .expect("Cedar init from shipped sources")
1797 }
1798
1799 fn claims_with_role(role: Option<&str>) -> JwtClaims {
1800 let mut c = JwtClaims::default();
1801 c.sub = "test-sub".to_string();
1802 if let Some(r) = role {
1803 c.extra.insert("role".to_string(), serde_json::json!(r));
1804 }
1805 c
1806 }
1807
1808 async fn allowed(auth: &CedarAuthorizer, role: Option<&str>, action: &str) -> bool {
1810 let claims = claims_with_role(role);
1811 let principal_entity = pep::cedar::build_principal_entity(&claims).unwrap();
1812 let app_entity = cedar_policy::Entity::new(
1813 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1814 HashMap::new(),
1815 std::collections::HashSet::new(),
1816 )
1817 .unwrap();
1818 let entities = Entities::from_entities(vec![principal_entity, app_entity], None).unwrap();
1819 let request = Request::new(
1820 pep::cedar::build_principal_uid(&claims).unwrap(),
1821 EntityUid::from_str(&format!("Action::\"{action}\"")).unwrap(),
1822 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1823 Context::empty(),
1824 None,
1825 )
1826 .unwrap();
1827 auth.is_allowed_with_entities(&request, &entities).allowed()
1828 }
1829
1830 #[tokio::test]
1831 async fn admin_allowed_including_destructive() {
1832 let auth = authorizer().await;
1833 for action in [
1834 actions::VIEW_SESSION,
1835 actions::COMMAND_SESSION,
1836 actions::DELETE_SESSION,
1837 actions::UPDATE_MCP_CREDENTIALS,
1838 ] {
1839 assert!(
1840 allowed(&auth, Some("admin"), action).await,
1841 "admin {action}"
1842 );
1843 }
1844 }
1845
1846 #[tokio::test]
1847 async fn user_full_session_management() {
1848 let auth = authorizer().await;
1849 for action in [
1850 actions::CREATE_SESSION,
1851 actions::COMMAND_SESSION,
1852 actions::DELETE_SESSION,
1853 actions::VIEW_HISTORY,
1854 actions::UPDATE_MCP_CREDENTIALS,
1855 ] {
1856 assert!(allowed(&auth, Some("user"), action).await, "user {action}");
1857 }
1858 }
1859
1860 #[tokio::test]
1861 async fn agent_working_set_but_delete_denied() {
1862 let auth = authorizer().await;
1863 for action in [
1864 actions::CREATE_SESSION,
1865 actions::COMMAND_SESSION,
1866 actions::CANCEL_SESSION,
1867 actions::RESUME_SESSION,
1868 actions::VIEW_HISTORY,
1869 actions::UPDATE_MCP_CREDENTIALS,
1870 ] {
1871 assert!(
1872 allowed(&auth, Some("agent"), action).await,
1873 "agent {action}"
1874 );
1875 }
1876 assert!(
1877 !allowed(&auth, Some("agent"), actions::DELETE_SESSION).await,
1878 "agent must NOT delete sessions (fail-closed start; revisit at task F)"
1879 );
1880 }
1881
1882 #[tokio::test]
1883 async fn service_read_only() {
1884 let auth = authorizer().await;
1885 for action in [
1886 actions::VIEW_SESSION,
1887 actions::LIST_SESSIONS,
1888 actions::VIEW_HISTORY,
1889 ] {
1890 assert!(allowed(&auth, Some("service"), action).await, "service {action}");
1891 }
1892 for action in [actions::COMMAND_SESSION, actions::DELETE_SESSION] {
1893 assert!(
1894 !allowed(&auth, Some("service"), action).await,
1895 "service {action} denied"
1896 );
1897 }
1898 }
1899
1900 #[tokio::test]
1901 async fn missing_or_unknown_role_denied_everything() {
1902 let auth = authorizer().await;
1903 for role in [None, Some("intern"), Some("Admin")] {
1904 assert!(
1905 !allowed(&auth, role, actions::VIEW_SESSION).await,
1906 "role={role:?} must be denied (fail-closed default)"
1907 );
1908 }
1909 }
1910
1911 #[test]
1912 fn boot_decision_is_fail_closed() {
1913 assert!(crate::cedar_boot_decision(true, false, false).is_err());
1914 assert!(crate::cedar_boot_decision(true, false, true).is_ok());
1915 assert!(crate::cedar_boot_decision(true, true, false).is_ok());
1916 assert!(crate::cedar_boot_decision(false, false, false).is_ok());
1917 }
1918}