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 let _ = self
245 .resource_server
246 .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
247 .await;
248
249 if claims.name.is_none() || claims.email.is_none() {
252 self.fill_userinfo_fields(&mut claims, token).await;
253 }
254
255 Ok(claims)
256 }
257
258 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
261 let userinfo_url = format!("{}/userinfo", self.config.issuer_url.trim_end_matches('/'));
265
266 let client = reqwest::Client::new();
267 let resp = client
268 .get(&userinfo_url)
269 .header("Authorization", format!("Bearer {}", token))
270 .header("Accept", "application/json")
271 .send()
272 .await;
273
274 let Ok(resp) = resp else {
275 tracing::debug!("Userinfo request failed for name/email enrichment");
276 return;
277 };
278
279 if !resp.status().is_success() {
280 tracing::debug!("Userinfo returned {} for name/email enrichment", resp.status());
281 return;
282 }
283
284 let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await else {
285 return;
286 };
287
288 tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
289
290 if claims.name.is_none() {
291 if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
292 claims.name = Some(name.to_string());
293 }
294 }
295 if claims.email.is_none() {
296 if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
297 claims.email = Some(email.to_string());
298 }
299 }
300 if claims.preferred_username.is_none() {
301 if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
302 claims.preferred_username = Some(uname.to_string());
303 }
304 }
305 }
306
307 fn check_cedar_authorized(&self, claims: &JwtClaims, action: &str) -> Result<(), ()> {
312 let Some(ref authorizer) = self.cedar_authorizer else {
313 return Ok(()); };
315
316 let principal_entity = match pep::cedar::build_principal_entity(claims) {
318 Ok(e) => e,
319 Err(e) => {
320 tracing::error!("Cedar: failed to build principal entity: {}", e);
321 return Err(());
322 }
323 };
324
325 let mut entities_vec = vec![principal_entity];
327
328 let app_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
330 Ok(uid) => uid,
331 Err(e) => {
332 tracing::error!("Cedar: failed to build TrusteeApp uid: {}", e);
333 return Err(());
334 }
335 };
336 let app_entity = match cedar_policy::Entity::new(
337 app_uid,
338 std::collections::HashMap::new(),
339 std::collections::HashSet::new(),
340 ) {
341 Ok(e) => e,
342 Err(e) => {
343 tracing::error!("Cedar: failed to build TrusteeApp entity: {}", e);
344 return Err(());
345 }
346 };
347 entities_vec.push(app_entity);
348
349 let entities = match Entities::from_entities(entities_vec, None) {
350 Ok(e) => e,
351 Err(e) => {
352 tracing::error!("Cedar: failed to build entities set: {}", e);
353 return Err(());
354 }
355 };
356
357 let principal_uid = match pep::cedar::build_principal_uid(claims) {
359 Ok(uid) => uid,
360 Err(e) => {
361 tracing::error!("Cedar: failed to build principal uid: {}", e);
362 return Err(());
363 }
364 };
365
366 let action_uid = match EntityUid::from_str(&format!("Action::\"{action}\"")) {
367 Ok(uid) => uid,
368 Err(e) => {
369 tracing::error!("Cedar: failed to build action uid: {}", e);
370 return Err(());
371 }
372 };
373
374 let resource_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
375 Ok(uid) => uid,
376 Err(e) => {
377 tracing::error!("Cedar: failed to build resource uid: {}", e);
378 return Err(());
379 }
380 };
381
382 let request = match Request::new(principal_uid, action_uid, resource_uid, Context::empty(), None) {
383 Ok(r) => r,
384 Err(e) => {
385 tracing::error!("Cedar: failed to build request: {}", e);
386 return Err(());
387 }
388 };
389
390 let response = authorizer.is_allowed_with_entities(&request, &entities);
391
392 if response.allowed() {
393 tracing::debug!(
394 "Cedar: authorized user {} (sub={})",
395 claims.email.as_deref().unwrap_or("unknown"),
396 claims.sub
397 );
398 Ok(())
399 } else {
400 tracing::warn!(
401 "Cedar: DENIED user {} (sub={}) — matched policies: {:?}, errors: {:?}",
402 claims.email.as_deref().unwrap_or("unknown"),
403 claims.sub,
404 response.matched_policies(),
405 response.errors()
406 );
407 Err(())
408 }
409 }
410}
411
412pub mod actions {
423 pub const LIST_MODELS: &str = "ListModels";
424 pub const LIST_SESSIONS: &str = "ListSessions";
425 pub const VIEW_SESSION: &str = "ViewSession";
426 pub const VIEW_HISTORY: &str = "ViewHistory";
427 pub const CREATE_SESSION: &str = "CreateSession";
428 pub const COMMAND_SESSION: &str = "CommandSession";
429 pub const CANCEL_SESSION: &str = "CancelSession";
430 pub const HANDOFF_SESSION: &str = "HandoffSession";
431 pub const RESUME_SESSION: &str = "ResumeSession";
432 pub const UPDATE_SESSION: &str = "UpdateSession";
433 pub const DELETE_SESSION: &str = "DeleteSession";
434 pub const VIEW_MCP_CREDENTIALS: &str = "ViewMcpCredentials";
435 pub const UPDATE_MCP_CREDENTIALS: &str = "UpdateMcpCredentials";
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum PrincipalKind {
443 Human,
444 Agent,
445}
446
447impl PrincipalKind {
448 pub fn from_role(role: Option<&str>) -> Self {
452 match role {
453 Some("agent") => Self::Agent,
454 _ => Self::Human,
455 }
456 }
457}
458
459fn claim_role(claims: &JwtClaims) -> Option<String> {
465 match claims.extra.get("role") {
466 Some(serde_json::Value::String(s)) => Some(s.clone()),
467 Some(serde_json::Value::Array(arr)) => arr
468 .iter()
469 .filter_map(|v| v.as_str())
470 .next()
471 .map(|s| s.to_string()),
472 _ => None,
473 }
474}
475
476#[derive(Debug, Clone)]
478pub struct AuthUser {
479 pub sub: String,
480 pub email: Option<String>,
481 pub name: Option<String>,
482 pub username: Option<String>,
483 pub is_dev: bool,
484 pub role: Option<String>,
486 pub kind: PrincipalKind,
488}
489
490impl From<JwtClaims> for AuthUser {
491 fn from(claims: JwtClaims) -> Self {
492 let role = claim_role(&claims);
493 let kind = PrincipalKind::from_role(role.as_deref());
494 Self {
495 sub: claims.sub,
496 email: claims.email,
497 name: claims.name,
498 username: claims.preferred_username,
499 is_dev: false,
500 role,
501 kind,
502 }
503 }
504}
505
506const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
508
509fn jwt_user_key(claims: &JwtClaims) -> String {
525 match claims.preferred_username.as_deref() {
526 Some(u) if !u.trim().is_empty() => u.trim().to_string(),
527 _ => {
528 tracing::debug!(
529 "user_key: preferred_username missing/empty for sub {} — falling back to sub",
530 claims.sub
531 );
532 claims.sub.clone()
533 }
534 }
535}
536
537fn dev_user_key(token: &str) -> Option<String> {
545 if let Some(name) = token.strip_prefix("dev:agent:") {
546 let name = name.trim();
547 if name.is_empty() || name.contains(':') {
548 return None;
549 }
550 return Some(format!("agent-{name}"));
551 }
552 let parts: Vec<&str> = token.splitn(4, ':').collect();
553 if parts.len() >= 4 {
554 Some(format!("dev:{}", parts[1]))
555 } else {
556 None
557 }
558}
559
560pub async fn check_auth(
581 auth: &Option<Arc<AuthState>>,
582 headers: &axum::http::HeaderMap,
583 action: &str,
584) -> Result<(Option<String>, String), StatusCode> {
585 let Some(auth) = auth.as_ref() else {
586 return Ok((None, "default".to_string())); };
588
589 if let Some(token) = headers
591 .get(header::AUTHORIZATION)
592 .and_then(|v| v.to_str().ok())
593 .and_then(|v| v.strip_prefix("Bearer "))
594 .map(|s| s.to_string())
595 {
596 if token.starts_with("dev:") {
598 if !auth.config.dev_config.local_dev_mode {
599 tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
600 return Err(StatusCode::UNAUTHORIZED);
601 }
602 return match dev_user_key(&token) {
603 Some(key) => Ok((None, key)),
604 None => Err(StatusCode::UNAUTHORIZED),
605 };
606 }
607
608 return match auth.validate_token(&token).await {
609 Ok(claims) => {
610 if auth.check_cedar_authorized(&claims, action).is_err() {
611 return Err(StatusCode::FORBIDDEN);
612 }
613 Ok((None, jwt_user_key(&claims)))
614 }
615 Err(e) => {
616 tracing::warn!("Bearer token validation failed: {}", e);
617 Err(StatusCode::UNAUTHORIZED)
618 }
619 };
620 }
621
622 let session_id = headers
624 .get(header::COOKIE)
625 .and_then(|v| v.to_str().ok())
626 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
627
628 let Some(session_id) = session_id else {
629 tracing::warn!("No auth token found in request");
630 return Err(StatusCode::UNAUTHORIZED);
631 };
632
633 if session_id.starts_with("dev:") {
635 if !auth.config.dev_config.local_dev_mode {
636 tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
637 return Err(StatusCode::UNAUTHORIZED);
638 }
639 return match dev_user_key(&session_id) {
640 Some(key) => Ok((None, key)),
641 None => Err(StatusCode::UNAUTHORIZED),
642 };
643 }
644
645 match auth.session_manager.get_token(&session_id).await {
647 Ok(access_token) => match auth.validate_token(&access_token).await {
648 Ok(claims) => {
649 if auth.check_cedar_authorized(&claims, action).is_err() {
651 return Err(StatusCode::FORBIDDEN);
652 }
653 let secure = auth.client_config.redirect_uri.starts_with("https");
655 let cookie = create_auth_cookie(
656 &auth.config.cookie_name,
657 &session_id,
658 SESSION_COOKIE_MAX_AGE,
659 secure,
660 );
661 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
662 }
663 Err(e) => {
664 tracing::warn!("Session token validation failed: {} — attempting force-refresh", e);
667 match auth.session_manager.force_refresh(&session_id).await {
668 Ok(new_token) => match auth.validate_token(&new_token).await {
669 Ok(claims) => {
670 if auth.check_cedar_authorized(&claims, action).is_err() {
672 return Err(StatusCode::FORBIDDEN);
673 }
674 let secure = auth.client_config.redirect_uri.starts_with("https");
675 let cookie = create_auth_cookie(
676 &auth.config.cookie_name,
677 &session_id,
678 SESSION_COOKIE_MAX_AGE,
679 secure,
680 );
681 Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
682 }
683 Err(e2) => {
684 tracing::warn!("Session token still invalid after force-refresh: {}", e2);
685 Err(StatusCode::UNAUTHORIZED)
686 }
687 },
688 Err(e2) => {
689 tracing::warn!("Force-refresh failed: {}", e2);
690 Err(StatusCode::UNAUTHORIZED)
691 }
692 }
693 }
694 },
695 Err(e) => {
696 tracing::warn!("Session lookup/refresh failed: {}", e);
697 Err(StatusCode::UNAUTHORIZED)
698 }
699 }
700}
701
702async fn resolve_access_token(
708 auth: &AuthState,
709 headers: &axum::http::HeaderMap,
710) -> Result<String, StatusCode> {
711 if let Some(token) = headers
713 .get(header::AUTHORIZATION)
714 .and_then(|v| v.to_str().ok())
715 .and_then(|v| v.strip_prefix("Bearer "))
716 .map(|s| s.to_string())
717 {
718 return Ok(token);
719 }
720
721 let session_id = headers
723 .get(header::COOKIE)
724 .and_then(|v| v.to_str().ok())
725 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
726
727 match session_id {
728 Some(sid) if sid.starts_with("dev:") => {
729 if !auth.config.dev_config.local_dev_mode {
730 tracing::warn!("Dev cookie in resolve_access_token but dev mode is disabled — rejecting");
731 Err(StatusCode::UNAUTHORIZED)
732 } else {
733 Ok(sid)
734 }
735 }
736 Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
737 tracing::warn!("Failed to resolve session token: {}", e);
738 StatusCode::UNAUTHORIZED
739 }),
740 None => Err(StatusCode::UNAUTHORIZED),
741 }
742}
743
744fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
746 for cookie in cookie_header.split(';') {
747 let cookie = cookie.trim();
748 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
749 return Some(value.to_string());
750 }
751 }
752 None
753}
754
755pub fn auth_routes() -> axum::Router<crate::ServerState> {
761 axum::Router::new()
762 .route("/login", axum::routing::get(login_handler))
763 .route("/callback", axum::routing::get(callback_handler))
764 .route("/me", axum::routing::get(me_handler))
765 .route("/logout", axum::routing::post(logout_handler))
766 .route("/mcp/login", axum::routing::get(mcp_login_handler))
767 .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
768 .route("/mcp/status", axum::routing::get(mcp_status_handler))
769 .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
770}
771
772#[derive(Debug, Deserialize)]
774pub struct CallbackQuery {
775 pub code: Option<String>,
776 pub state: Option<String>,
777 pub error: Option<String>,
778 pub error_description: Option<String>,
779}
780
781async fn login_handler(
783 State(state): State<crate::ServerState>,
784) -> Result<Response, AuthError> {
785 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
786
787 if auth.is_dev_mode() {
789 tracing::info!("Dev mode: creating dev session");
790 let dev = &auth.config.dev_config;
791 let dev_token = format!(
792 "dev:{}:{}:{}",
793 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
794 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
795 dev.local_dev_username.as_deref().unwrap_or("dev")
796 );
797 let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
798 return Ok(Response::builder()
799 .status(StatusCode::FOUND)
800 .header(header::LOCATION, "/")
801 .header(header::SET_COOKIE, cookie.to_string())
802 .body(Body::empty())
803 .unwrap());
804 }
805
806 let pkce_session = auth.pkce_manager.create();
808 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
809
810 let auth_url = auth
811 .oidc_client
812 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
813 .await
814 .map_err(|e| AuthError::OidcError(e.to_string()))?;
815
816 let secure = auth.client_config.redirect_uri.starts_with("https");
820 let pkce_cookie = Cookie::build((
821 auth.pkce_manager.cookie_name().to_string(),
822 pkce_session.cookie_value,
823 ))
824 .path("/")
825 .http_only(true)
826 .same_site(SameSite::Lax)
827 .secure(secure)
828 .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
829 .build();
830
831 Ok(Response::builder()
832 .status(StatusCode::TEMPORARY_REDIRECT)
833 .header(header::LOCATION, &auth_url)
834 .header(header::SET_COOKIE, pkce_cookie.to_string())
835 .body(Body::empty())
836 .unwrap())
837}
838
839async fn callback_handler(
841 State(state): State<crate::ServerState>,
842 Query(query): Query<CallbackQuery>,
843 headers: axum::http::HeaderMap,
844) -> Result<Response, AuthError> {
845 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
846
847 if let Some(error) = query.error {
849 let desc = query.error_description.unwrap_or_default();
850 tracing::error!("OIDC error: {} - {}", error, desc);
851 return Ok(Redirect::temporary(&format!(
852 "/?error={}&error_description={}",
853 urlencoding::encode(&error),
854 urlencoding::encode(&desc)
855 ))
856 .into_response());
857 }
858
859 let code = query.code.ok_or(AuthError::MissingCode)?;
860 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
861
862 let cookie_header = headers
864 .get(header::COOKIE)
865 .and_then(|v| v.to_str().ok())
866 .unwrap_or("");
867 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
868 .ok_or(AuthError::InvalidState)?;
869
870 let verifier = auth
872 .pkce_manager
873 .verify(&pkce_value, &oauth_state)
874 .ok_or(AuthError::InvalidState)?;
875
876 tracing::info!("Exchanging authorization code for tokens");
878 let token_response = auth
879 .oidc_client
880 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
881 .await
882 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
883
884 let session_id = auth
885 .session_manager
886 .create_session(&token_response)
887 .await
888 .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
889
890 let max_age = SESSION_COOKIE_MAX_AGE;
893
894 let secure = auth.client_config.redirect_uri.starts_with("https");
896 let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
897
898 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
900 .path("/")
901 .http_only(true)
902 .same_site(SameSite::Lax)
903 .max_age(TimeDuration::seconds(-1))
904 .build();
905
906 tracing::info!("Authentication successful, redirecting to /");
907
908 Ok(Response::builder()
909 .status(StatusCode::FOUND)
910 .header(header::LOCATION, "/")
911 .header(header::SET_COOKIE, cookie.to_string())
912 .header(header::SET_COOKIE, clear_pkce.to_string())
913 .body(Body::empty())
914 .unwrap())
915}
916
917async fn me_handler(
919 State(state): State<crate::ServerState>,
920 headers: axum::http::HeaderMap,
921) -> Response {
922 let Some(ref auth) = state.auth else {
923 return axum::Json(serde_json::json!({
925 "authenticated": true,
926 "auth_enabled": false
927 }))
928 .into_response();
929 };
930
931 let cookie_header = headers
932 .get(header::COOKIE)
933 .and_then(|v| v.to_str().ok())
934 .unwrap_or("");
935
936 let bearer = headers
938 .get(header::AUTHORIZATION)
939 .and_then(|v| v.to_str().ok())
940 .and_then(|v| v.strip_prefix("Bearer "))
941 .map(String::from);
942
943 let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
944
945 let Some(cookie_value) = token else {
946 return axum::Json(serde_json::json!({
947 "authenticated": false,
948 "auth_enabled": true
949 }))
950 .into_response();
951 };
952
953 if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
956 let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
957 if parts.len() >= 4 {
958 return axum::Json(serde_json::json!({
959 "authenticated": true,
960 "auth_enabled": true,
961 "email": parts[1],
962 "name": parts[2],
963 "username": parts[3],
964 "dev_mode": true
965 }))
966 .into_response();
967 }
968 }
969
970 let access_token = if bearer.is_some() {
972 cookie_value
974 } else {
975 match auth.session_manager.get_token(&cookie_value).await {
977 Ok(token) => token,
978 Err(e) => {
979 tracing::debug!("Session token resolution failed for /auth/me: {}", e);
980 return axum::Json(serde_json::json!({
981 "authenticated": false,
982 "auth_enabled": true
983 }))
984 .into_response();
985 }
986 }
987 };
988
989 match auth.validate_token(&access_token).await {
991 Ok(claims) => axum::Json(serde_json::json!({
992 "authenticated": true,
993 "auth_enabled": true,
994 "sub": claims.sub,
995 "email": claims.email,
996 "name": claims.name,
997 "username": claims.preferred_username,
998 "dev_mode": false
999 }))
1000 .into_response(),
1001 Err(e) => {
1002 tracing::debug!("Token validation failed for /auth/me: {}", e);
1003 axum::Json(serde_json::json!({
1004 "authenticated": false,
1005 "auth_enabled": true
1006 }))
1007 .into_response()
1008 }
1009 }
1010}
1011
1012async fn logout_handler(
1014 State(state): State<crate::ServerState>,
1015 headers: axum::http::HeaderMap,
1016) -> Response {
1017 let cookie_name = state
1018 .auth
1019 .as_ref()
1020 .map(|a| a.config.cookie_name.as_str())
1021 .unwrap_or("trustee_token");
1022
1023 if let Some(ref auth) = state.auth {
1025 if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
1026 if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
1027 if !session_id.starts_with("dev:") {
1028 let _ = auth.session_manager.destroy_session(&session_id);
1029 }
1030 }
1031 }
1032 }
1033
1034 let cookie = Cookie::build((cookie_name.to_string(), ""))
1035 .path("/")
1036 .http_only(true)
1037 .same_site(SameSite::Lax)
1038 .max_age(TimeDuration::seconds(-1))
1039 .build();
1040
1041 Response::builder()
1042 .status(StatusCode::FOUND)
1043 .header(header::LOCATION, "/")
1044 .header(header::SET_COOKIE, cookie.to_string())
1045 .body(Body::empty())
1046 .unwrap()
1047}
1048
1049#[derive(Debug, Deserialize)]
1055pub struct McpLoginQuery {
1056 pub cred: String,
1057}
1058
1059#[derive(Debug, Deserialize)]
1061pub struct McpCallbackQuery {
1062 pub code: Option<String>,
1063 pub state: Option<String>,
1064 pub error: Option<String>,
1065 pub error_description: Option<String>,
1066}
1067
1068async fn mcp_login_handler(
1073 State(state): State<crate::ServerState>,
1074 Query(query): Query<McpLoginQuery>,
1075 headers: axum::http::HeaderMap,
1076) -> Result<Response, AuthError> {
1077 let (_cookie, _user_key) = crate::auth::check_auth(
1079 &state.auth,
1080 &headers,
1081 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1082 )
1083 .await
1084 .map_err(|_| AuthError::AuthNotConfigured)?;
1085
1086 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1087
1088 let cred_config = load_mcp_credential(&state, &query.cred).await?;
1090
1091 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1092 McpCredentialInfo::WebInteractive {
1093 issuer_url,
1094 client_id,
1095 client_secret,
1096 scope,
1097 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1098 _ => {
1099 return Ok(Redirect::temporary(&format!(
1100 "/?mcp_error={}",
1101 urlencoding::encode(&format!("Credential '{}' is not web-interactive type", query.cred))
1102 ))
1103 .into_response());
1104 }
1105 };
1106
1107 let oidc_client = OidcClient::new();
1109 let verifier = OidcClient::generate_code_verifier();
1110 let challenge = OidcClient::generate_code_challenge(&verifier);
1111 let oauth_state = OidcClient::generate_state();
1112
1113 let mcp_redirect_uri = format!(
1115 "{}/auth/mcp/callback",
1116 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1117 );
1118
1119 let mcp_client_config = OidcClientConfig {
1120 issuer_url: issuer_url.clone(),
1121 client_id: client_id.clone(),
1122 client_secret: client_secret.clone(),
1123 redirect_uri: mcp_redirect_uri.clone(),
1124 scope: scope.clone(),
1125 code_challenge_method: "S256".to_string(),
1126 };
1127
1128 let auth_url = oidc_client
1130 .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
1131 .await
1132 .map_err(|e| AuthError::OidcError(e.to_string()))?;
1133
1134 mcp_pkce().insert(oauth_state.clone(), verifier.clone(), query.cred.clone()).await;
1136
1137 tracing::info!(
1138 "Initiating MCP browser login for credential '{}' (issuer={})",
1139 query.cred, issuer_url
1140 );
1141
1142 Ok(Response::builder()
1143 .status(StatusCode::TEMPORARY_REDIRECT)
1144 .header(header::LOCATION, &auth_url)
1145 .body(Body::empty())
1146 .unwrap())
1147}
1148
1149async fn mcp_callback_handler(
1151 State(state): State<crate::ServerState>,
1152 Query(query): Query<McpCallbackQuery>,
1153 headers: axum::http::HeaderMap,
1154) -> Result<Response, AuthError> {
1155 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1156
1157 if let Some(error) = query.error {
1159 let desc = query.error_description.unwrap_or_default();
1160 tracing::error!("MCP OIDC error: {} - {}", error, desc);
1161 return Ok(Redirect::temporary(&format!(
1162 "/?mcp_error={}&error_description={}",
1163 urlencoding::encode(&error),
1164 urlencoding::encode(&desc)
1165 ))
1166 .into_response());
1167 }
1168
1169 let code = query.code.ok_or(AuthError::MissingCode)?;
1170 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1171
1172 let pkce_data = mcp_pkce().take(&oauth_state).await
1174 .ok_or(AuthError::InvalidState)?;
1175
1176 let verifier = pkce_data.verifier;
1177 let cred_name = &pkce_data.cred_name;
1178
1179 let cred_config = load_mcp_credential(&state, cred_name).await?;
1181
1182 let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1183 McpCredentialInfo::WebInteractive {
1184 issuer_url,
1185 client_id,
1186 client_secret,
1187 scope,
1188 } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
1189 _ => {
1190 return Ok(Redirect::temporary(&format!(
1191 "/?mcp_error={}",
1192 urlencoding::encode("Credential is not web-interactive type")
1193 ))
1194 .into_response());
1195 }
1196 };
1197
1198 let mcp_redirect_uri = format!(
1200 "{}/auth/mcp/callback",
1201 auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
1202 );
1203
1204 let mcp_client_config = OidcClientConfig {
1205 issuer_url: issuer_url.clone(),
1206 client_id: client_id.clone(),
1207 client_secret: client_secret.clone(),
1208 redirect_uri: mcp_redirect_uri,
1209 scope: scope.clone(),
1210 code_challenge_method: "S256".to_string(),
1211 };
1212
1213 tracing::info!("Exchanging MCP authorization code for tokens (credential={})", cred_name);
1215 let oidc_client = OidcClient::new();
1216 let token_response = oidc_client
1217 .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
1218 .await
1219 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1220
1221 let expires_at = {
1223 let now = std::time::SystemTime::now()
1224 .duration_since(std::time::UNIX_EPOCH)
1225 .unwrap_or_default()
1226 .as_secs();
1227 let expires_epoch = now + token_response.expires_in.unwrap_or(900);
1228 let days = expires_epoch / 86400;
1229 let rem = expires_epoch % 86400;
1230 let h = rem / 3600;
1231 let m = (rem % 3600) / 60;
1232 let s = rem % 60;
1233 let z = days as i64 + 719468;
1234 let era = if z >= 0 { z } else { z - 146096 } / 146097;
1235 let doe = (z - era * 146097) as u64;
1236 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1237 let y = yoe as i64 + era * 400;
1238 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1239 let mp = (5 * doy + 2) / 153;
1240 let d = doy - (153 * mp + 2) / 5 + 1;
1241 let mon = if mp < 10 { mp + 3 } else { mp - 9 };
1242 let yr = if mon <= 2 { y + 1 } else { y };
1243 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
1244 };
1245
1246 use pep::{FileTokenStore, StoredToken, TokenStore};
1248
1249 let stored = StoredToken::new(
1250 &token_response.access_token,
1251 token_response.refresh_token.clone(),
1252 "Bearer",
1253 &expires_at,
1254 token_response.scope.clone(),
1255 );
1256
1257 let agent_name = state.config_toml.as_ref().and_then(|t| {
1258 toml::from_str::<toml::Value>(t).ok()
1259 .and_then(|v| v.get("agent").and_then(|a| a.get("name")).and_then(|n| n.as_str()).map(String::from))
1260 }).unwrap_or_else(|| "trustee".to_string());
1261 let token_store = FileTokenStore::new(&agent_name);
1262
1263 if let Err(e) = token_store.save(cred_name, &stored) {
1264 tracing::error!("Failed to store MCP token: {}", e);
1265 return Ok(Redirect::temporary(&format!(
1266 "/?mcp_error={}",
1267 urlencoding::encode(&format!("Failed to store token: {}", e))
1268 ))
1269 .into_response());
1270 }
1271
1272 tracing::info!(
1273 "MCP authentication successful for credential '{}' (expires {})",
1274 cred_name, expires_at
1275 );
1276
1277 Ok(Response::builder()
1278 .status(StatusCode::FOUND)
1279 .header(header::LOCATION, format!("/?mcp_connected={}", urlencoding::encode(cred_name)))
1280 .body(Body::empty())
1281 .unwrap())
1282}
1283
1284async fn mcp_status_handler(
1286 State(state): State<crate::ServerState>,
1287 headers: axum::http::HeaderMap,
1288) -> Response {
1289 use pep::{FileTokenStore, TokenStore};
1290
1291 let (_cookie, user_key) = match crate::auth::check_auth(
1293 &state.auth,
1294 &headers,
1295 crate::auth::actions::VIEW_MCP_CREDENTIALS,
1296 )
1297 .await
1298 {
1299 Ok(result) => result,
1300 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1301 };
1302
1303 let config_toml = {
1305 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1306 let session = session_arc.lock().await;
1307 match &session.config_toml {
1308 Some(t) => t.clone(),
1309 None => return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response(),
1310 }
1311 };
1312
1313 let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1314 Ok(v) => v,
1315 Err(_) => return Json(serde_json::json!([])).into_response(),
1316 };
1317
1318 let agent_name = {
1319 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1320 let session = session_arc.lock().await;
1321 session.agent_name.clone()
1322 };
1323 let token_store = FileTokenStore::new(&agent_name);
1324
1325 let servers = mcp_config
1327 .get("mcp")
1328 .and_then(|m| m.get("servers"))
1329 .and_then(|s| s.as_array());
1330 let credentials = mcp_config
1331 .get("mcp")
1332 .and_then(|m| m.get("credentials"))
1333 .and_then(|c| c.as_table());
1334
1335 let mut cred_servers: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
1336 if let Some(servers) = servers {
1337 for server in servers {
1338 let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1339 let cred_ref = server.get("credentials").and_then(|c| c.as_str()).unwrap_or("");
1340 if !cred_ref.is_empty() {
1341 cred_servers
1342 .entry(cred_ref.to_string())
1343 .or_default()
1344 .push(name.to_string());
1345 }
1346 }
1347 }
1348
1349 let mut result = Vec::new();
1350
1351 if let Some(creds) = credentials {
1352 for (cred_name, cred_config) in creds {
1353 let cred_type = cred_config.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1354 let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1355
1356 if cred_type == "web-session" {
1357 let connected = state.auth.is_some();
1359 result.push(serde_json::json!({
1360 "credential": cred_name,
1361 "type": cred_type,
1362 "connected": connected,
1363 "servers": servers_using,
1364 }));
1365 } else if cred_type == "service-account" {
1366 let token = cred_config
1370 .get("service_token")
1371 .and_then(|t| t.as_str())
1372 .unwrap_or("");
1373 result.push(serde_json::json!({
1374 "credential": cred_name,
1375 "type": cred_type,
1376 "connected": !token.is_empty(),
1377 "servers": servers_using,
1378 }));
1379 } else if cred_type == "static" {
1380 let token = cred_config
1382 .get("token")
1383 .and_then(|t| t.as_str())
1384 .unwrap_or("");
1385 result.push(serde_json::json!({
1386 "credential": cred_name,
1387 "type": cred_type,
1388 "connected": !token.is_empty(),
1389 "servers": servers_using,
1390 }));
1391 } else if cred_type == "web-interactive" || cred_type == "interactive" {
1392 let status = match token_store.load(cred_name) {
1394 Ok(Some(token)) => {
1395 let expired = token.is_expired();
1396 serde_json::json!({
1397 "credential": cred_name,
1398 "type": cred_type,
1399 "connected": !expired,
1400 "expires_at": token.expires_at,
1401 "servers": servers_using,
1402 })
1403 }
1404 _ => serde_json::json!({
1405 "credential": cred_name,
1406 "type": cred_type,
1407 "connected": false,
1408 "servers": servers_using,
1409 }),
1410 };
1411 result.push(status);
1412 }
1413 }
1414 }
1415
1416 Json(serde_json::Value::Array(result)).into_response()
1417}
1418
1419async fn mcp_logout_handler(
1421 State(state): State<crate::ServerState>,
1422 Query(query): Query<McpLoginQuery>,
1423 headers: axum::http::HeaderMap,
1424) -> Response {
1425 use pep::{FileTokenStore, TokenStore};
1426
1427 let (_cookie, user_key) = match crate::auth::check_auth(
1429 &state.auth,
1430 &headers,
1431 crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1432 )
1433 .await
1434 {
1435 Ok(result) => result,
1436 Err(code) => return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response(),
1437 };
1438
1439 let agent_name = {
1440 let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1441 let session = session_arc.lock().await;
1442 session.agent_name.clone()
1443 };
1444 let token_store = FileTokenStore::new(&agent_name);
1445
1446 match token_store.delete(&query.cred) {
1447 Ok(()) => {
1448 tracing::info!("Removed MCP credentials for '{}'", query.cred);
1449 Json(serde_json::json!({"success": true})).into_response()
1450 }
1451 Err(e) => {
1452 tracing::error!("Failed to remove MCP credentials: {}", e);
1453 (
1454 StatusCode::INTERNAL_SERVER_ERROR,
1455 Json(serde_json::json!({"error": e.to_string()})),
1456 )
1457 .into_response()
1458 }
1459 }
1460}
1461
1462struct McpPkceStore {
1469 entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1470}
1471
1472struct McpPkceEntry {
1473 verifier: String,
1474 cred_name: String,
1475 created_at: std::time::Instant,
1476}
1477
1478impl McpPkceStore {
1479 fn new() -> Self {
1480 Self {
1481 entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1482 }
1483 }
1484
1485 async fn insert(&self, state: String, verifier: String, cred_name: String) {
1487 let mut map = self.entries.lock().await;
1488 let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1490 map.retain(|_, v| v.created_at > cutoff);
1491 map.insert(state, McpPkceEntry {
1492 verifier,
1493 cred_name,
1494 created_at: std::time::Instant::now(),
1495 });
1496 }
1497
1498 async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1500 let mut map = self.entries.lock().await;
1501 map.remove(state)
1502 }
1503}
1504
1505static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1507
1508fn mcp_pkce() -> &'static McpPkceStore {
1510 MCP_PKCE.get_or_init(McpPkceStore::new)
1511}
1512
1513enum McpCredentialInfo {
1515 WebInteractive {
1516 issuer_url: String,
1517 client_id: String,
1518 client_secret: Option<String>,
1519 scope: String,
1520 },
1521 Other(String),
1522}
1523
1524async fn load_mcp_credential(
1526 state: &crate::ServerState,
1527 cred_name: &str,
1528) -> Result<McpCredentialInfo, AuthError> {
1529 let config_toml = state
1530 .config_toml
1531 .clone()
1532 .ok_or(AuthError::AuthNotConfigured)?;
1533
1534 let config: toml::Value = toml::from_str(&config_toml)
1535 .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1536
1537 let cred = config
1538 .get("mcp")
1539 .and_then(|m| m.get("credentials"))
1540 .and_then(|c| c.as_table())
1541 .and_then(|c| c.get(cred_name))
1542 .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1543
1544 let cred_type = cred.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1545
1546 match cred_type {
1547 "web-interactive" => {
1548 let issuer_url = cred
1549 .get("issuer_url")
1550 .and_then(|v| v.as_str())
1551 .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
1552 .to_string();
1553 let client_id = cred
1554 .get("client_id")
1555 .and_then(|v| v.as_str())
1556 .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
1557 .to_string();
1558 let client_secret = cred
1559 .get("client_secret")
1560 .and_then(|v| v.as_str())
1561 .map(String::from);
1562 let scope = cred
1563 .get("scope")
1564 .and_then(|v| v.as_str())
1565 .unwrap_or("openid profile email")
1566 .to_string();
1567
1568 Ok(McpCredentialInfo::WebInteractive {
1569 issuer_url,
1570 client_id,
1571 client_secret,
1572 scope,
1573 })
1574 }
1575 other => Ok(McpCredentialInfo::Other(other.to_string())),
1576 }
1577}
1578
1579fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
1585 Cookie::build((name.to_string(), value.to_string()))
1586 .path("/")
1587 .http_only(true)
1588 .same_site(SameSite::Lax)
1589 .secure(secure)
1590 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
1591 .build()
1592}
1593
1594#[derive(Debug)]
1600pub enum AuthError {
1601 MissingCode,
1602 MissingState,
1603 InvalidState,
1604 OidcError(String),
1605 TokenExchangeFailed(String),
1606 AuthNotConfigured,
1607}
1608
1609impl IntoResponse for AuthError {
1610 fn into_response(self) -> Response {
1611 let (_status, msg) = match self {
1612 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
1613 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
1614 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
1615 AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
1616 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
1617 AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
1618 };
1619 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
1620 }
1621}
1622
1623#[cfg(test)]
1624mod principal_tests {
1625 use super::*;
1626 use pep::oidc::types::JwtClaims;
1627 use std::collections::HashMap;
1628
1629 fn claims_with_role(role: serde_json::Value) -> JwtClaims {
1630 let mut c = JwtClaims::default();
1631 c.sub = "sub-uuid".to_string();
1632 c.preferred_username = Some("farzan".to_string());
1633 c.extra.insert("role".to_string(), role);
1634 c
1635 }
1636
1637 #[test]
1640 fn dev_agent_token_yields_agent_namespaced_key() {
1641 assert_eq!(
1642 dev_user_key("dev:agent:farzan"),
1643 Some("agent-farzan".to_string())
1644 );
1645 assert_eq!(
1646 dev_user_key("dev:agent:paydar"),
1647 Some("agent-paydar".to_string())
1648 );
1649 }
1650
1651 #[test]
1652 fn dev_agent_token_rejects_empty_and_colon_names() {
1653 assert_eq!(dev_user_key("dev:agent:"), None);
1654 assert_eq!(dev_user_key("dev:agent: "), None);
1655 assert_eq!(
1656 dev_user_key("dev:agent:a:b"),
1657 None,
1658 "name must not contain ':'"
1659 );
1660 }
1661
1662 #[test]
1663 fn dev_human_token_format_unchanged() {
1664 assert_eq!(
1665 dev_user_key("dev:a@b.c:Some Name:someuser"),
1666 Some("dev:a@b.c".to_string())
1667 );
1668 assert_eq!(dev_user_key("dev:only:two"), None);
1669 }
1670
1671 #[test]
1674 fn role_as_string_classifies_agent() {
1675 let c = claims_with_role(serde_json::json!("agent"));
1676 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1677 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1678 }
1679
1680 #[test]
1681 fn role_as_array_takes_first_value() {
1682 let c = claims_with_role(serde_json::json!(["agent", "other"]));
1684 assert_eq!(claim_role(&c).as_deref(), Some("agent"));
1685 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
1686 }
1687
1688 #[test]
1689 fn non_agent_roles_classify_human() {
1690 for role in ["user", "admin", "service"] {
1691 let c = claims_with_role(serde_json::json!(role));
1692 assert_eq!(claim_role(&c).as_deref(), Some(role));
1693 assert_eq!(AuthUser::from(c).kind, PrincipalKind::Human, "role={role}");
1694 }
1695 }
1696
1697 #[test]
1698 fn missing_or_nonstring_role_classifies_human() {
1699 let mut c = JwtClaims::default();
1700 c.sub = "sub-uuid".to_string();
1701 assert_eq!(claim_role(&c), None);
1702 assert_eq!(AuthUser::from(c.clone()).kind, PrincipalKind::Human);
1703 c.extra.insert("role".to_string(), serde_json::json!(42));
1704 assert_eq!(claim_role(&c), None, "non-string non-array role ignored");
1705 }
1706
1707 #[test]
1710 fn user_key_prefers_preferred_username() {
1711 let mut c = JwtClaims::default();
1712 c.sub = "sub-uuid".to_string();
1713 c.preferred_username = Some("farzan".to_string());
1714 c.email = Some("rebindable@example.com".to_string());
1715 assert_eq!(jwt_user_key(&c), "farzan", "email must never be the key");
1716 }
1717
1718 #[test]
1719 fn user_key_falls_back_to_sub_on_blank_username() {
1720 let mut c = JwtClaims::default();
1721 c.sub = "sub-uuid".to_string();
1722 c.preferred_username = Some(" ".to_string());
1723 assert_eq!(jwt_user_key(&c), "sub-uuid");
1724 c.preferred_username = None;
1725 assert_eq!(jwt_user_key(&c), "sub-uuid");
1726 }
1727
1728 #[test]
1729 fn authuser_carries_role_and_kind() {
1730 let c = claims_with_role(serde_json::json!("agent"));
1731 let u = AuthUser::from(c);
1732 assert_eq!(u.role.as_deref(), Some("agent"));
1733 assert_eq!(u.kind, PrincipalKind::Agent);
1734 assert_eq!(u.username.as_deref(), Some("farzan"));
1735 }
1736}
1737
1738#[cfg(test)]
1739mod cedar_p2_tests {
1740 use super::*;
1741 use cedar_policy::{Context, Entities, EntityUid, Request};
1742 use pep::cedar::{CedarAuthorizer, CedarConfig};
1743 use std::collections::HashMap;
1744
1745 const POLICY: &str = include_str!("../policies/trustee_default.cedar");
1746 const SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
1747
1748 async fn authorizer() -> CedarAuthorizer {
1749 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1751 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1752 let dir = std::env::temp_dir().join(format!("trustee-cedar-p2-{}-{n}", std::process::id()));
1753 std::fs::create_dir_all(&dir).expect("temp dir");
1754 let policy_path = dir.join("trustee_default.cedar");
1755 let schema_path = dir.join("trustee_schema.cedarschema");
1756 std::fs::write(&policy_path, POLICY).expect("write policy");
1757 std::fs::write(&schema_path, SCHEMA).expect("write schema");
1758 let cfg = CedarConfig {
1759 policy_path,
1760 schema_path: Some(schema_path),
1761 entities_path: None,
1762 default_decision: pep::cedar::DefaultDecision::Deny,
1763 validate_on_load: true,
1764 policy_store_url: None,
1765 policy_store_token: None,
1766 embedded_policy: Some(POLICY),
1767 embedded_schema: Some(SCHEMA),
1768 };
1769 CedarAuthorizer::new_with_policy_store(cfg)
1770 .await
1771 .expect("Cedar init from shipped sources")
1772 }
1773
1774 fn claims_with_role(role: Option<&str>) -> JwtClaims {
1775 let mut c = JwtClaims::default();
1776 c.sub = "test-sub".to_string();
1777 if let Some(r) = role {
1778 c.extra.insert("role".to_string(), serde_json::json!(r));
1779 }
1780 c
1781 }
1782
1783 async fn allowed(auth: &CedarAuthorizer, role: Option<&str>, action: &str) -> bool {
1785 let claims = claims_with_role(role);
1786 let principal_entity = pep::cedar::build_principal_entity(&claims).unwrap();
1787 let app_entity = cedar_policy::Entity::new(
1788 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1789 HashMap::new(),
1790 std::collections::HashSet::new(),
1791 )
1792 .unwrap();
1793 let entities = Entities::from_entities(vec![principal_entity, app_entity], None).unwrap();
1794 let request = Request::new(
1795 pep::cedar::build_principal_uid(&claims).unwrap(),
1796 EntityUid::from_str(&format!("Action::\"{action}\"")).unwrap(),
1797 EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
1798 Context::empty(),
1799 None,
1800 )
1801 .unwrap();
1802 auth.is_allowed_with_entities(&request, &entities).allowed()
1803 }
1804
1805 #[tokio::test]
1806 async fn admin_allowed_including_destructive() {
1807 let auth = authorizer().await;
1808 for action in [
1809 actions::VIEW_SESSION,
1810 actions::COMMAND_SESSION,
1811 actions::DELETE_SESSION,
1812 actions::UPDATE_MCP_CREDENTIALS,
1813 ] {
1814 assert!(
1815 allowed(&auth, Some("admin"), action).await,
1816 "admin {action}"
1817 );
1818 }
1819 }
1820
1821 #[tokio::test]
1822 async fn user_full_session_management() {
1823 let auth = authorizer().await;
1824 for action in [
1825 actions::CREATE_SESSION,
1826 actions::COMMAND_SESSION,
1827 actions::DELETE_SESSION,
1828 actions::VIEW_HISTORY,
1829 actions::UPDATE_MCP_CREDENTIALS,
1830 ] {
1831 assert!(allowed(&auth, Some("user"), action).await, "user {action}");
1832 }
1833 }
1834
1835 #[tokio::test]
1836 async fn agent_working_set_but_delete_denied() {
1837 let auth = authorizer().await;
1838 for action in [
1839 actions::CREATE_SESSION,
1840 actions::COMMAND_SESSION,
1841 actions::CANCEL_SESSION,
1842 actions::RESUME_SESSION,
1843 actions::VIEW_HISTORY,
1844 actions::UPDATE_MCP_CREDENTIALS,
1845 ] {
1846 assert!(
1847 allowed(&auth, Some("agent"), action).await,
1848 "agent {action}"
1849 );
1850 }
1851 assert!(
1852 !allowed(&auth, Some("agent"), actions::DELETE_SESSION).await,
1853 "agent must NOT delete sessions (fail-closed start; revisit at task F)"
1854 );
1855 }
1856
1857 #[tokio::test]
1858 async fn service_read_only() {
1859 let auth = authorizer().await;
1860 for action in [
1861 actions::VIEW_SESSION,
1862 actions::LIST_SESSIONS,
1863 actions::VIEW_HISTORY,
1864 ] {
1865 assert!(allowed(&auth, Some("service"), action).await, "service {action}");
1866 }
1867 for action in [actions::COMMAND_SESSION, actions::DELETE_SESSION] {
1868 assert!(
1869 !allowed(&auth, Some("service"), action).await,
1870 "service {action} denied"
1871 );
1872 }
1873 }
1874
1875 #[tokio::test]
1876 async fn missing_or_unknown_role_denied_everything() {
1877 let auth = authorizer().await;
1878 for role in [None, Some("intern"), Some("Admin")] {
1879 assert!(
1880 !allowed(&auth, role, actions::VIEW_SESSION).await,
1881 "role={role:?} must be denied (fail-closed default)"
1882 );
1883 }
1884 }
1885
1886 #[test]
1887 fn boot_decision_is_fail_closed() {
1888 assert!(crate::cedar_boot_decision(true, false, false).is_err());
1889 assert!(crate::cedar_boot_decision(true, false, true).is_ok());
1890 assert!(crate::cedar_boot_decision(true, true, false).is_ok());
1891 assert!(crate::cedar_boot_decision(false, false, false).is_ok());
1892 }
1893}