Skip to main content

trustee_api/
auth.rs

1//! Authentication module for Trustee API.
2//!
3//! Uses PEP for OIDC/OAuth2:
4//! - `ResourceServerClient` for JWT validation (offline, cached JWKS)
5//! - `OidcClient` for authorization code + PKCE login flow
6//! - `PkceCookieManager` for stateless PKCE state (HMAC-signed cookies)
7//! - `DevConfig` for local development bypass
8//!
9//! Two deployment modes:
10//! - **Standalone**: browser hits /auth/login → IdP redirect → /auth/callback → cookie
11//! - **Centralized**: external auth app sends `Authorization: Bearer <token>` directly
12//!
13//! Token extraction order: `Authorization: Bearer` header → `trustee_token` cookie.
14
15use 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// ---------------------------------------------------------------------------
37// Configuration
38// ---------------------------------------------------------------------------
39
40/// Authentication configuration parsed from `[oidc]` and `[dev]` TOML sections.
41#[derive(Debug, Clone)]
42pub struct AuthConfig {
43    /// OIDC provider issuer URL
44    pub issuer_url: String,
45    /// OAuth2 client ID
46    pub client_id: String,
47    /// OAuth2 client secret (None → public client, PKCE only)
48    pub client_secret: Option<String>,
49    /// Redirect URI for OIDC callback
50    pub redirect_uri: String,
51    /// OAuth2 scopes
52    pub scope: String,
53    /// Token cookie name
54    pub cookie_name: String,
55    /// Development mode configuration
56    pub dev_config: DevConfig,
57    /// JWT validation options
58    pub validation_options: JwtValidationOptions,
59    /// Secret for signing PKCE state cookies
60    pub pkce_cookie_secret: String,
61}
62
63impl AuthConfig {
64    /// Parse auth config from the merged trustee TOML string.
65    ///
66    /// Reads `[oidc]` and `[dev]` sections. If neither is present, returns None
67    /// (auth disabled — all endpoints open).
68    pub fn from_toml(config_toml: &str) -> Option<Self> {
69        let table: toml::Table = toml::from_str(config_toml).ok()?;
70
71        // Check for dev mode
72        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        // Dev mode without OIDC — return early with dev-only config
82        if let Some(ref dc) = dev_config {
83            if dc.local_dev_mode {
84                // Try to get OIDC config too (for login endpoint), but it's optional in dev mode
85                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        // Production mode — requires [oidc] section
101        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    /// Parse the `[oidc]` section from a TOML table.
118    /// Returns (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret).
119    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")) // backward compat
130            .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    /// Build OIDC client configuration for PEP's OidcClient.
161    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/// Shared authentication state, stored in ServerState.
174#[derive(Clone)]
175pub struct AuthState {
176    /// OIDC client for login flow (authorization code + PKCE)
177    pub oidc_client: OidcClient,
178    /// Resource server client for JWT validation (lazy-initialized)
179    pub resource_server: ResourceServerClient,
180    /// OIDC client configuration
181    pub client_config: OidcClientConfig,
182    /// Auth configuration
183    pub config: AuthConfig,
184    /// Stateless PKCE cookie manager
185    pub pkce_manager: PkceCookieManager,
186    /// Web session manager (cookie session_id → server-side token with auto-refresh)
187    pub session_manager: Arc<WebSessionManager>,
188    /// Cedar authorizer for ABAC authorization (None = Cedar disabled)
189    pub cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
190}
191
192impl AuthState {
193    /// Create new auth state from configuration.
194    pub fn new(config: AuthConfig) -> Self {
195        Self::with_cedar(config, None)
196    }
197
198    /// Create new auth state with optional Cedar authorizer.
199    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    /// Check if development mode is enabled.
226    pub fn is_dev_mode(&self) -> bool {
227        self.config.dev_config.local_dev_mode
228    }
229
230    /// Validate a JWT token using PEP's ResourceServerClient.
231    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        // Enrich with userinfo for role/groups (cached, no-op if already present)
244        let _ = self
245            .resource_server
246            .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
247            .await;
248
249        // PEP only merges groups/role from userinfo. If name/email are missing
250        // (Kanidm JWTs only contain sub), fetch them from userinfo directly.
251        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    /// Fetch name/email/preferred_username from the OIDC userinfo endpoint
259    /// and fill in any that are missing from the JWT claims.
260    async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
261        // Derive userinfo URL from issuer
262        // For Kanidm: issuer_url is the discovery endpoint,
263        // userinfo is at {issuer_url}/userinfo
264        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    /// Check Cedar authorization for the authenticated user.
308    ///
309    /// Returns Ok(()) if allowed (or if Cedar is not configured).
310    /// Returns Err(()) if denied — caller should return 403 Forbidden.
311    fn check_cedar_authorized(&self, claims: &JwtClaims, action: &str) -> Result<(), ()> {
312        let Some(ref authorizer) = self.cedar_authorizer else {
313            return Ok(()); // Cedar not configured — allow
314        };
315
316        // Build principal entity from JWT claims
317        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        // Build entities set with principal + TrusteeApp resource
326        let mut entities_vec = vec![principal_entity];
327
328        // Add a TrusteeApp entity as the resource
329        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        // Build the Cedar authorization request
358        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
412// ---------------------------------------------------------------------------
413// Auth checking — called by protected route handlers
414// ---------------------------------------------------------------------------
415
416/// Cedar action names (P2, nghr 645809c3).
417///
418/// Keep in sync with `policies/trustee_schema.cedarschema` — the schema and
419/// the embedded policy ship atomically with these constants; a filesystem
420/// policy override referencing removed actions will deny everything (loud,
421/// by design).
422pub 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/// Principal kind (16D). `Agent` iff the enriched `role` claim contains
439/// "agent" — the exact value mapped by Kanidm's `pdt-api-agents` group
440/// (role vocabulary: admin | user | service | agent, facts doc 42977cb7).
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum PrincipalKind {
443    Human,
444    Agent,
445}
446
447impl PrincipalKind {
448    /// Classify from the primary role. Anything that is not exactly
449    /// "agent" is Human — fail-toward-human keeps the default posture
450    /// identical to pre-16D behavior.
451    pub fn from_role(role: Option<&str>) -> Self {
452        match role {
453            Some("agent") => Self::Agent,
454            _ => Self::Human,
455        }
456    }
457}
458
459/// Extract the primary role from enriched JWT claims.
460///
461/// PEP merges userinfo into `extra` (flattened claims). Kanidm delivers
462/// `role` as a STRING or an ARRAY (pep 366e8ed lesson) — accept both,
463/// first value wins.
464fn 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/// Authenticated user info extracted from the token.
477#[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    /// Primary role from enriched claims (16D).
485    pub role: Option<String>,
486    /// Principal classification (16D): Agent iff role == "agent".
487    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
506/// Cookie max-age for session cookies (1 hour, matching the server-side idle timeout).
507const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
508
509/// PINNED (16D): user_key for JWT principals.
510///
511/// `preferred_username` first, falling back to `sub`. NEVER email — email is
512/// rebindable in Kanidm and would silently re-home a principal's namespace.
513///
514/// Kanidm service accounts carry a stable, human-readable
515/// `preferred_username` (the agent name: "farzan", "paydar"), so agent
516/// principals land in `~/.trustee/users/{sha256(agent_name)[:8Bhex]}/` —
517/// the same isolation path as humans. This rule is pinned in the approved
518/// Kanidm identity facts doc (42977cb7): `preferred_username || sub`.
519///
520/// MIGRATION NOTE: this changed the key for existing JWT humans too (they
521/// were keyed by `sub` before 0.11.0). Kanidm humans have a
522/// `preferred_username`, so their namespace hash changes on first login —
523/// pre-web-production history under the old hash is orphaned, not deleted.
524fn 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
537/// Extract a user key from a dev-mode token string.
538///
539/// Two formats (BOTH gated by `local_dev_mode` at every call site):
540/// - `dev:agent:<name>` → `agent-{name}` (16D: agent namespace in dev —
541///   unblocks per-user cache + THQ E2E without Kanidm accounts; distinct
542///   prefix so dev agents can never collide with dev humans)
543/// - `dev:email:name:username` → `dev:{email}` (dev humans, legacy)
544fn 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
560/// Check authentication for a protected endpoint.
561///
562/// Returns `Ok((None, user_key))` if auth is not configured (open mode), or if
563/// a valid token is present without needing cookie renewal. Returns
564/// `Ok((Some(cookie), user_key))` if auth succeeded and the caller should
565/// include the given `Set-Cookie` header value in the response (rolling session).
566/// Returns `Err(StatusCode)` if auth is configured but no valid token is found.
567///
568/// The returned `user_key` is the identity string used for session isolation
569/// (JWT principals: `preferred_username || sub` — PINNED, see [`jwt_user_key`];
570/// `dev:{email}` for dev-mode humans, `agent-{name}` for `dev:agent:` tokens,
571/// `"default"` when auth is not configured). This avoids the need for handlers
572/// to call `resolve_user_key()` which would re-validate the JWT a second time.
573///
574/// Token sources (in order):
575/// 1. `Authorization: Bearer <token>` header (raw JWT — validated directly)
576/// 2. `trustee_token=<session_id>` cookie (looked up in WebSessionManager,
577///    auto-refreshed if near expiry)
578///
579/// Dev mode tokens use the format `dev:email:name:username`.
580pub 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())); // Auth not configured — allow
587    };
588
589    // 1. Try Bearer header first (raw JWT — e.g. from API clients, Torpi proxy)
590    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        // Dev mode token — only accepted when dev mode is currently enabled
597        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    // 2. Try cookie (session_id → WebSessionManager → access token with auto-refresh)
623    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    // Dev mode token in cookie — only accepted when dev mode is currently enabled
634    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    // Session-based: look up via WebSessionManager (auto-refreshes)
646    match auth.session_manager.get_token(&session_id).await {
647        Ok(access_token) => match auth.validate_token(&access_token).await {
648            Ok(claims) => {
649                // Cedar authorization check
650                if auth.check_cedar_authorized(&claims, action).is_err() {
651                    return Err(StatusCode::FORBIDDEN);
652                }
653                // Roll the cookie — reset max-age so active users stay logged in
654                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                // Token was returned but JWT validation failed (e.g. ExpiredSignature
665                // due to clock skew). Force-refresh and retry once.
666                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                            // Cedar authorization check
671                            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
702/// Extract a valid access token from the request (for use by handlers that
703/// need the token itself, not just auth checking).
704///
705/// Resolves session_id cookies to actual access tokens via WebSessionManager.
706/// Bearer headers are returned as-is.
707async fn resolve_access_token(
708    auth: &AuthState,
709    headers: &axum::http::HeaderMap,
710) -> Result<String, StatusCode> {
711    // Bearer header — return as-is
712    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    // Cookie — resolve session_id → access_token
722    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
744/// Extract token value from a cookie header string.
745fn 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
755// ---------------------------------------------------------------------------
756// Auth routes: /auth/login, /auth/callback, /auth/me, /auth/logout
757// ---------------------------------------------------------------------------
758
759/// Build the auth routes as a nested Router.
760pub 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/// Query parameters for OIDC callback.
773#[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
781/// GET /auth/login — initiate OIDC login with PKCE, or create dev session.
782async 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    // Dev mode — create synthetic session
788    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    // Production — redirect to IdP with PKCE
807    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    // Set PKCE state cookie (HttpOnly, SameSite=Lax)
817    // Secure flag follows the redirect_uri scheme — HTTP localhost/LAN must not
818    // set Secure or the browser drops the cookie and PKCE state is lost.
819    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
839/// GET /auth/callback — exchange authorization code for tokens, set cookie.
840async 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    // Check for errors from IdP
848    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    // Retrieve PKCE cookie
863    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    // Verify PKCE cookie (HMAC + expiry + state match)
871    let verifier = auth
872        .pkce_manager
873        .verify(&pkce_value, &oauth_state)
874        .ok_or(AuthError::InvalidState)?;
875
876    // Exchange code for tokens
877    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    // Cookie lifetime matches server-side idle timeout (1 hour).
891    // The cookie is rolled on every successful request via check_auth().
892    let max_age = SESSION_COOKIE_MAX_AGE;
893
894    // Set auth cookie — Secure only when redirect_uri is HTTPS
895    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    // Clear PKCE cookie (single-use)
899    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
917/// GET /auth/me — return current user info.
918async 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        // Auth not configured — always authenticated (no auth required)
924        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    // Also try Authorization: Bearer header
937    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    // Dev mode token (stored directly in cookie, no session manager)
954    // Only report as authenticated when dev mode is currently enabled
955    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    // Bearer header = raw JWT; Cookie value = session_id → resolve to access token
971    let access_token = if bearer.is_some() {
972        // Already have the raw token from Bearer header
973        cookie_value
974    } else {
975        // Cookie value is a session_id — resolve via WebSessionManager
976        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    // Real JWT — validate and return claims
990    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
1012/// POST /auth/logout — destroy session and clear auth cookie.
1013async 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    // Destroy the session on the server side
1024    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// ---------------------------------------------------------------------------
1050// MCP auth routes: /auth/mcp/login, /callback, /status, /logout (C2)
1051// ---------------------------------------------------------------------------
1052
1053/// Query parameters for MCP login initiation.
1054#[derive(Debug, Deserialize)]
1055pub struct McpLoginQuery {
1056    pub cred: String,
1057}
1058
1059/// Query parameters for MCP OIDC callback.
1060#[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
1068/// GET /auth/mcp/login?cred=<name> — initiate per-server OIDC PKCE login.
1069///
1070/// Reads the credential config from the session's config_toml, verifies it's
1071/// `type = "web-interactive"`, then redirects to the OIDC provider.
1072async 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    // Require authentication — user must be logged into trustee-web
1078    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    // Parse MCP credential config from session's config_toml
1089    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    // Build PKCE pair using a separate PkceCookieManager for MCP
1108    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    // Build OidcClientConfig for the MCP credential's OIDC client
1114    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    // Build authorization URL
1129    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    // Store PKCE state + credential name in the in-memory map
1135    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
1149/// GET /auth/mcp/callback — handle MCP OIDC callback, store tokens.
1150async 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    // Check for errors from IdP
1158    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    // Look up PKCE verifier + credential name from in-memory store
1173    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    // Parse the MCP credential config to get OIDC settings for token exchange
1180    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    // Build redirect URI (must match what was used in login)
1199    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    // Exchange code for tokens
1214    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    // Compute expires_at
1222    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    // Store via FileTokenStore (same as `trustee mcp auth`)
1247    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
1284/// GET /auth/mcp/status — return connection status for all MCP credentials.
1285async fn mcp_status_handler(
1286    State(state): State<crate::ServerState>,
1287    headers: axum::http::HeaderMap,
1288) -> Response {
1289    use pep::{FileTokenStore, TokenStore};
1290
1291    // Require auth
1292    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    // Parse MCP config from session
1304    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    // Build server → credential mapping
1326    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                // Session credentials are always "connected" if auth is enabled
1358                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                // Long-lived service token, exchanged lazily (RFC 8693) by the
1367                // agent at runtime. "Connected" = the service_token resolved to
1368                // a non-empty value in this (already ${VAR}-substituted) config.
1369                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                // Static token — connected when it resolved non-empty.
1381                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                // Check token store
1393                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
1419/// POST /auth/mcp/logout?cred=<name> — remove stored MCP tokens.
1420async 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    // Require auth
1428    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
1462// ---------------------------------------------------------------------------
1463// MCP auth helpers
1464// ---------------------------------------------------------------------------
1465
1466/// In-memory store for MCP PKCE state (state token → verifier + credential name).
1467/// Entries expire after 10 minutes. Not persisted across restarts.
1468struct 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    /// Insert a PKCE entry. Cleans up entries older than 10 minutes.
1486    async fn insert(&self, state: String, verifier: String, cred_name: String) {
1487        let mut map = self.entries.lock().await;
1488        // Cleanup expired entries (older than 10 min)
1489        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    /// Take and remove a PKCE entry (single-use).
1499    async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1500        let mut map = self.entries.lock().await;
1501        map.remove(state)
1502    }
1503}
1504
1505/// Global singleton PKCE store for MCP browser logins.
1506static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1507
1508/// Get or initialize the global MCP PKCE store.
1509fn mcp_pkce() -> &'static McpPkceStore {
1510    MCP_PKCE.get_or_init(McpPkceStore::new)
1511}
1512
1513/// Simplified MCP credential info (parsed from TOML).
1514enum 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
1524/// Load a specific MCP credential from the session's config_toml.
1525async 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
1579// ---------------------------------------------------------------------------
1580// Helpers
1581// ---------------------------------------------------------------------------
1582
1583/// Create an HttpOnly auth cookie.
1584fn 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// ---------------------------------------------------------------------------
1595// Error handling
1596// ---------------------------------------------------------------------------
1597
1598/// Authentication errors.
1599#[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    // -- dev_user_key (16D dev:agent namespace) ------------------------------
1638
1639    #[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    // -- claim_role / PrincipalKind (string OR array role) --------------------
1672
1673    #[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        // Kanidm may deliver role as an array (pep 366e8ed lesson).
1683        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    // -- jwt_user_key (PINNED: preferred_username || sub, never email) --------
1708
1709    #[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        // Unique per call: tests run concurrently and must not share files.
1750        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    /// Mirrors check_cedar_authorized's request construction exactly.
1784    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}