Skip to main content

trustee_api/
auth.rs

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