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::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use std::sync::Mutex;
18use std::time::Duration as StdDuration;
19
20use axum::{
21    body::Body,
22    extract::{Query, State},
23    http::{header, StatusCode},
24    response::{IntoResponse, Json, Redirect, Response},
25};
26use axum_extra::extract::cookie::{Cookie, SameSite};
27use pep::oidc::pkce_cookie::PkceCookieManager;
28use pep::oidc_client::OidcClient;
29use pep::oidc_resource_server::ResourceServerClient;
30use pep::session_manager::WebSessionManager;
31use pep::{DevConfig, JwtClaims, JwtValidationOptions, OidcClientConfig};
32use serde::Deserialize;
33use sha2::{Digest, Sha256};
34use time::Duration as TimeDuration;
35
36use cedar_policy::{Context, Entities, EntityUid, Request};
37use std::str::FromStr;
38
39// ---------------------------------------------------------------------------
40// Configuration
41// ---------------------------------------------------------------------------
42
43/// Authentication configuration parsed from `[oidc]` and `[dev]` TOML sections.
44#[derive(Debug, Clone)]
45pub struct AuthConfig {
46    /// OIDC provider issuer URL
47    pub issuer_url: String,
48    /// OAuth2 client ID
49    pub client_id: String,
50    /// OAuth2 client secret (None → public client, PKCE only)
51    pub client_secret: Option<String>,
52    /// Redirect URI for OIDC callback
53    pub redirect_uri: String,
54    /// OAuth2 scopes
55    pub scope: String,
56    /// Token cookie name
57    pub cookie_name: String,
58    /// Development mode configuration
59    pub dev_config: DevConfig,
60    /// JWT validation options
61    pub validation_options: JwtValidationOptions,
62    /// Secret for signing PKCE state cookies
63    pub pkce_cookie_secret: String,
64}
65
66impl AuthConfig {
67    /// Parse auth config from the merged trustee TOML string.
68    ///
69    /// Reads `[oidc]` and `[dev]` sections. If neither is present, returns None
70    /// (auth disabled — all endpoints open).
71    pub fn from_toml(config_toml: &str) -> Option<Self> {
72        let table: toml::Table = toml::from_str(config_toml).ok()?;
73
74        // Check for dev mode
75        let dev_config = table
76            .get("dev")
77            .and_then(|d| d.as_table())
78            .map(|d| DevConfig {
79                local_dev_mode: d
80                    .get("local_dev_mode")
81                    .and_then(|v| v.as_bool())
82                    .unwrap_or(false),
83                local_dev_email: d
84                    .get("local_dev_email")
85                    .and_then(|v| v.as_str())
86                    .map(String::from),
87                local_dev_name: d
88                    .get("local_dev_name")
89                    .and_then(|v| v.as_str())
90                    .map(String::from),
91                local_dev_username: d
92                    .get("local_dev_username")
93                    .and_then(|v| v.as_str())
94                    .map(String::from),
95            });
96
97        // Dev mode without OIDC — return early with dev-only config
98        if let Some(ref dc) = dev_config {
99            if dc.local_dev_mode {
100                // Try to get OIDC config too (for login endpoint), but it's optional in dev mode
101                let oidc = Self::parse_oidc_section(&table);
102                return Some(Self {
103                    issuer_url: oidc
104                        .as_ref()
105                        .map(|o| o.0.clone())
106                        .unwrap_or_else(|| "https://auth.example.com".into()),
107                    client_id: oidc
108                        .as_ref()
109                        .map(|o| o.1.clone())
110                        .unwrap_or_else(|| "trustee".into()),
111                    client_secret: oidc.as_ref().and_then(|o| o.2.clone()),
112                    redirect_uri: oidc
113                        .as_ref()
114                        .map(|o| o.3.clone())
115                        .unwrap_or_else(|| "http://localhost:3000/auth/callback".into()),
116                    scope: oidc
117                        .as_ref()
118                        .map(|o| o.4.clone())
119                        .unwrap_or_else(|| "openid profile email".into()),
120                    cookie_name: "trustee_token".into(),
121                    dev_config: dc.clone(),
122                    validation_options: JwtValidationOptions::default(),
123                    pkce_cookie_secret: oidc
124                        .as_ref()
125                        .map(|o| o.6.clone())
126                        .unwrap_or_else(|| "trustee-default-pkce-secret-change-me".into()),
127                });
128            }
129        }
130
131        // Production mode — requires [oidc] section
132        let (
133            issuer_url,
134            client_id,
135            client_secret,
136            redirect_uri,
137            scope,
138            validation_options,
139            pkce_secret,
140        ) = Self::parse_oidc_section(&table)?;
141
142        Some(Self {
143            issuer_url,
144            client_id,
145            client_secret,
146            redirect_uri,
147            scope,
148            cookie_name: "trustee_token".into(),
149            dev_config: dev_config.unwrap_or_default(),
150            validation_options,
151            pkce_cookie_secret: pkce_secret,
152        })
153    }
154
155    /// Parse the `[oidc]` section from a TOML table.
156    /// Returns (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret).
157    fn parse_oidc_section(
158        table: &toml::Table,
159    ) -> Option<(
160        String,
161        String,
162        Option<String>,
163        String,
164        String,
165        JwtValidationOptions,
166        String,
167    )> {
168        let oidc = table.get("oidc")?.as_table()?;
169
170        let issuer_url = oidc.get("issuer_url")?.as_str()?.to_string();
171        let client_id = oidc.get("client_id")?.as_str()?.to_string();
172        let client_secret = oidc
173            .get("client_secret")
174            .and_then(|v| v.as_str())
175            .map(String::from);
176        let redirect_uri = oidc
177            .get("redirect_uri")
178            .or_else(|| oidc.get("redirect_url")) // backward compat
179            .and_then(|v| v.as_str())
180            .unwrap_or("http://localhost:3000/auth/callback")
181            .to_string();
182        let scope = oidc
183            .get("scope")
184            .and_then(|v| v.as_str())
185            .unwrap_or("openid profile email")
186            .to_string();
187
188        let mut validation_options = JwtValidationOptions::default();
189        if let Some(skip) = oidc.get("skip_issuer_validation").and_then(|v| v.as_bool()) {
190            validation_options.skip_issuer_validation = skip;
191        }
192        if let Some(skip) = oidc
193            .get("skip_audience_validation")
194            .and_then(|v| v.as_bool())
195        {
196            validation_options.skip_audience_validation = skip;
197        }
198        validation_options.expected_audience = oidc
199            .get("expected_audience")
200            .and_then(|v| v.as_str())
201            .map(String::from);
202
203        let pkce_secret = oidc
204            .get("pkce_cookie_secret")
205            .and_then(|v| v.as_str())
206            .unwrap_or("trustee-default-pkce-secret-change-me")
207            .to_string();
208
209        Some((
210            issuer_url,
211            client_id,
212            client_secret,
213            redirect_uri,
214            scope,
215            validation_options,
216            pkce_secret,
217        ))
218    }
219
220    /// Build OIDC client configuration for PEP's OidcClient.
221    pub fn oidc_client_config(&self) -> OidcClientConfig {
222        OidcClientConfig {
223            issuer_url: self.issuer_url.clone(),
224            client_id: self.client_id.clone(),
225            client_secret: self.client_secret.clone(),
226            redirect_uri: self.redirect_uri.clone(),
227            scope: self.scope.clone(),
228            code_challenge_method: "S256".to_string(),
229        }
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Token validation cache (issue c41eb9d7)
235// ---------------------------------------------------------------------------
236
237/// Grace subtracted from a token's `exp` before its cached validation goes
238/// stale. MUST exceed PEP's 60s clock-skew leeway, so a cached result is
239/// never served for a token PEP itself would reject on skew.
240const CACHE_EXP_GRACE_SECS: i64 = 120;
241
242/// Upper bound on how long any cached validation stays fresh, regardless of
243/// token lifetime. Bounds staleness of role/group enrichment changes.
244const CACHE_TTL_SECS: i64 = 300;
245
246/// Maximum cached tokens — bounded memory on a long-running daemon.
247const CACHE_MAX_ENTRIES: usize = 1024;
248
249/// Cache key: first 16 bytes of SHA-256(token). 128-bit truncation — no
250/// practical collision, and the raw token is never stored or logged.
251fn validation_cache_key(token: &str) -> [u8; 16] {
252    let digest = Sha256::digest(token.as_bytes());
253    let mut key = [0u8; 16];
254    key.copy_from_slice(&digest[..16]);
255    key
256}
257
258fn unix_now() -> i64 {
259    std::time::SystemTime::now()
260        .duration_since(std::time::UNIX_EPOCH)
261        .map(|d| d.as_secs() as i64)
262        .unwrap_or(0)
263}
264
265/// AUTHN-only token validation cache.
266///
267/// Keyed by [`validation_cache_key`], entry lifetime
268/// `min(exp - CACHE_EXP_GRACE_SECS, now + CACHE_TTL_SECS)`. Never gates
269/// authorization: callers MUST still run Cedar per request on the cached
270/// principal (`check_auth` does). Critical sections are clone-and-insert
271/// sized — the lock is never held across an await, and a concurrent
272/// double-validation on a cold token is benign (one wasted validation,
273/// correctness intact).
274struct ValidationCache {
275    inner: Mutex<ValidationCacheInner>,
276}
277
278#[derive(Default)]
279struct ValidationCacheInner {
280    /// token-hash → (claims, valid_until as unix seconds)
281    entries: HashMap<[u8; 16], (JwtClaims, i64)>,
282    /// (sub, issuer) pairs already announced once at INFO.
283    first_sight: HashSet<(String, String)>,
284}
285
286impl ValidationCache {
287    fn new() -> Self {
288        Self {
289            inner: Mutex::new(ValidationCacheInner::default()),
290        }
291    }
292
293    /// Cached claims if present and still fresh.
294    fn get(&self, key: &[u8; 16], now: i64) -> Option<JwtClaims> {
295        let inner = self.inner.lock().expect("validation cache poisoned");
296        inner
297            .entries
298            .get(key)
299            .filter(|(_, until)| *until > now)
300            .map(|(claims, _)| claims.clone())
301    }
302
303    /// Store a fresh validation result with
304    /// `valid_until = min(exp - grace, now + ttl)`. A near-expiry token is
305    /// inserted harmlessly — `get` filters by time, so it is born stale.
306    fn put(&self, key: [u8; 16], claims: JwtClaims, now: i64) {
307        let valid_until = (claims.exp - CACHE_EXP_GRACE_SECS).min(now + CACHE_TTL_SECS);
308        let mut inner = self.inner.lock().expect("validation cache poisoned");
309        if inner.entries.len() >= CACHE_MAX_ENTRIES {
310            // Prefer dropping expired entries; if the map is all-hot, clear
311            // rather than random-evict live sessions. The cache is an
312            // optimization — correctness never depends on a hit.
313            inner.entries.retain(|_, (_, until)| *until > now);
314            if inner.entries.len() >= CACHE_MAX_ENTRIES {
315                inner.entries.clear();
316            }
317        }
318        inner.entries.insert(key, (claims, valid_until));
319    }
320
321    /// True on first sight of (sub, issuer) — the one chance to log at INFO.
322    fn mark_first_sight(&self, sub: &str, issuer: &str) -> bool {
323        let mut inner = self.inner.lock().expect("validation cache poisoned");
324        if inner.first_sight.len() >= CACHE_MAX_ENTRIES {
325            inner.first_sight.clear();
326        }
327        inner
328            .first_sight
329            .insert((sub.to_string(), issuer.to_string()))
330    }
331}
332
333/// Shared authentication state, stored in ServerState.
334#[derive(Clone)]
335pub struct AuthState {
336    /// OIDC client for login flow (authorization code + PKCE)
337    pub oidc_client: OidcClient,
338    /// Resource server client for JWT validation (lazy-initialized)
339    pub resource_server: ResourceServerClient,
340    /// OIDC client configuration
341    pub client_config: OidcClientConfig,
342    /// Auth configuration
343    pub config: AuthConfig,
344    /// Stateless PKCE cookie manager
345    pub pkce_manager: PkceCookieManager,
346    /// Web session manager (cookie session_id → server-side token with auto-refresh)
347    pub session_manager: Arc<WebSessionManager>,
348    /// 16F: service-account issuer candidates (Kanidm vhosts agent tokens
349    /// are minted on). Tokens carrying one of these fail primary validation
350    /// (the `[oidc]` issuer is a different vhost) — `check_auth` falls back
351    /// to validating against each candidate, never skipping issuer checks.
352    /// Same IdP, same keys, no trust expansion.
353    pub issuer_fallbacks: Vec<String>,
354    /// Cedar authorizer for ABAC authorization (None = Cedar disabled)
355    pub cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
356    /// AUTHN-only token-validation cache (issue c41eb9d7). NEVER gates Cedar:
357    /// per-action authorization still runs per-request on the cached principal.
358    validation_cache: Arc<ValidationCache>,
359}
360
361impl AuthState {
362    /// Create new auth state from configuration.
363    pub fn new(config: AuthConfig) -> Self {
364        Self::with_cedar(config, None)
365    }
366
367    /// Create new auth state with optional Cedar authorizer.
368    pub fn with_cedar(
369        config: AuthConfig,
370        cedar_authorizer: Option<Arc<pep::cedar::CedarAuthorizer>>,
371    ) -> Self {
372        let pkce_manager = PkceCookieManager::new(
373            config.pkce_cookie_secret.as_bytes(),
374            "trustee_pkce_state",
375            StdDuration::from_secs(600),
376        );
377
378        let session_manager = Arc::new(WebSessionManager::new(
379            OidcClient::new(),
380            config.issuer_url.clone(),
381            config.client_id.clone(),
382            config.client_secret.clone(),
383            config.scope.clone(),
384        ));
385
386        Self {
387            oidc_client: OidcClient::new(),
388            resource_server: ResourceServerClient::new(),
389            client_config: config.oidc_client_config(),
390            pkce_manager,
391            session_manager,
392            config,
393            cedar_authorizer,
394            issuer_fallbacks: Vec::new(),
395            validation_cache: Arc::new(ValidationCache::new()),
396        }
397    }
398
399    /// 16F: set the service-account issuer fallback candidates.
400    pub fn with_issuer_fallbacks(mut self, issuers: Vec<String>) -> Self {
401        self.issuer_fallbacks = issuers;
402        self
403    }
404
405    /// Check if development mode is enabled.
406    pub fn is_dev_mode(&self) -> bool {
407        self.config.dev_config.local_dev_mode
408    }
409
410    /// Validate a JWT token using PEP's ResourceServerClient.
411    pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
412        self.validate_token_on(&self.config.issuer_url, token).await
413    }
414
415    /// 16F: primary validation, then the service issuer if that fails —
416    /// cached per token (issue c41eb9d7).
417    ///
418    /// Kanidm stamps tokens with the vhost they were minted on: human logins
419    /// carry the `[oidc]` issuer, agent tokens exchanged via the service
420    /// credential carry the service issuer. Each validates only against its
421    /// own — this tries both, never skipping issuer validation.
422    ///
423    /// Results are cached (SHA-256(token)[:16] → claims, valid
424    /// `min(exp-120s, now+300s)`, cap 1024) so polling clients stop paying a
425    /// double validation + log line per request. AUTHN only: callers MUST
426    /// still run Cedar authorization per request (`check_auth` does).
427    pub async fn validate_token_flexible(&self, token: &str) -> anyhow::Result<JwtClaims> {
428        let key = validation_cache_key(token);
429        let now = unix_now();
430        if let Some(cached) = self.validation_cache.get(&key, now) {
431            return Ok(cached);
432        }
433        let claims = self.validate_token_flexible_uncached(token).await?;
434        self.validation_cache.put(key, claims.clone(), now);
435        Ok(claims)
436    }
437
438    async fn validate_token_flexible_uncached(&self, token: &str) -> anyhow::Result<JwtClaims> {
439        match self.validate_token(token).await {
440            Ok(claims) => Ok(claims),
441            Err(primary) => {
442                let mut last = primary;
443                for si in &self.issuer_fallbacks {
444                    if *si == self.config.issuer_url {
445                        continue; // primary already tried it
446                    }
447                    match self.validate_token_on(si, token).await {
448                        Ok(claims) => {
449                            // c41eb9d7: this used to log INFO per request — an
450                            // idle-poll terminal flood. One INFO on first
451                            // sight of (sub, issuer), DEBUG afterwards.
452                            let first = self.validation_cache.mark_first_sight(&claims.sub, si);
453                            let who = claims.preferred_username.as_deref().unwrap_or(&claims.sub);
454                            if first {
455                                tracing::info!(
456                                    "service principal {} authenticating via service-issuer fallback (iss={})",
457                                    who,
458                                    si
459                                );
460                            } else {
461                                tracing::debug!(
462                                    "token validated via service-issuer fallback (iss={}) — principal {}",
463                                    si,
464                                    who
465                                );
466                            }
467                            return Ok(claims);
468                        }
469                        Err(e) => last = e,
470                    }
471                }
472                // Auth failure must SCREAM regardless of caller handling
473                // (callers also warn on the returned error — belt and braces).
474                tracing::warn!(
475                    "token validation failed — primary: {last}; all service-issuer fallbacks exhausted"
476                );
477                Err(anyhow::anyhow!(
478                    "primary: {last}; all service-issuer fallbacks exhausted"
479                ))
480            }
481        }
482    }
483
484    /// Validate against a SPECIFIC issuer — validation AND enrichment must
485    /// both run on the vhost that issued the token.
486    pub async fn validate_token_on(
487        &self,
488        issuer_url: &str,
489        token: &str,
490    ) -> anyhow::Result<JwtClaims> {
491        let mut claims = self
492            .resource_server
493            .validate_jwt_with_options(
494                token,
495                issuer_url,
496                &self.config.client_id,
497                &self.config.validation_options,
498            )
499            .await
500            .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
501
502        // Enrich with userinfo for role/groups (cached, no-op if already present)
503        // 2de5d1eb: this used to be `let _ =` — a silent role-less principal
504        // that Cedar then fail-closed DENIED with `matched policies: []` and
505        // zero diagnosis. Enrichment failure must SCREAM.
506        if let Err(e) = self
507            .resource_server
508            .enrich_claims_with_userinfo(&mut claims, token, issuer_url, None)
509            .await
510        {
511            tracing::error!(
512                "userinfo enrichment FAILED for sub {}: {} — principal carries NO role/groups; \
513                 with Cedar enabled every request will be DENIED until enrichment succeeds",
514                claims.sub,
515                e
516            );
517        }
518
519        // PEP only merges groups/role from userinfo. If name/email are missing
520        // (Kanidm JWTs only contain sub), fetch them from userinfo directly.
521        if claims.name.is_none() || claims.email.is_none() {
522            self.fill_userinfo_fields(&mut claims, token, issuer_url)
523                .await;
524        }
525
526        Ok(claims)
527    }
528
529    /// Fetch name/email/preferred_username from the OIDC userinfo endpoint
530    /// and fill in any that are missing from the JWT claims.
531    async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str, issuer_url: &str) {
532        // Derive userinfo URL from issuer
533        // For Kanidm: issuer_url is the discovery endpoint,
534        // userinfo is at {issuer_url}/userinfo — the token's OWN vhost
535        // (16F: a token minted on the service vhost must enrich there too).
536        let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));
537
538        let client = reqwest::Client::new();
539        let resp = client
540            .get(&userinfo_url)
541            .header("Authorization", format!("Bearer {}", token))
542            .header("Accept", "application/json")
543            .send()
544            .await;
545
546        let Ok(resp) = resp else {
547            tracing::debug!("Userinfo request failed for name/email enrichment");
548            return;
549        };
550
551        if !resp.status().is_success() {
552            tracing::debug!(
553                "Userinfo returned {} for name/email enrichment",
554                resp.status()
555            );
556            return;
557        }
558
559        let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await
560        else {
561            return;
562        };
563
564        tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
565
566        if claims.name.is_none() {
567            if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
568                claims.name = Some(name.to_string());
569            }
570        }
571        if claims.email.is_none() {
572            if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
573                claims.email = Some(email.to_string());
574            }
575        }
576        if claims.preferred_username.is_none() {
577            if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
578                claims.preferred_username = Some(uname.to_string());
579            }
580        }
581    }
582
583    /// Check Cedar authorization for the authenticated user.
584    ///
585    /// Returns Ok(()) if allowed (or if Cedar is not configured).
586    /// Returns Err(()) if denied — caller should return 403 Forbidden.
587    fn check_cedar_authorized(&self, claims: &JwtClaims, action: &str) -> Result<(), ()> {
588        let Some(ref authorizer) = self.cedar_authorizer else {
589            return Ok(()); // Cedar not configured — allow
590        };
591
592        // Build principal entity from JWT claims
593        let principal_entity = match pep::cedar::build_principal_entity(claims) {
594            Ok(e) => e,
595            Err(e) => {
596                tracing::error!("Cedar: failed to build principal entity: {}", e);
597                return Err(());
598            }
599        };
600
601        // Build entities set with principal + TrusteeApp resource
602        let mut entities_vec = vec![principal_entity];
603
604        // Add a TrusteeApp entity as the resource
605        let app_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
606            Ok(uid) => uid,
607            Err(e) => {
608                tracing::error!("Cedar: failed to build TrusteeApp uid: {}", e);
609                return Err(());
610            }
611        };
612        let app_entity = match cedar_policy::Entity::new(
613            app_uid,
614            std::collections::HashMap::new(),
615            std::collections::HashSet::new(),
616        ) {
617            Ok(e) => e,
618            Err(e) => {
619                tracing::error!("Cedar: failed to build TrusteeApp entity: {}", e);
620                return Err(());
621            }
622        };
623        entities_vec.push(app_entity);
624
625        let entities = match Entities::from_entities(entities_vec, None) {
626            Ok(e) => e,
627            Err(e) => {
628                tracing::error!("Cedar: failed to build entities set: {}", e);
629                return Err(());
630            }
631        };
632
633        // Build the Cedar authorization request
634        let principal_uid = match pep::cedar::build_principal_uid(claims) {
635            Ok(uid) => uid,
636            Err(e) => {
637                tracing::error!("Cedar: failed to build principal uid: {}", e);
638                return Err(());
639            }
640        };
641
642        let action_uid = match EntityUid::from_str(&format!("Action::\"{action}\"")) {
643            Ok(uid) => uid,
644            Err(e) => {
645                tracing::error!("Cedar: failed to build action uid: {}", e);
646                return Err(());
647            }
648        };
649
650        let resource_uid = match EntityUid::from_str(r#"TrusteeApp::"default""#) {
651            Ok(uid) => uid,
652            Err(e) => {
653                tracing::error!("Cedar: failed to build resource uid: {}", e);
654                return Err(());
655            }
656        };
657
658        let request = match Request::new(
659            principal_uid,
660            action_uid,
661            resource_uid,
662            Context::empty(),
663            None,
664        ) {
665            Ok(r) => r,
666            Err(e) => {
667                tracing::error!("Cedar: failed to build request: {}", e);
668                return Err(());
669            }
670        };
671
672        let response = authorizer.is_allowed_with_entities(&request, &entities);
673
674        if response.allowed() {
675            tracing::debug!(
676                "Cedar: authorized user {} (sub={})",
677                claims.email.as_deref().unwrap_or("unknown"),
678                claims.sub
679            );
680            Ok(())
681        } else {
682            tracing::warn!(
683                "Cedar: DENIED user {} (sub={}) — matched policies: {:?}, errors: {:?}",
684                claims.email.as_deref().unwrap_or("unknown"),
685                claims.sub,
686                response.matched_policies(),
687                response.errors()
688            );
689            Err(())
690        }
691    }
692}
693
694// ---------------------------------------------------------------------------
695// Auth checking — called by protected route handlers
696// ---------------------------------------------------------------------------
697
698/// Cedar action names (P2, nghr 645809c3).
699///
700/// Keep in sync with `policies/trustee_schema.cedarschema` — the schema and
701/// the embedded policy ship atomically with these constants; a filesystem
702/// policy override referencing removed actions will deny everything (loud,
703/// by design).
704pub mod actions {
705    pub const LIST_MODELS: &str = "ListModels";
706    pub const LIST_SESSIONS: &str = "ListSessions";
707    pub const VIEW_SESSION: &str = "ViewSession";
708    pub const VIEW_HISTORY: &str = "ViewHistory";
709    pub const CREATE_SESSION: &str = "CreateSession";
710    pub const COMMAND_SESSION: &str = "CommandSession";
711    pub const CANCEL_SESSION: &str = "CancelSession";
712    pub const HANDOFF_SESSION: &str = "HandoffSession";
713    pub const RESUME_SESSION: &str = "ResumeSession";
714    pub const UPDATE_SESSION: &str = "UpdateSession";
715    pub const DELETE_SESSION: &str = "DeleteSession";
716    pub const VIEW_MCP_CREDENTIALS: &str = "ViewMcpCredentials";
717    pub const UPDATE_MCP_CREDENTIALS: &str = "UpdateMcpCredentials";
718}
719
720/// Principal kind (16D). `Agent` iff the enriched `role` claim contains
721/// "agent" — the exact value mapped by Kanidm's `pdt-api-agents` group
722/// (role vocabulary: admin | user | service | agent, facts doc 42977cb7).
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub enum PrincipalKind {
725    Human,
726    Agent,
727}
728
729impl PrincipalKind {
730    /// Classify from the primary role. Anything that is not exactly
731    /// "agent" is Human — fail-toward-human keeps the default posture
732    /// identical to pre-16D behavior.
733    pub fn from_role(role: Option<&str>) -> Self {
734        match role {
735            Some("agent") => Self::Agent,
736            _ => Self::Human,
737        }
738    }
739}
740
741/// Extract the primary role from enriched JWT claims.
742///
743/// PEP merges userinfo into `extra` (flattened claims). Kanidm delivers
744/// `role` as a STRING or an ARRAY (pep 366e8ed lesson) — accept both,
745/// first value wins.
746fn claim_role(claims: &JwtClaims) -> Option<String> {
747    match claims.extra.get("role") {
748        Some(serde_json::Value::String(s)) => Some(s.clone()),
749        Some(serde_json::Value::Array(arr)) => arr
750            .iter()
751            .filter_map(|v| v.as_str())
752            .next()
753            .map(|s| s.to_string()),
754        _ => None,
755    }
756}
757
758/// Authenticated user info extracted from the token.
759#[derive(Debug, Clone)]
760pub struct AuthUser {
761    pub sub: String,
762    pub email: Option<String>,
763    pub name: Option<String>,
764    pub username: Option<String>,
765    pub is_dev: bool,
766    /// Primary role from enriched claims (16D).
767    pub role: Option<String>,
768    /// Principal classification (16D): Agent iff role == "agent".
769    pub kind: PrincipalKind,
770}
771
772impl From<JwtClaims> for AuthUser {
773    fn from(claims: JwtClaims) -> Self {
774        let role = claim_role(&claims);
775        let kind = PrincipalKind::from_role(role.as_deref());
776        Self {
777            sub: claims.sub,
778            email: claims.email,
779            name: claims.name,
780            username: claims.preferred_username,
781            is_dev: false,
782            role,
783            kind,
784        }
785    }
786}
787
788/// Cookie max-age for session cookies (1 hour, matching the server-side idle timeout).
789const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
790
791/// PINNED (16D) + AGENT CARVE-OUT (16E): user_key for JWT principals.
792///
793/// Humans: `preferred_username` first, falling back to `sub`. NEVER email —
794/// email is rebindable in Kanidm and would silently re-home a principal's
795/// namespace.
796///
797/// Agents: `sub` — ALWAYS. Exchanged agent tokens carry no
798/// `preferred_username` today, but a future scope change (e.g. adding
799/// `profile` to the exchange) would add it and silently re-home every
800/// agent's namespace — the exact migration bug class 0.10→0.11 inflicted on
801/// humans. Agents are pinned to `sub` for the lifetime of the namespace.
802///
803/// MIGRATION NOTE (humans): the 16D rule changed the key for existing JWT
804/// humans (they were keyed by `sub` before 0.11.0). Kanidm humans have a
805/// `preferred_username`, so their namespace hash changes on first login —
806/// pre-web-production history under the old hash is orphaned, not deleted.
807fn jwt_user_key(claims: &JwtClaims) -> String {
808    if PrincipalKind::from_role(claim_role(claims).as_deref()) == PrincipalKind::Agent {
809        return claims.sub.clone();
810    }
811    match claims.preferred_username.as_deref() {
812        Some(u) if !u.trim().is_empty() => u.trim().to_string(),
813        _ => {
814            tracing::debug!(
815                "user_key: preferred_username missing/empty for sub {} — falling back to sub",
816                claims.sub
817            );
818            claims.sub.clone()
819        }
820    }
821}
822
823/// Extract a user key from a dev-mode token string.
824///
825/// Two formats (BOTH gated by `local_dev_mode` at every call site):
826/// - `dev:agent:<name>` → `agent-{name}` (16D: agent namespace in dev —
827///   unblocks per-user cache + THQ E2E without Kanidm accounts; distinct
828///   prefix so dev agents can never collide with dev humans)
829/// - `dev:email:name:username` → `dev:{email}` (dev humans, legacy)
830fn dev_user_key(token: &str) -> Option<String> {
831    if let Some(name) = token.strip_prefix("dev:agent:") {
832        let name = name.trim();
833        if name.is_empty() || name.contains(':') {
834            return None;
835        }
836        return Some(format!("agent-{name}"));
837    }
838    let parts: Vec<&str> = token.splitn(4, ':').collect();
839    if parts.len() >= 4 {
840        Some(format!("dev:{}", parts[1]))
841    } else {
842        None
843    }
844}
845
846/// Pure decision core of [`check_dispatch_admin`] — unit-testable without an IdP.
847pub(crate) fn dispatch_allowed(kind: PrincipalKind, role: Option<&str>) -> bool {
848    kind == PrincipalKind::Human && role == Some("admin")
849}
850
851/// 16F: admin gate for the per-agent dispatch surface (`/xagent/{name}`).
852///
853/// The OUTER dispatch must be performed by a HUMAN ADMIN: agents may never
854/// dispatch agents, and `service` is read-only by design. The impersonated
855/// INNER request is separately authenticated and Cedar-gated AS THE AGENT
856/// (per-action matrix), so this gate answers exactly one question: "may this
857/// principal dispatch agent-users at all".
858///
859/// Open mode (no auth configured) allows dispatch, consistent with the
860/// posture check_auth already applies. Dev-mode human tokens carry no role
861/// and are therefore rejected — dispatch testing requires a real admin JWT.
862pub async fn check_dispatch_admin(
863    auth: &Option<Arc<AuthState>>,
864    headers: &axum::http::HeaderMap,
865) -> Result<(), StatusCode> {
866    let Some(auth) = auth.as_ref() else {
867        return Ok(()); // open mode — same posture as check_auth
868    };
869    let Some(token) = headers
870        .get(header::AUTHORIZATION)
871        .and_then(|v| v.to_str().ok())
872        .and_then(|v| v.strip_prefix("Bearer "))
873        .map(|s| s.to_string())
874    else {
875        return Err(StatusCode::UNAUTHORIZED);
876    };
877    if token.starts_with("dev:") {
878        tracing::warn!("xagent dispatch rejected: dev tokens cannot dispatch agents");
879        return Err(StatusCode::FORBIDDEN);
880    }
881    let claims = auth.validate_token_flexible(&token).await.map_err(|e| {
882        tracing::warn!("xagent dispatch: caller token validation failed: {}", e);
883        StatusCode::UNAUTHORIZED
884    })?;
885    let user = AuthUser::from(claims);
886    if !dispatch_allowed(user.kind, user.role.as_deref()) {
887        tracing::warn!(
888            "xagent dispatch rejected: principal kind={:?} role={:?} — admin required",
889            user.kind,
890            user.role
891        );
892        return Err(StatusCode::FORBIDDEN);
893    }
894    Ok(())
895}
896
897impl AuthState {
898    /// 16F: exchange an agent-user's Kanidm service token for a short-lived
899    /// access token carrying `role=agent`, usable as the impersonated Bearer
900    /// on the inner dispatch. Same grant the MCP credentials use (verified
901    /// RFC 8693 shape); enrichment in `validate_token` resolves the role.
902    ///
903    /// `issuer_url` MUST be the service-account issuer: Kanidm binds token
904    /// exchange to the client origin — the `[oidc]` auth issuer host serves
905    /// logins but rejects exchange with `invalid_request`, while the
906    /// `[mcp.credentials.*]` service issuer (see
907    /// `ServerState::service_issuer`) accepts it (verified live 2026-08-30:
908    /// same token, 200 vs 400).
909    pub async fn exchange_agent_token(
910        &self,
911        issuer_url: &str,
912        service_token: &str,
913    ) -> Result<(String, u64), StatusCode> {
914        let tr = self
915            .oidc_client
916            .exchange_token(
917                issuer_url,
918                &self.config.client_id,
919                None, // public client — no secret (Kanidm rejects one)
920                service_token,
921                &self.config.client_id,
922                Some("openid groups"),
923            )
924            .await
925            .map_err(|e| {
926                tracing::error!(
927                    "xagent dispatch: agent token exchange FAILED at {issuer_url}: {} — impersonation unavailable",
928                    e
929                );
930                StatusCode::BAD_GATEWAY
931            })?;
932        Ok((tr.access_token, tr.expires_in.unwrap_or(900)))
933    }
934}
935
936/// Check authentication for a protected endpoint.
937///
938/// Returns `Ok((None, user_key))` if auth is not configured (open mode), or if
939/// a valid token is present without needing cookie renewal. Returns
940/// `Ok((Some(cookie), user_key))` if auth succeeded and the caller should
941/// include the given `Set-Cookie` header value in the response (rolling session).
942/// Returns `Err(StatusCode)` if auth is configured but no valid token is found.
943///
944/// The returned `user_key` is the identity string used for session isolation
945/// (JWT principals: `preferred_username || sub` — PINNED, see [`jwt_user_key`];
946/// `dev:{email}` for dev-mode humans, `agent-{name}` for `dev:agent:` tokens,
947/// `"default"` when auth is not configured). This avoids the need for handlers
948/// to call `resolve_user_key()` which would re-validate the JWT a second time.
949///
950/// Token sources (in order):
951/// 1. `Authorization: Bearer <token>` header (raw JWT — validated directly)
952/// 2. `trustee_token=<session_id>` cookie (looked up in WebSessionManager,
953///    auto-refreshed if near expiry)
954///
955/// Dev mode tokens use the format `dev:email:name:username`.
956pub async fn check_auth(
957    auth: &Option<Arc<AuthState>>,
958    headers: &axum::http::HeaderMap,
959    action: &str,
960) -> Result<(Option<String>, String), StatusCode> {
961    let Some(auth) = auth.as_ref() else {
962        return Ok((None, "default".to_string())); // Auth not configured — allow
963    };
964
965    // 1. Try Bearer header first (raw JWT — e.g. from API clients, Torpi proxy)
966    if let Some(token) = headers
967        .get(header::AUTHORIZATION)
968        .and_then(|v| v.to_str().ok())
969        .and_then(|v| v.strip_prefix("Bearer "))
970        .map(|s| s.to_string())
971    {
972        // Dev mode token — only accepted when dev mode is currently enabled
973        if token.starts_with("dev:") {
974            if !auth.config.dev_config.local_dev_mode {
975                tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
976                return Err(StatusCode::UNAUTHORIZED);
977            }
978            return match dev_user_key(&token) {
979                Some(key) => Ok((None, key)),
980                None => Err(StatusCode::UNAUTHORIZED),
981            };
982        }
983
984        return match auth.validate_token_flexible(&token).await {
985            Ok(claims) => {
986                if auth.check_cedar_authorized(&claims, action).is_err() {
987                    return Err(StatusCode::FORBIDDEN);
988                }
989                Ok((None, jwt_user_key(&claims)))
990            }
991            Err(e) => {
992                tracing::warn!("Bearer token validation failed: {}", e);
993                Err(StatusCode::UNAUTHORIZED)
994            }
995        };
996    }
997
998    // 2. Try cookie (session_id → WebSessionManager → access token with auto-refresh)
999    let session_id = headers
1000        .get(header::COOKIE)
1001        .and_then(|v| v.to_str().ok())
1002        .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
1003
1004    let Some(session_id) = session_id else {
1005        tracing::warn!("No auth token found in request");
1006        return Err(StatusCode::UNAUTHORIZED);
1007    };
1008
1009    // Dev mode token in cookie — only accepted when dev mode is currently enabled
1010    if session_id.starts_with("dev:") {
1011        if !auth.config.dev_config.local_dev_mode {
1012            tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
1013            return Err(StatusCode::UNAUTHORIZED);
1014        }
1015        return match dev_user_key(&session_id) {
1016            Some(key) => Ok((None, key)),
1017            None => Err(StatusCode::UNAUTHORIZED),
1018        };
1019    }
1020
1021    // Session-based: look up via WebSessionManager (auto-refreshes)
1022    match auth.session_manager.get_token(&session_id).await {
1023        Ok(access_token) => match auth.validate_token(&access_token).await {
1024            Ok(claims) => {
1025                // Cedar authorization check
1026                if auth.check_cedar_authorized(&claims, action).is_err() {
1027                    return Err(StatusCode::FORBIDDEN);
1028                }
1029                // Roll the cookie — reset max-age so active users stay logged in
1030                let secure = auth.client_config.redirect_uri.starts_with("https");
1031                let cookie = create_auth_cookie(
1032                    &auth.config.cookie_name,
1033                    &session_id,
1034                    SESSION_COOKIE_MAX_AGE,
1035                    secure,
1036                );
1037                Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
1038            }
1039            Err(e) => {
1040                // Token was returned but JWT validation failed (e.g. ExpiredSignature
1041                // due to clock skew). Force-refresh and retry once.
1042                tracing::warn!(
1043                    "Session token validation failed: {} — attempting force-refresh",
1044                    e
1045                );
1046                match auth.session_manager.force_refresh(&session_id).await {
1047                    Ok(new_token) => match auth.validate_token(&new_token).await {
1048                        Ok(claims) => {
1049                            // Cedar authorization check
1050                            if auth.check_cedar_authorized(&claims, action).is_err() {
1051                                return Err(StatusCode::FORBIDDEN);
1052                            }
1053                            let secure = auth.client_config.redirect_uri.starts_with("https");
1054                            let cookie = create_auth_cookie(
1055                                &auth.config.cookie_name,
1056                                &session_id,
1057                                SESSION_COOKIE_MAX_AGE,
1058                                secure,
1059                            );
1060                            Ok((Some(cookie.to_string()), jwt_user_key(&claims)))
1061                        }
1062                        Err(e2) => {
1063                            tracing::warn!(
1064                                "Session token still invalid after force-refresh: {}",
1065                                e2
1066                            );
1067                            Err(StatusCode::UNAUTHORIZED)
1068                        }
1069                    },
1070                    Err(e2) => {
1071                        tracing::warn!("Force-refresh failed: {}", e2);
1072                        Err(StatusCode::UNAUTHORIZED)
1073                    }
1074                }
1075            }
1076        },
1077        Err(e) => {
1078            tracing::warn!("Session lookup/refresh failed: {}", e);
1079            Err(StatusCode::UNAUTHORIZED)
1080        }
1081    }
1082}
1083
1084/// Extract a valid access token from the request (for use by handlers that
1085/// need the token itself, not just auth checking).
1086///
1087/// Resolves session_id cookies to actual access tokens via WebSessionManager.
1088/// Bearer headers are returned as-is.
1089async fn resolve_access_token(
1090    auth: &AuthState,
1091    headers: &axum::http::HeaderMap,
1092) -> Result<String, StatusCode> {
1093    // Bearer header — return as-is
1094    if let Some(token) = headers
1095        .get(header::AUTHORIZATION)
1096        .and_then(|v| v.to_str().ok())
1097        .and_then(|v| v.strip_prefix("Bearer "))
1098        .map(|s| s.to_string())
1099    {
1100        return Ok(token);
1101    }
1102
1103    // Cookie — resolve session_id → access_token
1104    let session_id = headers
1105        .get(header::COOKIE)
1106        .and_then(|v| v.to_str().ok())
1107        .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
1108
1109    match session_id {
1110        Some(sid) if sid.starts_with("dev:") => {
1111            if !auth.config.dev_config.local_dev_mode {
1112                tracing::warn!(
1113                    "Dev cookie in resolve_access_token but dev mode is disabled — rejecting"
1114                );
1115                Err(StatusCode::UNAUTHORIZED)
1116            } else {
1117                Ok(sid)
1118            }
1119        }
1120        Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
1121            tracing::warn!("Failed to resolve session token: {}", e);
1122            StatusCode::UNAUTHORIZED
1123        }),
1124        None => Err(StatusCode::UNAUTHORIZED),
1125    }
1126}
1127
1128/// Extract token value from a cookie header string.
1129fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
1130    for cookie in cookie_header.split(';') {
1131        let cookie = cookie.trim();
1132        if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
1133            return Some(value.to_string());
1134        }
1135    }
1136    None
1137}
1138
1139// ---------------------------------------------------------------------------
1140// Auth routes: /auth/login, /auth/callback, /auth/me, /auth/logout
1141// ---------------------------------------------------------------------------
1142
1143/// Build the auth routes as a nested Router.
1144pub fn auth_routes() -> axum::Router<crate::ServerState> {
1145    axum::Router::new()
1146        .route("/login", axum::routing::get(login_handler))
1147        .route("/callback", axum::routing::get(callback_handler))
1148        .route("/me", axum::routing::get(me_handler))
1149        .route("/logout", axum::routing::post(logout_handler))
1150        .route("/mcp/login", axum::routing::get(mcp_login_handler))
1151        .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
1152        .route("/mcp/status", axum::routing::get(mcp_status_handler))
1153        .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
1154}
1155
1156/// Query parameters for OIDC callback.
1157#[derive(Debug, Deserialize)]
1158pub struct CallbackQuery {
1159    pub code: Option<String>,
1160    pub state: Option<String>,
1161    pub error: Option<String>,
1162    pub error_description: Option<String>,
1163}
1164
1165/// GET /auth/login — initiate OIDC login with PKCE, or create dev session.
1166async fn login_handler(State(state): State<crate::ServerState>) -> Result<Response, AuthError> {
1167    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1168
1169    // Dev mode — create synthetic session
1170    if auth.is_dev_mode() {
1171        tracing::info!("Dev mode: creating dev session");
1172        let dev = &auth.config.dev_config;
1173        let dev_token = format!(
1174            "dev:{}:{}:{}",
1175            dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
1176            dev.local_dev_name.as_deref().unwrap_or("Dev User"),
1177            dev.local_dev_username.as_deref().unwrap_or("dev")
1178        );
1179        let cookie = create_auth_cookie(
1180            &auth.config.cookie_name,
1181            &dev_token,
1182            StdDuration::from_secs(86400),
1183            false,
1184        );
1185        return Ok(Response::builder()
1186            .status(StatusCode::FOUND)
1187            .header(header::LOCATION, "/")
1188            .header(header::SET_COOKIE, cookie.to_string())
1189            .body(Body::empty())
1190            .unwrap());
1191    }
1192
1193    // Production — redirect to IdP with PKCE
1194    let pkce_session = auth.pkce_manager.create();
1195    let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
1196
1197    let auth_url = auth
1198        .oidc_client
1199        .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
1200        .await
1201        .map_err(|e| AuthError::OidcError(e.to_string()))?;
1202
1203    // Set PKCE state cookie (HttpOnly, SameSite=Lax)
1204    // Secure flag follows the redirect_uri scheme — HTTP localhost/LAN must not
1205    // set Secure or the browser drops the cookie and PKCE state is lost.
1206    let secure = auth.client_config.redirect_uri.starts_with("https");
1207    let pkce_cookie = Cookie::build((
1208        auth.pkce_manager.cookie_name().to_string(),
1209        pkce_session.cookie_value,
1210    ))
1211    .path("/")
1212    .http_only(true)
1213    .same_site(SameSite::Lax)
1214    .secure(secure)
1215    .max_age(TimeDuration::seconds(
1216        auth.pkce_manager.ttl().as_secs() as i64
1217    ))
1218    .build();
1219
1220    Ok(Response::builder()
1221        .status(StatusCode::TEMPORARY_REDIRECT)
1222        .header(header::LOCATION, &auth_url)
1223        .header(header::SET_COOKIE, pkce_cookie.to_string())
1224        .body(Body::empty())
1225        .unwrap())
1226}
1227
1228/// GET /auth/callback — exchange authorization code for tokens, set cookie.
1229async fn callback_handler(
1230    State(state): State<crate::ServerState>,
1231    Query(query): Query<CallbackQuery>,
1232    headers: axum::http::HeaderMap,
1233) -> Result<Response, AuthError> {
1234    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1235
1236    // Check for errors from IdP
1237    if let Some(error) = query.error {
1238        let desc = query.error_description.unwrap_or_default();
1239        tracing::error!("OIDC error: {} - {}", error, desc);
1240        return Ok(Redirect::temporary(&format!(
1241            "/?error={}&error_description={}",
1242            urlencoding::encode(&error),
1243            urlencoding::encode(&desc)
1244        ))
1245        .into_response());
1246    }
1247
1248    let code = query.code.ok_or(AuthError::MissingCode)?;
1249    let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1250
1251    // Retrieve PKCE cookie
1252    let cookie_header = headers
1253        .get(header::COOKIE)
1254        .and_then(|v| v.to_str().ok())
1255        .unwrap_or("");
1256    let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
1257        .ok_or(AuthError::InvalidState)?;
1258
1259    // Verify PKCE cookie (HMAC + expiry + state match)
1260    let verifier = auth
1261        .pkce_manager
1262        .verify(&pkce_value, &oauth_state)
1263        .ok_or(AuthError::InvalidState)?;
1264
1265    // Exchange code for tokens
1266    tracing::info!("Exchanging authorization code for tokens");
1267    let token_response = auth
1268        .oidc_client
1269        .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
1270        .await
1271        .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1272
1273    let session_id = auth
1274        .session_manager
1275        .create_session(&token_response)
1276        .await
1277        .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
1278
1279    // Cookie lifetime matches server-side idle timeout (1 hour).
1280    // The cookie is rolled on every successful request via check_auth().
1281    let max_age = SESSION_COOKIE_MAX_AGE;
1282
1283    // Set auth cookie — Secure only when redirect_uri is HTTPS
1284    let secure = auth.client_config.redirect_uri.starts_with("https");
1285    let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
1286
1287    // Clear PKCE cookie (single-use)
1288    let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
1289        .path("/")
1290        .http_only(true)
1291        .same_site(SameSite::Lax)
1292        .max_age(TimeDuration::seconds(-1))
1293        .build();
1294
1295    tracing::info!("Authentication successful, redirecting to /");
1296
1297    Ok(Response::builder()
1298        .status(StatusCode::FOUND)
1299        .header(header::LOCATION, "/")
1300        .header(header::SET_COOKIE, cookie.to_string())
1301        .header(header::SET_COOKIE, clear_pkce.to_string())
1302        .body(Body::empty())
1303        .unwrap())
1304}
1305
1306/// GET /auth/me — return current user info.
1307async fn me_handler(
1308    State(state): State<crate::ServerState>,
1309    headers: axum::http::HeaderMap,
1310) -> Response {
1311    let Some(ref auth) = state.auth else {
1312        // Auth not configured — always authenticated (no auth required)
1313        return axum::Json(serde_json::json!({
1314            "authenticated": true,
1315            "auth_enabled": false
1316        }))
1317        .into_response();
1318    };
1319
1320    let cookie_header = headers
1321        .get(header::COOKIE)
1322        .and_then(|v| v.to_str().ok())
1323        .unwrap_or("");
1324
1325    // Also try Authorization: Bearer header
1326    let bearer = headers
1327        .get(header::AUTHORIZATION)
1328        .and_then(|v| v.to_str().ok())
1329        .and_then(|v| v.strip_prefix("Bearer "))
1330        .map(String::from);
1331
1332    let token = bearer
1333        .clone()
1334        .or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
1335
1336    let Some(cookie_value) = token else {
1337        return axum::Json(serde_json::json!({
1338            "authenticated": false,
1339            "auth_enabled": true
1340        }))
1341        .into_response();
1342    };
1343
1344    // Dev mode token (stored directly in cookie, no session manager)
1345    // Only report as authenticated when dev mode is currently enabled
1346    if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
1347        let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
1348        if parts.len() >= 4 {
1349            return axum::Json(serde_json::json!({
1350                "authenticated": true,
1351                "auth_enabled": true,
1352                "email": parts[1],
1353                "name": parts[2],
1354                "username": parts[3],
1355                "dev_mode": true
1356            }))
1357            .into_response();
1358        }
1359    }
1360
1361    // Bearer header = raw JWT; Cookie value = session_id → resolve to access token
1362    let access_token = if bearer.is_some() {
1363        // Already have the raw token from Bearer header
1364        cookie_value
1365    } else {
1366        // Cookie value is a session_id — resolve via WebSessionManager
1367        match auth.session_manager.get_token(&cookie_value).await {
1368            Ok(token) => token,
1369            Err(e) => {
1370                tracing::debug!("Session token resolution failed for /auth/me: {}", e);
1371                return axum::Json(serde_json::json!({
1372                    "authenticated": false,
1373                    "auth_enabled": true
1374                }))
1375                .into_response();
1376            }
1377        }
1378    };
1379
1380    // Real JWT — validate and return claims
1381    match auth.validate_token(&access_token).await {
1382        Ok(claims) => axum::Json(serde_json::json!({
1383            "authenticated": true,
1384            "auth_enabled": true,
1385            "sub": claims.sub,
1386            "email": claims.email,
1387            "name": claims.name,
1388            "username": claims.preferred_username,
1389            "dev_mode": false
1390        }))
1391        .into_response(),
1392        Err(e) => {
1393            tracing::debug!("Token validation failed for /auth/me: {}", e);
1394            axum::Json(serde_json::json!({
1395                "authenticated": false,
1396                "auth_enabled": true
1397            }))
1398            .into_response()
1399        }
1400    }
1401}
1402
1403/// POST /auth/logout — destroy session and clear auth cookie.
1404async fn logout_handler(
1405    State(state): State<crate::ServerState>,
1406    headers: axum::http::HeaderMap,
1407) -> Response {
1408    let cookie_name = state
1409        .auth
1410        .as_ref()
1411        .map(|a| a.config.cookie_name.as_str())
1412        .unwrap_or("trustee_token");
1413
1414    // Destroy the session on the server side
1415    if let Some(ref auth) = state.auth {
1416        if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
1417            if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
1418                if !session_id.starts_with("dev:") {
1419                    let _ = auth.session_manager.destroy_session(&session_id);
1420                }
1421            }
1422        }
1423    }
1424
1425    let cookie = Cookie::build((cookie_name.to_string(), ""))
1426        .path("/")
1427        .http_only(true)
1428        .same_site(SameSite::Lax)
1429        .max_age(TimeDuration::seconds(-1))
1430        .build();
1431
1432    Response::builder()
1433        .status(StatusCode::FOUND)
1434        .header(header::LOCATION, "/")
1435        .header(header::SET_COOKIE, cookie.to_string())
1436        .body(Body::empty())
1437        .unwrap()
1438}
1439
1440// ---------------------------------------------------------------------------
1441// MCP auth routes: /auth/mcp/login, /callback, /status, /logout (C2)
1442// ---------------------------------------------------------------------------
1443
1444/// Query parameters for MCP login initiation.
1445#[derive(Debug, Deserialize)]
1446pub struct McpLoginQuery {
1447    pub cred: String,
1448}
1449
1450/// Query parameters for MCP OIDC callback.
1451#[derive(Debug, Deserialize)]
1452pub struct McpCallbackQuery {
1453    pub code: Option<String>,
1454    pub state: Option<String>,
1455    pub error: Option<String>,
1456    pub error_description: Option<String>,
1457}
1458
1459/// GET /auth/mcp/login?cred=<name> — initiate per-server OIDC PKCE login.
1460///
1461/// Reads the credential config from the session's config_toml, verifies it's
1462/// `type = "web-interactive"`, then redirects to the OIDC provider.
1463async fn mcp_login_handler(
1464    State(state): State<crate::ServerState>,
1465    Query(query): Query<McpLoginQuery>,
1466    headers: axum::http::HeaderMap,
1467) -> Result<Response, AuthError> {
1468    // Require authentication — user must be logged into trustee-web
1469    let (_cookie, _user_key) = crate::auth::check_auth(
1470        &state.auth,
1471        &headers,
1472        crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1473    )
1474    .await
1475    .map_err(|_| AuthError::AuthNotConfigured)?;
1476
1477    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1478
1479    // Parse MCP credential config from session's config_toml
1480    let cred_config = load_mcp_credential(&state, &query.cred).await?;
1481
1482    let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1483        McpCredentialInfo::WebInteractive {
1484            issuer_url,
1485            client_id,
1486            client_secret,
1487            scope,
1488        } => (
1489            issuer_url.clone(),
1490            client_id.clone(),
1491            client_secret.clone(),
1492            scope.clone(),
1493        ),
1494        _ => {
1495            return Ok(Redirect::temporary(&format!(
1496                "/?mcp_error={}",
1497                urlencoding::encode(&format!(
1498                    "Credential '{}' is not web-interactive type",
1499                    query.cred
1500                ))
1501            ))
1502            .into_response());
1503        }
1504    };
1505
1506    // Build PKCE pair using a separate PkceCookieManager for MCP
1507    let oidc_client = OidcClient::new();
1508    let verifier = OidcClient::generate_code_verifier();
1509    let challenge = OidcClient::generate_code_challenge(&verifier);
1510    let oauth_state = OidcClient::generate_state();
1511
1512    // Build OidcClientConfig for the MCP credential's OIDC client
1513    let mcp_redirect_uri = format!(
1514        "{}/auth/mcp/callback",
1515        auth.client_config
1516            .redirect_uri
1517            .trim_end_matches('/')
1518            .trim_end_matches("/auth/callback")
1519    );
1520
1521    let mcp_client_config = OidcClientConfig {
1522        issuer_url: issuer_url.clone(),
1523        client_id: client_id.clone(),
1524        client_secret: client_secret.clone(),
1525        redirect_uri: mcp_redirect_uri.clone(),
1526        scope: scope.clone(),
1527        code_challenge_method: "S256".to_string(),
1528    };
1529
1530    // Build authorization URL
1531    let auth_url = oidc_client
1532        .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
1533        .await
1534        .map_err(|e| AuthError::OidcError(e.to_string()))?;
1535
1536    // Store PKCE state + credential name in the in-memory map
1537    mcp_pkce()
1538        .insert(oauth_state.clone(), verifier.clone(), query.cred.clone())
1539        .await;
1540
1541    tracing::info!(
1542        "Initiating MCP browser login for credential '{}' (issuer={})",
1543        query.cred,
1544        issuer_url
1545    );
1546
1547    Ok(Response::builder()
1548        .status(StatusCode::TEMPORARY_REDIRECT)
1549        .header(header::LOCATION, &auth_url)
1550        .body(Body::empty())
1551        .unwrap())
1552}
1553
1554/// GET /auth/mcp/callback — handle MCP OIDC callback, store tokens.
1555async fn mcp_callback_handler(
1556    State(state): State<crate::ServerState>,
1557    Query(query): Query<McpCallbackQuery>,
1558    headers: axum::http::HeaderMap,
1559) -> Result<Response, AuthError> {
1560    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
1561
1562    // Check for errors from IdP
1563    if let Some(error) = query.error {
1564        let desc = query.error_description.unwrap_or_default();
1565        tracing::error!("MCP OIDC error: {} - {}", error, desc);
1566        return Ok(Redirect::temporary(&format!(
1567            "/?mcp_error={}&error_description={}",
1568            urlencoding::encode(&error),
1569            urlencoding::encode(&desc)
1570        ))
1571        .into_response());
1572    }
1573
1574    let code = query.code.ok_or(AuthError::MissingCode)?;
1575    let oauth_state = query.state.ok_or(AuthError::MissingState)?;
1576
1577    // Look up PKCE verifier + credential name from in-memory store
1578    let pkce_data = mcp_pkce()
1579        .take(&oauth_state)
1580        .await
1581        .ok_or(AuthError::InvalidState)?;
1582
1583    let verifier = pkce_data.verifier;
1584    let cred_name = &pkce_data.cred_name;
1585
1586    // Parse the MCP credential config to get OIDC settings for token exchange
1587    let cred_config = load_mcp_credential(&state, cred_name).await?;
1588
1589    let (issuer_url, client_id, client_secret, scope) = match &cred_config {
1590        McpCredentialInfo::WebInteractive {
1591            issuer_url,
1592            client_id,
1593            client_secret,
1594            scope,
1595        } => (
1596            issuer_url.clone(),
1597            client_id.clone(),
1598            client_secret.clone(),
1599            scope.clone(),
1600        ),
1601        _ => {
1602            return Ok(Redirect::temporary(&format!(
1603                "/?mcp_error={}",
1604                urlencoding::encode("Credential is not web-interactive type")
1605            ))
1606            .into_response());
1607        }
1608    };
1609
1610    // Build redirect URI (must match what was used in login)
1611    let mcp_redirect_uri = format!(
1612        "{}/auth/mcp/callback",
1613        auth.client_config
1614            .redirect_uri
1615            .trim_end_matches('/')
1616            .trim_end_matches("/auth/callback")
1617    );
1618
1619    let mcp_client_config = OidcClientConfig {
1620        issuer_url: issuer_url.clone(),
1621        client_id: client_id.clone(),
1622        client_secret: client_secret.clone(),
1623        redirect_uri: mcp_redirect_uri,
1624        scope: scope.clone(),
1625        code_challenge_method: "S256".to_string(),
1626    };
1627
1628    // Exchange code for tokens
1629    tracing::info!(
1630        "Exchanging MCP authorization code for tokens (credential={})",
1631        cred_name
1632    );
1633    let oidc_client = OidcClient::new();
1634    let token_response = oidc_client
1635        .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
1636        .await
1637        .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
1638
1639    // Compute expires_at
1640    let expires_at = {
1641        let now = std::time::SystemTime::now()
1642            .duration_since(std::time::UNIX_EPOCH)
1643            .unwrap_or_default()
1644            .as_secs();
1645        let expires_epoch = now + token_response.expires_in.unwrap_or(900);
1646        let days = expires_epoch / 86400;
1647        let rem = expires_epoch % 86400;
1648        let h = rem / 3600;
1649        let m = (rem % 3600) / 60;
1650        let s = rem % 60;
1651        let z = days as i64 + 719468;
1652        let era = if z >= 0 { z } else { z - 146096 } / 146097;
1653        let doe = (z - era * 146097) as u64;
1654        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1655        let y = yoe as i64 + era * 400;
1656        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1657        let mp = (5 * doy + 2) / 153;
1658        let d = doy - (153 * mp + 2) / 5 + 1;
1659        let mon = if mp < 10 { mp + 3 } else { mp - 9 };
1660        let yr = if mon <= 2 { y + 1 } else { y };
1661        format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
1662    };
1663
1664    // Store via FileTokenStore (same as `trustee mcp auth`)
1665    use pep::{FileTokenStore, StoredToken, TokenStore};
1666
1667    let stored = StoredToken::new(
1668        &token_response.access_token,
1669        token_response.refresh_token.clone(),
1670        "Bearer",
1671        &expires_at,
1672        token_response.scope.clone(),
1673    );
1674
1675    let agent_name = state
1676        .config_toml
1677        .as_ref()
1678        .and_then(|t| {
1679            toml::from_str::<toml::Value>(t).ok().and_then(|v| {
1680                v.get("agent")
1681                    .and_then(|a| a.get("name"))
1682                    .and_then(|n| n.as_str())
1683                    .map(String::from)
1684            })
1685        })
1686        .unwrap_or_else(|| "trustee".to_string());
1687    let token_store = FileTokenStore::new(&agent_name);
1688
1689    if let Err(e) = token_store.save(cred_name, &stored) {
1690        tracing::error!("Failed to store MCP token: {}", e);
1691        return Ok(Redirect::temporary(&format!(
1692            "/?mcp_error={}",
1693            urlencoding::encode(&format!("Failed to store token: {}", e))
1694        ))
1695        .into_response());
1696    }
1697
1698    tracing::info!(
1699        "MCP authentication successful for credential '{}' (expires {})",
1700        cred_name,
1701        expires_at
1702    );
1703
1704    Ok(Response::builder()
1705        .status(StatusCode::FOUND)
1706        .header(
1707            header::LOCATION,
1708            format!("/?mcp_connected={}", urlencoding::encode(cred_name)),
1709        )
1710        .body(Body::empty())
1711        .unwrap())
1712}
1713
1714/// GET /auth/mcp/status — return connection status for all MCP credentials.
1715async fn mcp_status_handler(
1716    State(state): State<crate::ServerState>,
1717    headers: axum::http::HeaderMap,
1718) -> Response {
1719    use pep::{FileTokenStore, TokenStore};
1720
1721    // Require auth
1722    let (_cookie, user_key) = match crate::auth::check_auth(
1723        &state.auth,
1724        &headers,
1725        crate::auth::actions::VIEW_MCP_CREDENTIALS,
1726    )
1727    .await
1728    {
1729        Ok(result) => result,
1730        Err(code) => {
1731            return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response()
1732        }
1733    };
1734
1735    // Parse MCP config from session
1736    let config_toml = {
1737        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1738        let session = session_arc.lock().await;
1739        match &session.config_toml {
1740            Some(t) => t.clone(),
1741            None => {
1742                return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response()
1743            }
1744        }
1745    };
1746
1747    let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1748        Ok(v) => v,
1749        Err(_) => return Json(serde_json::json!([])).into_response(),
1750    };
1751
1752    let agent_name = {
1753        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1754        let session = session_arc.lock().await;
1755        session.agent_name.clone()
1756    };
1757    let token_store = FileTokenStore::new(&agent_name);
1758
1759    // Build server → credential mapping
1760    let servers = mcp_config
1761        .get("mcp")
1762        .and_then(|m| m.get("servers"))
1763        .and_then(|s| s.as_array());
1764    let credentials = mcp_config
1765        .get("mcp")
1766        .and_then(|m| m.get("credentials"))
1767        .and_then(|c| c.as_table());
1768
1769    let mut cred_servers: std::collections::HashMap<String, Vec<String>> =
1770        std::collections::HashMap::new();
1771    if let Some(servers) = servers {
1772        for server in servers {
1773            let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1774            let cred_ref = server
1775                .get("credentials")
1776                .and_then(|c| c.as_str())
1777                .unwrap_or("");
1778            if !cred_ref.is_empty() {
1779                cred_servers
1780                    .entry(cred_ref.to_string())
1781                    .or_default()
1782                    .push(name.to_string());
1783            }
1784        }
1785    }
1786
1787    let mut result = Vec::new();
1788
1789    if let Some(creds) = credentials {
1790        for (cred_name, cred_config) in creds {
1791            let cred_type = cred_config
1792                .get("type")
1793                .and_then(|t| t.as_str())
1794                .unwrap_or("unknown");
1795            let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1796
1797            if cred_type == "web-session" {
1798                // Session credentials are always "connected" if auth is enabled
1799                let connected = state.auth.is_some();
1800                result.push(serde_json::json!({
1801                    "credential": cred_name,
1802                    "type": cred_type,
1803                    "connected": connected,
1804                    "servers": servers_using,
1805                }));
1806            } else if cred_type == "service-account" {
1807                // Long-lived service token, exchanged lazily (RFC 8693) by the
1808                // agent at runtime. "Connected" = the service_token resolved to
1809                // a non-empty value in this (already ${VAR}-substituted) config.
1810                let token = cred_config
1811                    .get("service_token")
1812                    .and_then(|t| t.as_str())
1813                    .unwrap_or("");
1814                result.push(serde_json::json!({
1815                    "credential": cred_name,
1816                    "type": cred_type,
1817                    "connected": !token.is_empty(),
1818                    "servers": servers_using,
1819                }));
1820            } else if cred_type == "static" {
1821                // Static token — connected when it resolved non-empty.
1822                let token = cred_config
1823                    .get("token")
1824                    .and_then(|t| t.as_str())
1825                    .unwrap_or("");
1826                result.push(serde_json::json!({
1827                    "credential": cred_name,
1828                    "type": cred_type,
1829                    "connected": !token.is_empty(),
1830                    "servers": servers_using,
1831                }));
1832            } else if cred_type == "web-interactive" || cred_type == "interactive" {
1833                // Check token store
1834                let status = match token_store.load(cred_name) {
1835                    Ok(Some(token)) => {
1836                        let expired = token.is_expired();
1837                        serde_json::json!({
1838                            "credential": cred_name,
1839                            "type": cred_type,
1840                            "connected": !expired,
1841                            "expires_at": token.expires_at,
1842                            "servers": servers_using,
1843                        })
1844                    }
1845                    _ => serde_json::json!({
1846                        "credential": cred_name,
1847                        "type": cred_type,
1848                        "connected": false,
1849                        "servers": servers_using,
1850                    }),
1851                };
1852                result.push(status);
1853            }
1854        }
1855    }
1856
1857    Json(serde_json::Value::Array(result)).into_response()
1858}
1859
1860/// POST /auth/mcp/logout?cred=<name> — remove stored MCP tokens.
1861async fn mcp_logout_handler(
1862    State(state): State<crate::ServerState>,
1863    Query(query): Query<McpLoginQuery>,
1864    headers: axum::http::HeaderMap,
1865) -> Response {
1866    use pep::{FileTokenStore, TokenStore};
1867
1868    // Require auth
1869    let (_cookie, user_key) = match crate::auth::check_auth(
1870        &state.auth,
1871        &headers,
1872        crate::auth::actions::UPDATE_MCP_CREDENTIALS,
1873    )
1874    .await
1875    {
1876        Ok(result) => result,
1877        Err(code) => {
1878            return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response()
1879        }
1880    };
1881
1882    let agent_name = {
1883        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1884        let session = session_arc.lock().await;
1885        session.agent_name.clone()
1886    };
1887    let token_store = FileTokenStore::new(&agent_name);
1888
1889    match token_store.delete(&query.cred) {
1890        Ok(()) => {
1891            tracing::info!("Removed MCP credentials for '{}'", query.cred);
1892            Json(serde_json::json!({"success": true})).into_response()
1893        }
1894        Err(e) => {
1895            tracing::error!("Failed to remove MCP credentials: {}", e);
1896            (
1897                StatusCode::INTERNAL_SERVER_ERROR,
1898                Json(serde_json::json!({"error": e.to_string()})),
1899            )
1900                .into_response()
1901        }
1902    }
1903}
1904
1905// ---------------------------------------------------------------------------
1906// MCP auth helpers
1907// ---------------------------------------------------------------------------
1908
1909/// In-memory store for MCP PKCE state (state token → verifier + credential name).
1910/// Entries expire after 10 minutes. Not persisted across restarts.
1911struct McpPkceStore {
1912    entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1913}
1914
1915struct McpPkceEntry {
1916    verifier: String,
1917    cred_name: String,
1918    created_at: std::time::Instant,
1919}
1920
1921impl McpPkceStore {
1922    fn new() -> Self {
1923        Self {
1924            entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1925        }
1926    }
1927
1928    /// Insert a PKCE entry. Cleans up entries older than 10 minutes.
1929    async fn insert(&self, state: String, verifier: String, cred_name: String) {
1930        let mut map = self.entries.lock().await;
1931        // Cleanup expired entries (older than 10 min)
1932        let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1933        map.retain(|_, v| v.created_at > cutoff);
1934        map.insert(
1935            state,
1936            McpPkceEntry {
1937                verifier,
1938                cred_name,
1939                created_at: std::time::Instant::now(),
1940            },
1941        );
1942    }
1943
1944    /// Take and remove a PKCE entry (single-use).
1945    async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1946        let mut map = self.entries.lock().await;
1947        map.remove(state)
1948    }
1949}
1950
1951/// Global singleton PKCE store for MCP browser logins.
1952static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1953
1954/// Get or initialize the global MCP PKCE store.
1955fn mcp_pkce() -> &'static McpPkceStore {
1956    MCP_PKCE.get_or_init(McpPkceStore::new)
1957}
1958
1959/// Simplified MCP credential info (parsed from TOML).
1960enum McpCredentialInfo {
1961    WebInteractive {
1962        issuer_url: String,
1963        client_id: String,
1964        client_secret: Option<String>,
1965        scope: String,
1966    },
1967    Other(String),
1968}
1969
1970/// Load a specific MCP credential from the session's config_toml.
1971async fn load_mcp_credential(
1972    state: &crate::ServerState,
1973    cred_name: &str,
1974) -> Result<McpCredentialInfo, AuthError> {
1975    let config_toml = state
1976        .config_toml
1977        .clone()
1978        .ok_or(AuthError::AuthNotConfigured)?;
1979
1980    let config: toml::Value = toml::from_str(&config_toml)
1981        .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1982
1983    let cred = config
1984        .get("mcp")
1985        .and_then(|m| m.get("credentials"))
1986        .and_then(|c| c.as_table())
1987        .and_then(|c| c.get(cred_name))
1988        .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1989
1990    let cred_type = cred
1991        .get("type")
1992        .and_then(|t| t.as_str())
1993        .unwrap_or("unknown");
1994
1995    match cred_type {
1996        "web-interactive" => {
1997            let issuer_url = cred
1998                .get("issuer_url")
1999                .and_then(|v| v.as_str())
2000                .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
2001                .to_string();
2002            let client_id = cred
2003                .get("client_id")
2004                .and_then(|v| v.as_str())
2005                .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
2006                .to_string();
2007            let client_secret = cred
2008                .get("client_secret")
2009                .and_then(|v| v.as_str())
2010                .map(String::from);
2011            let scope = cred
2012                .get("scope")
2013                .and_then(|v| v.as_str())
2014                .unwrap_or("openid profile email")
2015                .to_string();
2016
2017            Ok(McpCredentialInfo::WebInteractive {
2018                issuer_url,
2019                client_id,
2020                client_secret,
2021                scope,
2022            })
2023        }
2024        other => Ok(McpCredentialInfo::Other(other.to_string())),
2025    }
2026}
2027
2028// ---------------------------------------------------------------------------
2029// Helpers
2030// ---------------------------------------------------------------------------
2031
2032/// Create an HttpOnly auth cookie.
2033fn create_auth_cookie(
2034    name: &str,
2035    value: &str,
2036    max_age: StdDuration,
2037    secure: bool,
2038) -> Cookie<'static> {
2039    Cookie::build((name.to_string(), value.to_string()))
2040        .path("/")
2041        .http_only(true)
2042        .same_site(SameSite::Lax)
2043        .secure(secure)
2044        .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
2045        .build()
2046}
2047
2048// ---------------------------------------------------------------------------
2049// Error handling
2050// ---------------------------------------------------------------------------
2051
2052/// Authentication errors.
2053#[derive(Debug)]
2054pub enum AuthError {
2055    MissingCode,
2056    MissingState,
2057    InvalidState,
2058    OidcError(String),
2059    TokenExchangeFailed(String),
2060    AuthNotConfigured,
2061}
2062
2063impl IntoResponse for AuthError {
2064    fn into_response(self) -> Response {
2065        let (_status, msg) = match self {
2066            AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
2067            AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
2068            AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
2069            AuthError::OidcError(_) => (
2070                StatusCode::SERVICE_UNAVAILABLE,
2071                "Authentication service error",
2072            ),
2073            AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
2074            AuthError::AuthNotConfigured => {
2075                (StatusCode::NOT_IMPLEMENTED, "Authentication not configured")
2076            }
2077        };
2078        Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
2079    }
2080}
2081
2082#[cfg(test)]
2083mod principal_tests {
2084    use super::*;
2085    use pep::oidc::types::JwtClaims;
2086    use std::collections::HashMap;
2087
2088    fn claims_with_role(role: serde_json::Value) -> JwtClaims {
2089        let mut c = JwtClaims::default();
2090        c.sub = "sub-uuid".to_string();
2091        c.preferred_username = Some("farzan".to_string());
2092        c.extra.insert("role".to_string(), role);
2093        c
2094    }
2095
2096    // -- dev_user_key (16D dev:agent namespace) ------------------------------
2097
2098    #[test]
2099    fn dev_agent_token_yields_agent_namespaced_key() {
2100        assert_eq!(
2101            dev_user_key("dev:agent:farzan"),
2102            Some("agent-farzan".to_string())
2103        );
2104        assert_eq!(
2105            dev_user_key("dev:agent:paydar"),
2106            Some("agent-paydar".to_string())
2107        );
2108    }
2109
2110    #[test]
2111    fn dev_agent_token_rejects_empty_and_colon_names() {
2112        assert_eq!(dev_user_key("dev:agent:"), None);
2113        assert_eq!(dev_user_key("dev:agent:  "), None);
2114        assert_eq!(
2115            dev_user_key("dev:agent:a:b"),
2116            None,
2117            "name must not contain ':'"
2118        );
2119    }
2120
2121    #[test]
2122    fn dev_human_token_format_unchanged() {
2123        assert_eq!(
2124            dev_user_key("dev:a@b.c:Some Name:someuser"),
2125            Some("dev:a@b.c".to_string())
2126        );
2127        assert_eq!(dev_user_key("dev:only:two"), None);
2128    }
2129
2130    // -- claim_role / PrincipalKind (string OR array role) --------------------
2131
2132    #[test]
2133    fn role_as_string_classifies_agent() {
2134        let c = claims_with_role(serde_json::json!("agent"));
2135        assert_eq!(claim_role(&c).as_deref(), Some("agent"));
2136        assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
2137    }
2138
2139    #[test]
2140    fn role_as_array_takes_first_value() {
2141        // Kanidm may deliver role as an array (pep 366e8ed lesson).
2142        let c = claims_with_role(serde_json::json!(["agent", "other"]));
2143        assert_eq!(claim_role(&c).as_deref(), Some("agent"));
2144        assert_eq!(AuthUser::from(c).kind, PrincipalKind::Agent);
2145    }
2146
2147    #[test]
2148    fn non_agent_roles_classify_human() {
2149        for role in ["user", "admin", "service"] {
2150            let c = claims_with_role(serde_json::json!(role));
2151            assert_eq!(claim_role(&c).as_deref(), Some(role));
2152            assert_eq!(AuthUser::from(c).kind, PrincipalKind::Human, "role={role}");
2153        }
2154    }
2155
2156    #[test]
2157    fn missing_or_nonstring_role_classifies_human() {
2158        let mut c = JwtClaims::default();
2159        c.sub = "sub-uuid".to_string();
2160        assert_eq!(claim_role(&c), None);
2161        assert_eq!(AuthUser::from(c.clone()).kind, PrincipalKind::Human);
2162        c.extra.insert("role".to_string(), serde_json::json!(42));
2163        assert_eq!(claim_role(&c), None, "non-string non-array role ignored");
2164    }
2165
2166    // -- jwt_user_key (PINNED: preferred_username || sub, never email) --------
2167
2168    #[test]
2169    fn user_key_prefers_preferred_username() {
2170        let mut c = JwtClaims::default();
2171        c.sub = "sub-uuid".to_string();
2172        c.preferred_username = Some("farzan".to_string());
2173        c.email = Some("rebindable@example.com".to_string());
2174        assert_eq!(jwt_user_key(&c), "farzan", "email must never be the key");
2175    }
2176
2177    #[test]
2178    fn user_key_falls_back_to_sub_on_blank_username() {
2179        let mut c = JwtClaims::default();
2180        c.sub = "sub-uuid".to_string();
2181        c.preferred_username = Some("   ".to_string());
2182        assert_eq!(jwt_user_key(&c), "sub-uuid");
2183        c.preferred_username = None;
2184        assert_eq!(jwt_user_key(&c), "sub-uuid");
2185    }
2186
2187    #[test]
2188    fn user_key_agent_pinned_to_sub_even_with_username() {
2189        // 16E: agent principals NEVER use preferred_username — a future
2190        // token-scope change must not re-home agent namespaces.
2191        let mut c = claims_with_role(serde_json::json!("agent"));
2192        c.sub = "agent-sub-uuid".to_string();
2193        c.preferred_username = Some("farzan".to_string());
2194        assert_eq!(jwt_user_key(&c), "agent-sub-uuid");
2195    }
2196
2197    #[test]
2198    fn authuser_carries_role_and_kind() {
2199        let c = claims_with_role(serde_json::json!("agent"));
2200        let u = AuthUser::from(c);
2201        assert_eq!(u.role.as_deref(), Some("agent"));
2202        assert_eq!(u.kind, PrincipalKind::Agent);
2203        assert_eq!(u.username.as_deref(), Some("farzan"));
2204    }
2205}
2206
2207#[cfg(test)]
2208mod cedar_p2_tests {
2209    use super::*;
2210    use cedar_policy::{Context, Entities, EntityUid, Request};
2211    use pep::cedar::{CedarAuthorizer, CedarConfig};
2212    use std::collections::HashMap;
2213
2214    const POLICY: &str = include_str!("../policies/trustee_default.cedar");
2215    const SCHEMA: &str = include_str!("../policies/trustee_schema.cedarschema");
2216
2217    async fn authorizer() -> CedarAuthorizer {
2218        // Unique per call: tests run concurrently and must not share files.
2219        static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2220        let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2221        let dir = std::env::temp_dir().join(format!("trustee-cedar-p2-{}-{n}", std::process::id()));
2222        std::fs::create_dir_all(&dir).expect("temp dir");
2223        let policy_path = dir.join("trustee_default.cedar");
2224        let schema_path = dir.join("trustee_schema.cedarschema");
2225        std::fs::write(&policy_path, POLICY).expect("write policy");
2226        std::fs::write(&schema_path, SCHEMA).expect("write schema");
2227        let cfg = CedarConfig {
2228            policy_path,
2229            schema_path: Some(schema_path),
2230            entities_path: None,
2231            default_decision: pep::cedar::DefaultDecision::Deny,
2232            validate_on_load: true,
2233            policy_store_url: None,
2234            policy_store_token: None,
2235            embedded_policy: Some(POLICY),
2236            embedded_schema: Some(SCHEMA),
2237        };
2238        CedarAuthorizer::new_with_policy_store(cfg)
2239            .await
2240            .expect("Cedar init from shipped sources")
2241    }
2242
2243    fn claims_with_role(role: Option<&str>) -> JwtClaims {
2244        let mut c = JwtClaims::default();
2245        c.sub = "test-sub".to_string();
2246        if let Some(r) = role {
2247            c.extra.insert("role".to_string(), serde_json::json!(r));
2248        }
2249        c
2250    }
2251
2252    /// Mirrors check_cedar_authorized's request construction exactly.
2253    async fn allowed(auth: &CedarAuthorizer, role: Option<&str>, action: &str) -> bool {
2254        let claims = claims_with_role(role);
2255        let principal_entity = pep::cedar::build_principal_entity(&claims).unwrap();
2256        let app_entity = cedar_policy::Entity::new(
2257            EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
2258            HashMap::new(),
2259            std::collections::HashSet::new(),
2260        )
2261        .unwrap();
2262        let entities = Entities::from_entities(vec![principal_entity, app_entity], None).unwrap();
2263        let request = Request::new(
2264            pep::cedar::build_principal_uid(&claims).unwrap(),
2265            EntityUid::from_str(&format!("Action::\"{action}\"")).unwrap(),
2266            EntityUid::from_str(r#"TrusteeApp::"default""#).unwrap(),
2267            Context::empty(),
2268            None,
2269        )
2270        .unwrap();
2271        auth.is_allowed_with_entities(&request, &entities).allowed()
2272    }
2273
2274    #[tokio::test]
2275    async fn admin_allowed_including_destructive() {
2276        let auth = authorizer().await;
2277        for action in [
2278            actions::VIEW_SESSION,
2279            actions::COMMAND_SESSION,
2280            actions::DELETE_SESSION,
2281            actions::UPDATE_MCP_CREDENTIALS,
2282        ] {
2283            assert!(
2284                allowed(&auth, Some("admin"), action).await,
2285                "admin {action}"
2286            );
2287        }
2288    }
2289
2290    #[tokio::test]
2291    async fn user_full_session_management() {
2292        let auth = authorizer().await;
2293        for action in [
2294            actions::CREATE_SESSION,
2295            actions::COMMAND_SESSION,
2296            actions::DELETE_SESSION,
2297            actions::VIEW_HISTORY,
2298            actions::UPDATE_MCP_CREDENTIALS,
2299        ] {
2300            assert!(allowed(&auth, Some("user"), action).await, "user {action}");
2301        }
2302    }
2303
2304    #[tokio::test]
2305    async fn agent_working_set_but_delete_denied() {
2306        let auth = authorizer().await;
2307        for action in [
2308            actions::CREATE_SESSION,
2309            actions::COMMAND_SESSION,
2310            actions::CANCEL_SESSION,
2311            actions::RESUME_SESSION,
2312            actions::VIEW_HISTORY,
2313            actions::UPDATE_MCP_CREDENTIALS,
2314        ] {
2315            assert!(
2316                allowed(&auth, Some("agent"), action).await,
2317                "agent {action}"
2318            );
2319        }
2320        assert!(
2321            !allowed(&auth, Some("agent"), actions::DELETE_SESSION).await,
2322            "agent must NOT delete sessions (fail-closed start; revisit at task F)"
2323        );
2324    }
2325
2326    #[tokio::test]
2327    async fn service_read_only() {
2328        let auth = authorizer().await;
2329        for action in [
2330            actions::VIEW_SESSION,
2331            actions::LIST_SESSIONS,
2332            actions::VIEW_HISTORY,
2333        ] {
2334            assert!(
2335                allowed(&auth, Some("service"), action).await,
2336                "service {action}"
2337            );
2338        }
2339        for action in [actions::COMMAND_SESSION, actions::DELETE_SESSION] {
2340            assert!(
2341                !allowed(&auth, Some("service"), action).await,
2342                "service {action} denied"
2343            );
2344        }
2345    }
2346
2347    #[tokio::test]
2348    async fn missing_or_unknown_role_denied_everything() {
2349        let auth = authorizer().await;
2350        for role in [None, Some("intern"), Some("Admin")] {
2351            assert!(
2352                !allowed(&auth, role, actions::VIEW_SESSION).await,
2353                "role={role:?} must be denied (fail-closed default)"
2354            );
2355        }
2356    }
2357
2358    #[test]
2359    fn boot_decision_is_fail_closed() {
2360        assert!(crate::cedar_boot_decision(true, false, false).is_err());
2361        assert!(crate::cedar_boot_decision(true, false, true).is_ok());
2362        assert!(crate::cedar_boot_decision(true, true, false).is_ok());
2363        assert!(crate::cedar_boot_decision(false, false, false).is_ok());
2364    }
2365}
2366
2367/// Token validation cache (issue c41eb9d7) — pure-logic tests against a
2368/// standalone [`ValidationCache`]; no IdP, no network.
2369#[cfg(test)]
2370mod validation_cache_tests {
2371    use super::*;
2372    use std::sync::atomic::{AtomicUsize, Ordering};
2373
2374    fn claims_with(exp_in: i64, sub: &str) -> JwtClaims {
2375        let mut c = JwtClaims::default();
2376        c.exp = unix_now() + exp_in;
2377        c.sub = sub.to_string();
2378        c.preferred_username = Some("farzan".to_string());
2379        c
2380    }
2381
2382    /// ACCEPTANCE c41eb9d7: repeated polls = 1 validation, not 2.
2383    #[test]
2384    fn cache_hit_two_polls_one_validation() {
2385        let cache = ValidationCache::new();
2386        let validations = Arc::new(AtomicUsize::new(0));
2387        let key = validation_cache_key("token-A");
2388
2389        for _ in 0..2 {
2390            if cache.get(&key, unix_now()).is_some() {
2391                continue; // cache hit — poll served without validation
2392            }
2393            // stub: this block is the "real validation"
2394            validations.fetch_add(1, Ordering::SeqCst);
2395            cache.put(key, claims_with(3600, "sub-A"), unix_now());
2396        }
2397
2398        assert_eq!(
2399            validations.load(Ordering::SeqCst),
2400            1,
2401            "second poll must hit the cache, not re-validate"
2402        );
2403    }
2404
2405    /// Expiry: after valid_until the entry is a miss → re-validation.
2406    #[test]
2407    fn expired_entry_revalidates() {
2408        let cache = ValidationCache::new();
2409        let key = validation_cache_key("token-B");
2410        let now = unix_now();
2411        cache.put(key, claims_with(3600, "sub-B"), now);
2412
2413        assert!(cache.get(&key, now).is_some(), "fresh entry must hit");
2414        assert!(
2415            cache.get(&key, now + CACHE_TTL_SECS + 1).is_none(),
2416            "entry past valid_until must miss and force re-validation"
2417        );
2418    }
2419
2420    /// exp-grace: a token inside its last 120s is born stale — get() must
2421    /// never serve it, mirroring PEP's 60s skew leeway with margin.
2422    #[test]
2423    fn near_expiry_token_never_served_from_cache() {
2424        let cache = ValidationCache::new();
2425        let key = validation_cache_key("token-C");
2426        let now = unix_now();
2427        // exp in 60s → valid_until = exp - 120 < now
2428        cache.put(key, claims_with(60, "sub-C"), now);
2429        assert!(
2430            cache.get(&key, now).is_none(),
2431            "entry valid_until <= now must be rejected immediately"
2432        );
2433    }
2434
2435    /// Cap: at CACHE_MAX_ENTRIES the cache evicts instead of growing —
2436    /// correctness never depends on a hit (dropped entries re-validate).
2437    #[test]
2438    fn cap_evicts_instead_of_growing_unbounded() {
2439        let cache = ValidationCache::new();
2440        let now = unix_now();
2441        for i in 0..CACHE_MAX_ENTRIES {
2442            cache.put(
2443                validation_cache_key(&format!("tok-{i}")),
2444                claims_with(3600, "s"),
2445                now,
2446            );
2447        }
2448        // Map is all-hot → overflow clears rather than random-evicts.
2449        let overflow = validation_cache_key("tok-overflow");
2450        cache.put(overflow, claims_with(3600, "s"), now);
2451
2452        let inner = cache.inner.lock().unwrap();
2453        assert!(
2454            inner.entries.len() <= CACHE_MAX_ENTRIES,
2455            "cache must stay bounded"
2456        );
2457        assert!(
2458            inner.entries.contains_key(&overflow),
2459            "newest entry must survive"
2460        );
2461        assert!(
2462            !inner.entries.contains_key(&validation_cache_key("tok-0")),
2463            "pre-overflow entries were reset, not served stale forever"
2464        );
2465    }
2466
2467    /// INFO-vs-DEBUG gate: first sight per (sub, issuer), never again.
2468    #[test]
2469    fn first_sight_fires_once_per_sub_issuer_pair() {
2470        let cache = ValidationCache::new();
2471        assert!(cache.mark_first_sight("sub-A", "https://idp.tanbal.ir"));
2472        assert!(!cache.mark_first_sight("sub-A", "https://idp.tanbal.ir"));
2473        assert!(
2474            cache.mark_first_sight("sub-A", "https://other.tanbal.ir"),
2475            "different issuer = first sight"
2476        );
2477        assert!(
2478            cache.mark_first_sight("sub-B", "https://idp.tanbal.ir"),
2479            "different sub = first sight"
2480        );
2481    }
2482
2483    /// 128-bit truncated SHA-256: distinct tokens must not collide.
2484    #[test]
2485    fn distinct_tokens_distinct_keys() {
2486        assert_ne!(validation_cache_key("tok-1"), validation_cache_key("tok-2"));
2487        // Raw token material must not leak into the stored key
2488        assert_eq!(
2489            validation_cache_key("secret"),
2490            validation_cache_key("secret")
2491        );
2492    }
2493}