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