Skip to main content

trustee_api/
auth.rs

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