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