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
33// ---------------------------------------------------------------------------
34// Configuration
35// ---------------------------------------------------------------------------
36
37/// Authentication configuration parsed from `[oidc]` and `[dev]` TOML sections.
38#[derive(Debug, Clone)]
39pub struct AuthConfig {
40    /// OIDC provider issuer URL
41    pub issuer_url: String,
42    /// OAuth2 client ID
43    pub client_id: String,
44    /// OAuth2 client secret (None → public client, PKCE only)
45    pub client_secret: Option<String>,
46    /// Redirect URI for OIDC callback
47    pub redirect_uri: String,
48    /// OAuth2 scopes
49    pub scope: String,
50    /// Token cookie name
51    pub cookie_name: String,
52    /// Development mode configuration
53    pub dev_config: DevConfig,
54    /// JWT validation options
55    pub validation_options: JwtValidationOptions,
56    /// Secret for signing PKCE state cookies
57    pub pkce_cookie_secret: String,
58}
59
60impl AuthConfig {
61    /// Parse auth config from the merged trustee TOML string.
62    ///
63    /// Reads `[oidc]` and `[dev]` sections. If neither is present, returns None
64    /// (auth disabled — all endpoints open).
65    pub fn from_toml(config_toml: &str) -> Option<Self> {
66        let table: toml::Table = toml::from_str(config_toml).ok()?;
67
68        // Check for dev mode
69        let dev_config = table.get("dev").and_then(|d| d.as_table()).map(|d| {
70            DevConfig {
71                local_dev_mode: d.get("local_dev_mode").and_then(|v| v.as_bool()).unwrap_or(false),
72                local_dev_email: d.get("local_dev_email").and_then(|v| v.as_str()).map(String::from),
73                local_dev_name: d.get("local_dev_name").and_then(|v| v.as_str()).map(String::from),
74                local_dev_username: d.get("local_dev_username").and_then(|v| v.as_str()).map(String::from),
75            }
76        });
77
78        // Dev mode without OIDC — return early with dev-only config
79        if let Some(ref dc) = dev_config {
80            if dc.local_dev_mode {
81                // Try to get OIDC config too (for login endpoint), but it's optional in dev mode
82                let oidc = Self::parse_oidc_section(&table);
83                return Some(Self {
84                    issuer_url: oidc.as_ref().map(|o| o.0.clone()).unwrap_or_else(|| "https://auth.example.com".into()),
85                    client_id: oidc.as_ref().map(|o| o.1.clone()).unwrap_or_else(|| "trustee".into()),
86                    client_secret: oidc.as_ref().and_then(|o| o.2.clone()),
87                    redirect_uri: oidc.as_ref().map(|o| o.3.clone()).unwrap_or_else(|| "http://localhost:3000/auth/callback".into()),
88                    scope: oidc.as_ref().map(|o| o.4.clone()).unwrap_or_else(|| "openid profile email".into()),
89                    cookie_name: "trustee_token".into(),
90                    dev_config: dc.clone(),
91                    validation_options: JwtValidationOptions::default(),
92                    pkce_cookie_secret: oidc.as_ref().map(|o| o.6.clone()).unwrap_or_else(|| "trustee-default-pkce-secret-change-me".into()),
93                });
94            }
95        }
96
97        // Production mode — requires [oidc] section
98        let (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret) =
99            Self::parse_oidc_section(&table)?;
100
101        Some(Self {
102            issuer_url,
103            client_id,
104            client_secret,
105            redirect_uri,
106            scope,
107            cookie_name: "trustee_token".into(),
108            dev_config: dev_config.unwrap_or_default(),
109            validation_options,
110            pkce_cookie_secret: pkce_secret,
111        })
112    }
113
114    /// Parse the `[oidc]` section from a TOML table.
115    /// Returns (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret).
116    fn parse_oidc_section(
117        table: &toml::Table,
118    ) -> Option<(String, String, Option<String>, String, String, JwtValidationOptions, String)> {
119        let oidc = table.get("oidc")?.as_table()?;
120
121        let issuer_url = oidc.get("issuer_url")?.as_str()?.to_string();
122        let client_id = oidc.get("client_id")?.as_str()?.to_string();
123        let client_secret = oidc.get("client_secret").and_then(|v| v.as_str()).map(String::from);
124        let redirect_uri = oidc
125            .get("redirect_url")
126            .and_then(|v| v.as_str())
127            .unwrap_or("http://localhost:3000/auth/callback")
128            .to_string();
129        let scope = oidc
130            .get("scope")
131            .and_then(|v| v.as_str())
132            .unwrap_or("openid profile email")
133            .to_string();
134
135        let mut validation_options = JwtValidationOptions::default();
136        if let Some(skip) = oidc.get("skip_issuer_validation").and_then(|v| v.as_bool()) {
137            validation_options.skip_issuer_validation = skip;
138        }
139        if let Some(skip) = oidc.get("skip_audience_validation").and_then(|v| v.as_bool()) {
140            validation_options.skip_audience_validation = skip;
141        }
142        validation_options.expected_audience = oidc
143            .get("expected_audience")
144            .and_then(|v| v.as_str())
145            .map(String::from);
146
147        let pkce_secret = oidc
148            .get("pkce_cookie_secret")
149            .and_then(|v| v.as_str())
150            .unwrap_or("trustee-default-pkce-secret-change-me")
151            .to_string();
152
153        Some((issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret))
154    }
155
156    /// Build OIDC client configuration for PEP's OidcClient.
157    pub fn oidc_client_config(&self) -> OidcClientConfig {
158        OidcClientConfig {
159            issuer_url: self.issuer_url.clone(),
160            client_id: self.client_id.clone(),
161            client_secret: self.client_secret.clone(),
162            redirect_uri: self.redirect_uri.clone(),
163            scope: self.scope.clone(),
164            code_challenge_method: "S256".to_string(),
165        }
166    }
167}
168
169/// Shared authentication state, stored in ServerState.
170#[derive(Clone)]
171pub struct AuthState {
172    /// OIDC client for login flow (authorization code + PKCE)
173    pub oidc_client: OidcClient,
174    /// Resource server client for JWT validation (lazy-initialized)
175    pub resource_server: ResourceServerClient,
176    /// OIDC client configuration
177    pub client_config: OidcClientConfig,
178    /// Auth configuration
179    pub config: AuthConfig,
180    /// Stateless PKCE cookie manager
181    pub pkce_manager: PkceCookieManager,
182    /// Web session manager (cookie session_id → server-side token with auto-refresh)
183    pub session_manager: Arc<WebSessionManager>,
184}
185
186impl AuthState {
187    /// Create new auth state from configuration.
188    pub fn new(config: AuthConfig) -> Self {
189        let pkce_manager = PkceCookieManager::new(
190            config.pkce_cookie_secret.as_bytes(),
191            "trustee_pkce_state",
192            StdDuration::from_secs(600),
193        );
194
195        let session_manager = Arc::new(WebSessionManager::new(
196            OidcClient::new(),
197            config.issuer_url.clone(),
198            config.client_id.clone(),
199            config.client_secret.clone(),
200            config.scope.clone(),
201        ));
202
203        Self {
204            oidc_client: OidcClient::new(),
205            resource_server: ResourceServerClient::new(),
206            client_config: config.oidc_client_config(),
207            pkce_manager,
208            session_manager,
209            config,
210        }
211    }
212
213    /// Check if development mode is enabled.
214    pub fn is_dev_mode(&self) -> bool {
215        self.config.dev_config.local_dev_mode
216    }
217
218    /// Validate a JWT token using PEP's ResourceServerClient.
219    pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
220        let mut claims = self
221            .resource_server
222            .validate_jwt_with_options(
223                token,
224                &self.config.issuer_url,
225                &self.config.client_id,
226                &self.config.validation_options,
227            )
228            .await
229            .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
230
231        // Enrich with userinfo for role/groups (cached, no-op if already present)
232        let _ = self
233            .resource_server
234            .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
235            .await;
236
237        // PEP only merges groups/role from userinfo. If name/email are missing
238        // (Kanidm JWTs only contain sub), fetch them from userinfo directly.
239        if claims.name.is_none() || claims.email.is_none() {
240            self.fill_userinfo_fields(&mut claims, token).await;
241        }
242
243        Ok(claims)
244    }
245
246    /// Fetch name/email/preferred_username from the OIDC userinfo endpoint
247    /// and fill in any that are missing from the JWT claims.
248    async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
249        // Derive userinfo URL from issuer
250        // For Kanidm: issuer_url is the discovery endpoint,
251        // userinfo is at {issuer_url}/userinfo
252        let userinfo_url = format!("{}/userinfo", self.config.issuer_url.trim_end_matches('/'));
253
254        let client = reqwest::Client::new();
255        let resp = client
256            .get(&userinfo_url)
257            .header("Authorization", format!("Bearer {}", token))
258            .header("Accept", "application/json")
259            .send()
260            .await;
261
262        let Ok(resp) = resp else {
263            tracing::debug!("Userinfo request failed for name/email enrichment");
264            return;
265        };
266
267        if !resp.status().is_success() {
268            tracing::debug!("Userinfo returned {} for name/email enrichment", resp.status());
269            return;
270        }
271
272        let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await else {
273            return;
274        };
275
276        tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
277
278        if claims.name.is_none() {
279            if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
280                claims.name = Some(name.to_string());
281            }
282        }
283        if claims.email.is_none() {
284            if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
285                claims.email = Some(email.to_string());
286            }
287        }
288        if claims.preferred_username.is_none() {
289            if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
290                claims.preferred_username = Some(uname.to_string());
291            }
292        }
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Auth checking — called by protected route handlers
298// ---------------------------------------------------------------------------
299
300/// Authenticated user info extracted from the token.
301#[derive(Debug, Clone)]
302pub struct AuthUser {
303    pub sub: String,
304    pub email: Option<String>,
305    pub name: Option<String>,
306    pub username: Option<String>,
307    pub is_dev: bool,
308}
309
310impl From<JwtClaims> for AuthUser {
311    fn from(claims: JwtClaims) -> Self {
312        Self {
313            sub: claims.sub,
314            email: claims.email,
315            name: claims.name,
316            username: claims.preferred_username,
317            is_dev: false,
318        }
319    }
320}
321
322/// Cookie max-age for session cookies (1 hour, matching the server-side idle timeout).
323const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
324
325/// Check authentication for a protected endpoint.
326///
327/// Returns `Ok(None)` if auth is not configured (open mode), or if a valid
328/// token is present without needing cookie renewal. Returns `Ok(Some(cookie))`
329/// if auth succeeded and the caller should include the given `Set-Cookie`
330/// header value in the response (rolling session). Returns `Err(StatusCode)`
331/// if auth is configured but no valid token is found.
332///
333/// Token sources (in order):
334/// 1. `Authorization: Bearer <token>` header (raw JWT — validated directly)
335/// 2. `trustee_token=<session_id>` cookie (looked up in WebSessionManager,
336///    auto-refreshed if near expiry)
337///
338/// Dev mode tokens use the format `dev:email:name:username`.
339pub async fn check_auth(
340    auth: &Option<Arc<AuthState>>,
341    headers: &axum::http::HeaderMap,
342) -> Result<Option<String>, StatusCode> {
343    let Some(auth) = auth.as_ref() else {
344        return Ok(None); // Auth not configured — allow
345    };
346
347    // 1. Try Bearer header first (raw JWT — e.g. from API clients, Torpi proxy)
348    if let Some(token) = headers
349        .get(header::AUTHORIZATION)
350        .and_then(|v| v.to_str().ok())
351        .and_then(|v| v.strip_prefix("Bearer "))
352        .map(|s| s.to_string())
353    {
354        // Dev mode token — only accepted when dev mode is currently enabled
355        if token.starts_with("dev:") {
356            if !auth.config.dev_config.local_dev_mode {
357                tracing::warn!("Dev token presented but dev mode is disabled — rejecting");
358                return Err(StatusCode::UNAUTHORIZED);
359            }
360            let parts: Vec<&str> = token.splitn(4, ':').collect();
361            return if parts.len() >= 4 {
362                Ok(None)
363            } else {
364                Err(StatusCode::UNAUTHORIZED)
365            };
366        }
367
368        return match auth.validate_token(&token).await {
369            Ok(_) => Ok(None),
370            Err(e) => {
371                tracing::warn!("Bearer token validation failed: {}", e);
372                Err(StatusCode::UNAUTHORIZED)
373            }
374        };
375    }
376
377    // 2. Try cookie (session_id → WebSessionManager → access token with auto-refresh)
378    let session_id = headers
379        .get(header::COOKIE)
380        .and_then(|v| v.to_str().ok())
381        .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
382
383    let Some(session_id) = session_id else {
384        tracing::warn!("No auth token found in request");
385        return Err(StatusCode::UNAUTHORIZED);
386    };
387
388    // Dev mode token in cookie — only accepted when dev mode is currently enabled
389    if session_id.starts_with("dev:") {
390        if !auth.config.dev_config.local_dev_mode {
391            tracing::warn!("Dev cookie presented but dev mode is disabled — rejecting");
392            return Err(StatusCode::UNAUTHORIZED);
393        }
394        let parts: Vec<&str> = session_id.splitn(4, ':').collect();
395        return if parts.len() >= 4 {
396            Ok(None)
397        } else {
398            Err(StatusCode::UNAUTHORIZED)
399        };
400    }
401
402    // Session-based: look up via WebSessionManager (auto-refreshes)
403    match auth.session_manager.get_token(&session_id).await {
404        Ok(access_token) => match auth.validate_token(&access_token).await {
405            Ok(_) => {
406                // Roll the cookie — reset max-age so active users stay logged in
407                let secure = auth.client_config.redirect_uri.starts_with("https");
408                let cookie = create_auth_cookie(
409                    &auth.config.cookie_name,
410                    &session_id,
411                    SESSION_COOKIE_MAX_AGE,
412                    secure,
413                );
414                Ok(Some(cookie.to_string()))
415            }
416            Err(e) => {
417                // Token was returned but JWT validation failed (e.g. ExpiredSignature
418                // due to clock skew). Force-refresh and retry once.
419                tracing::warn!("Session token validation failed: {} — attempting force-refresh", e);
420                match auth.session_manager.force_refresh(&session_id).await {
421                    Ok(new_token) => match auth.validate_token(&new_token).await {
422                        Ok(_) => {
423                            let secure = auth.client_config.redirect_uri.starts_with("https");
424                            let cookie = create_auth_cookie(
425                                &auth.config.cookie_name,
426                                &session_id,
427                                SESSION_COOKIE_MAX_AGE,
428                                secure,
429                            );
430                            Ok(Some(cookie.to_string()))
431                        }
432                        Err(e2) => {
433                            tracing::warn!("Session token still invalid after force-refresh: {}", e2);
434                            Err(StatusCode::UNAUTHORIZED)
435                        }
436                    },
437                    Err(e2) => {
438                        tracing::warn!("Force-refresh failed: {}", e2);
439                        Err(StatusCode::UNAUTHORIZED)
440                    }
441                }
442            }
443        },
444        Err(e) => {
445            tracing::warn!("Session lookup/refresh failed: {}", e);
446            Err(StatusCode::UNAUTHORIZED)
447        }
448    }
449}
450
451/// Extract a valid access token from the request (for use by handlers that
452/// need the token itself, not just auth checking).
453///
454/// Resolves session_id cookies to actual access tokens via WebSessionManager.
455/// Bearer headers are returned as-is.
456async fn resolve_access_token(
457    auth: &AuthState,
458    headers: &axum::http::HeaderMap,
459) -> Result<String, StatusCode> {
460    // Bearer header — return as-is
461    if let Some(token) = headers
462        .get(header::AUTHORIZATION)
463        .and_then(|v| v.to_str().ok())
464        .and_then(|v| v.strip_prefix("Bearer "))
465        .map(|s| s.to_string())
466    {
467        return Ok(token);
468    }
469
470    // Cookie — resolve session_id → access_token
471    let session_id = headers
472        .get(header::COOKIE)
473        .and_then(|v| v.to_str().ok())
474        .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name));
475
476    match session_id {
477        Some(sid) if sid.starts_with("dev:") => {
478            if !auth.config.dev_config.local_dev_mode {
479                tracing::warn!("Dev cookie in resolve_access_token but dev mode is disabled — rejecting");
480                Err(StatusCode::UNAUTHORIZED)
481            } else {
482                Ok(sid)
483            }
484        }
485        Some(sid) => auth.session_manager.get_token(&sid).await.map_err(|e| {
486            tracing::warn!("Failed to resolve session token: {}", e);
487            StatusCode::UNAUTHORIZED
488        }),
489        None => Err(StatusCode::UNAUTHORIZED),
490    }
491}
492
493/// Extract token value from a cookie header string.
494fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
495    for cookie in cookie_header.split(';') {
496        let cookie = cookie.trim();
497        if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
498            return Some(value.to_string());
499        }
500    }
501    None
502}
503
504// ---------------------------------------------------------------------------
505// Auth routes: /auth/login, /auth/callback, /auth/me, /auth/logout
506// ---------------------------------------------------------------------------
507
508/// Build the auth routes as a nested Router.
509pub fn auth_routes() -> axum::Router<crate::ServerState> {
510    axum::Router::new()
511        .route("/login", axum::routing::get(login_handler))
512        .route("/callback", axum::routing::get(callback_handler))
513        .route("/me", axum::routing::get(me_handler))
514        .route("/logout", axum::routing::post(logout_handler))
515        .route("/mcp/login", axum::routing::get(mcp_login_handler))
516        .route("/mcp/callback", axum::routing::get(mcp_callback_handler))
517        .route("/mcp/status", axum::routing::get(mcp_status_handler))
518        .route("/mcp/logout", axum::routing::post(mcp_logout_handler))
519}
520
521/// Query parameters for OIDC callback.
522#[derive(Debug, Deserialize)]
523pub struct CallbackQuery {
524    pub code: Option<String>,
525    pub state: Option<String>,
526    pub error: Option<String>,
527    pub error_description: Option<String>,
528}
529
530/// GET /auth/login — initiate OIDC login with PKCE, or create dev session.
531async fn login_handler(
532    State(state): State<crate::ServerState>,
533) -> Result<Response, AuthError> {
534    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
535
536    // Dev mode — create synthetic session
537    if auth.is_dev_mode() {
538        tracing::info!("Dev mode: creating dev session");
539        let dev = &auth.config.dev_config;
540        let dev_token = format!(
541            "dev:{}:{}:{}",
542            dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
543            dev.local_dev_name.as_deref().unwrap_or("Dev User"),
544            dev.local_dev_username.as_deref().unwrap_or("dev")
545        );
546        let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
547        return Ok(Response::builder()
548            .status(StatusCode::FOUND)
549            .header(header::LOCATION, "/")
550            .header(header::SET_COOKIE, cookie.to_string())
551            .body(Body::empty())
552            .unwrap());
553    }
554
555    // Production — redirect to IdP with PKCE
556    let pkce_session = auth.pkce_manager.create();
557    let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
558
559    let auth_url = auth
560        .oidc_client
561        .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
562        .await
563        .map_err(|e| AuthError::OidcError(e.to_string()))?;
564
565    // Set PKCE state cookie (HttpOnly, SameSite=Lax)
566    // Secure flag follows the redirect_uri scheme — HTTP localhost/LAN must not
567    // set Secure or the browser drops the cookie and PKCE state is lost.
568    let secure = auth.client_config.redirect_uri.starts_with("https");
569    let pkce_cookie = Cookie::build((
570        auth.pkce_manager.cookie_name().to_string(),
571        pkce_session.cookie_value,
572    ))
573        .path("/")
574        .http_only(true)
575        .same_site(SameSite::Lax)
576        .secure(secure)
577        .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
578        .build();
579
580    Ok(Response::builder()
581        .status(StatusCode::TEMPORARY_REDIRECT)
582        .header(header::LOCATION, &auth_url)
583        .header(header::SET_COOKIE, pkce_cookie.to_string())
584        .body(Body::empty())
585        .unwrap())
586}
587
588/// GET /auth/callback — exchange authorization code for tokens, set cookie.
589async fn callback_handler(
590    State(state): State<crate::ServerState>,
591    Query(query): Query<CallbackQuery>,
592    headers: axum::http::HeaderMap,
593) -> Result<Response, AuthError> {
594    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
595
596    // Check for errors from IdP
597    if let Some(error) = query.error {
598        let desc = query.error_description.unwrap_or_default();
599        tracing::error!("OIDC error: {} - {}", error, desc);
600        return Ok(Redirect::temporary(&format!(
601            "/?error={}&error_description={}",
602            urlencoding::encode(&error),
603            urlencoding::encode(&desc)
604        ))
605        .into_response());
606    }
607
608    let code = query.code.ok_or(AuthError::MissingCode)?;
609    let oauth_state = query.state.ok_or(AuthError::MissingState)?;
610
611    // Retrieve PKCE cookie
612    let cookie_header = headers
613        .get(header::COOKIE)
614        .and_then(|v| v.to_str().ok())
615        .unwrap_or("");
616    let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
617        .ok_or(AuthError::InvalidState)?;
618
619    // Verify PKCE cookie (HMAC + expiry + state match)
620    let verifier = auth
621        .pkce_manager
622        .verify(&pkce_value, &oauth_state)
623        .ok_or(AuthError::InvalidState)?;
624
625    // Exchange code for tokens
626    tracing::info!("Exchanging authorization code for tokens");
627    let token_response = auth
628        .oidc_client
629        .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
630        .await
631        .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
632
633    let session_id = auth
634        .session_manager
635        .create_session(&token_response)
636        .await
637        .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
638
639    // Cookie lifetime matches server-side idle timeout (1 hour).
640    // The cookie is rolled on every successful request via check_auth().
641    let max_age = SESSION_COOKIE_MAX_AGE;
642
643    // Set auth cookie — Secure only when redirect_uri is HTTPS
644    let secure = auth.client_config.redirect_uri.starts_with("https");
645    let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
646
647    // Clear PKCE cookie (single-use)
648    let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
649        .path("/")
650        .http_only(true)
651        .same_site(SameSite::Lax)
652        .max_age(TimeDuration::seconds(-1))
653        .build();
654
655    tracing::info!("Authentication successful, redirecting to /");
656
657    Ok(Response::builder()
658        .status(StatusCode::FOUND)
659        .header(header::LOCATION, "/")
660        .header(header::SET_COOKIE, cookie.to_string())
661        .header(header::SET_COOKIE, clear_pkce.to_string())
662        .body(Body::empty())
663        .unwrap())
664}
665
666/// GET /auth/me — return current user info.
667async fn me_handler(
668    State(state): State<crate::ServerState>,
669    headers: axum::http::HeaderMap,
670) -> Response {
671    let Some(ref auth) = state.auth else {
672        // Auth not configured — always authenticated (no auth required)
673        return axum::Json(serde_json::json!({
674            "authenticated": true,
675            "auth_enabled": false
676        }))
677        .into_response();
678    };
679
680    let cookie_header = headers
681        .get(header::COOKIE)
682        .and_then(|v| v.to_str().ok())
683        .unwrap_or("");
684
685    // Also try Authorization: Bearer header
686    let bearer = headers
687        .get(header::AUTHORIZATION)
688        .and_then(|v| v.to_str().ok())
689        .and_then(|v| v.strip_prefix("Bearer "))
690        .map(String::from);
691
692    let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
693
694    let Some(cookie_value) = token else {
695        return axum::Json(serde_json::json!({
696            "authenticated": false,
697            "auth_enabled": true
698        }))
699        .into_response();
700    };
701
702    // Dev mode token (stored directly in cookie, no session manager)
703    // Only report as authenticated when dev mode is currently enabled
704    if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
705        let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
706        if parts.len() >= 4 {
707            return axum::Json(serde_json::json!({
708                "authenticated": true,
709                "auth_enabled": true,
710                "email": parts[1],
711                "name": parts[2],
712                "username": parts[3],
713                "dev_mode": true
714            }))
715            .into_response();
716        }
717    }
718
719    // Bearer header = raw JWT; Cookie value = session_id → resolve to access token
720    let access_token = if bearer.is_some() {
721        // Already have the raw token from Bearer header
722        cookie_value
723    } else {
724        // Cookie value is a session_id — resolve via WebSessionManager
725        match auth.session_manager.get_token(&cookie_value).await {
726            Ok(token) => token,
727            Err(e) => {
728                tracing::debug!("Session token resolution failed for /auth/me: {}", e);
729                return axum::Json(serde_json::json!({
730                    "authenticated": false,
731                    "auth_enabled": true
732                }))
733                .into_response();
734            }
735        }
736    };
737
738    // Real JWT — validate and return claims
739    match auth.validate_token(&access_token).await {
740        Ok(claims) => axum::Json(serde_json::json!({
741            "authenticated": true,
742            "auth_enabled": true,
743            "sub": claims.sub,
744            "email": claims.email,
745            "name": claims.name,
746            "username": claims.preferred_username,
747            "dev_mode": false
748        }))
749        .into_response(),
750        Err(e) => {
751            tracing::debug!("Token validation failed for /auth/me: {}", e);
752            axum::Json(serde_json::json!({
753                "authenticated": false,
754                "auth_enabled": true
755            }))
756            .into_response()
757        }
758    }
759}
760
761/// POST /auth/logout — destroy session and clear auth cookie.
762async fn logout_handler(
763    State(state): State<crate::ServerState>,
764    headers: axum::http::HeaderMap,
765) -> Response {
766    let cookie_name = state
767        .auth
768        .as_ref()
769        .map(|a| a.config.cookie_name.as_str())
770        .unwrap_or("trustee_token");
771
772    // Destroy the session on the server side
773    if let Some(ref auth) = state.auth {
774        if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
775            if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
776                if !session_id.starts_with("dev:") {
777                    let _ = auth.session_manager.destroy_session(&session_id);
778                }
779            }
780        }
781    }
782
783    let cookie = Cookie::build((cookie_name.to_string(), ""))
784        .path("/")
785        .http_only(true)
786        .same_site(SameSite::Lax)
787        .max_age(TimeDuration::seconds(-1))
788        .build();
789
790    Response::builder()
791        .status(StatusCode::FOUND)
792        .header(header::LOCATION, "/")
793        .header(header::SET_COOKIE, cookie.to_string())
794        .body(Body::empty())
795        .unwrap()
796}
797
798// ---------------------------------------------------------------------------
799// MCP auth routes: /auth/mcp/login, /callback, /status, /logout (C2)
800// ---------------------------------------------------------------------------
801
802/// Query parameters for MCP login initiation.
803#[derive(Debug, Deserialize)]
804pub struct McpLoginQuery {
805    pub cred: String,
806}
807
808/// Query parameters for MCP OIDC callback.
809#[derive(Debug, Deserialize)]
810pub struct McpCallbackQuery {
811    pub code: Option<String>,
812    pub state: Option<String>,
813    pub error: Option<String>,
814    pub error_description: Option<String>,
815}
816
817/// GET /auth/mcp/login?cred=<name> — initiate per-server OIDC PKCE login.
818///
819/// Reads the credential config from the session's config_toml, verifies it's
820/// `type = "web-interactive"`, then redirects to the OIDC provider.
821async fn mcp_login_handler(
822    State(state): State<crate::ServerState>,
823    Query(query): Query<McpLoginQuery>,
824    headers: axum::http::HeaderMap,
825) -> Result<Response, AuthError> {
826    // Require authentication — user must be logged into trustee-web
827    crate::auth::check_auth(&state.auth, &headers)
828        .await
829        .map_err(|_| AuthError::AuthNotConfigured)?;
830
831    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
832
833    // Parse MCP credential config from session's config_toml
834    let cred_config = load_mcp_credential(&state, &query.cred).await?;
835
836    let (issuer_url, client_id, client_secret, scope) = match &cred_config {
837        McpCredentialInfo::WebInteractive {
838            issuer_url,
839            client_id,
840            client_secret,
841            scope,
842        } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
843        _ => {
844            return Ok(Redirect::temporary(&format!(
845                "/?mcp_error={}",
846                urlencoding::encode(&format!("Credential '{}' is not web-interactive type", query.cred))
847            ))
848            .into_response());
849        }
850    };
851
852    // Build PKCE pair using a separate PkceCookieManager for MCP
853    let oidc_client = OidcClient::new();
854    let verifier = OidcClient::generate_code_verifier();
855    let challenge = OidcClient::generate_code_challenge(&verifier);
856    let oauth_state = OidcClient::generate_state();
857
858    // Build OidcClientConfig for the MCP credential's OIDC client
859    let mcp_redirect_uri = format!(
860        "{}/auth/mcp/callback",
861        auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
862    );
863
864    let mcp_client_config = OidcClientConfig {
865        issuer_url: issuer_url.clone(),
866        client_id: client_id.clone(),
867        client_secret: client_secret.clone(),
868        redirect_uri: mcp_redirect_uri.clone(),
869        scope: scope.clone(),
870        code_challenge_method: "S256".to_string(),
871    };
872
873    // Build authorization URL
874    let auth_url = oidc_client
875        .build_authorization_url(&mcp_client_config, &oauth_state, Some(&challenge))
876        .await
877        .map_err(|e| AuthError::OidcError(e.to_string()))?;
878
879    // Store PKCE state + credential name in the in-memory map
880    mcp_pkce().insert(oauth_state.clone(), verifier.clone(), query.cred.clone()).await;
881
882    tracing::info!(
883        "Initiating MCP browser login for credential '{}' (issuer={})",
884        query.cred, issuer_url
885    );
886
887    Ok(Response::builder()
888        .status(StatusCode::TEMPORARY_REDIRECT)
889        .header(header::LOCATION, &auth_url)
890        .body(Body::empty())
891        .unwrap())
892}
893
894/// GET /auth/mcp/callback — handle MCP OIDC callback, store tokens.
895async fn mcp_callback_handler(
896    State(state): State<crate::ServerState>,
897    Query(query): Query<McpCallbackQuery>,
898    headers: axum::http::HeaderMap,
899) -> Result<Response, AuthError> {
900    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
901
902    // Check for errors from IdP
903    if let Some(error) = query.error {
904        let desc = query.error_description.unwrap_or_default();
905        tracing::error!("MCP OIDC error: {} - {}", error, desc);
906        return Ok(Redirect::temporary(&format!(
907            "/?mcp_error={}&error_description={}",
908            urlencoding::encode(&error),
909            urlencoding::encode(&desc)
910        ))
911        .into_response());
912    }
913
914    let code = query.code.ok_or(AuthError::MissingCode)?;
915    let oauth_state = query.state.ok_or(AuthError::MissingState)?;
916
917    // Look up PKCE verifier + credential name from in-memory store
918    let pkce_data = mcp_pkce().take(&oauth_state).await
919        .ok_or(AuthError::InvalidState)?;
920
921    let verifier = pkce_data.verifier;
922    let cred_name = &pkce_data.cred_name;
923
924    // Parse the MCP credential config to get OIDC settings for token exchange
925    let cred_config = load_mcp_credential(&state, cred_name).await?;
926
927    let (issuer_url, client_id, client_secret, scope) = match &cred_config {
928        McpCredentialInfo::WebInteractive {
929            issuer_url,
930            client_id,
931            client_secret,
932            scope,
933        } => (issuer_url.clone(), client_id.clone(), client_secret.clone(), scope.clone()),
934        _ => {
935            return Ok(Redirect::temporary(&format!(
936                "/?mcp_error={}",
937                urlencoding::encode("Credential is not web-interactive type")
938            ))
939            .into_response());
940        }
941    };
942
943    // Build redirect URI (must match what was used in login)
944    let mcp_redirect_uri = format!(
945        "{}/auth/mcp/callback",
946        auth.client_config.redirect_uri.trim_end_matches('/').trim_end_matches("/auth/callback")
947    );
948
949    let mcp_client_config = OidcClientConfig {
950        issuer_url: issuer_url.clone(),
951        client_id: client_id.clone(),
952        client_secret: client_secret.clone(),
953        redirect_uri: mcp_redirect_uri,
954        scope: scope.clone(),
955        code_challenge_method: "S256".to_string(),
956    };
957
958    // Exchange code for tokens
959    tracing::info!("Exchanging MCP authorization code for tokens (credential={})", cred_name);
960    let oidc_client = OidcClient::new();
961    let token_response = oidc_client
962        .exchange_code_for_tokens(&mcp_client_config, &code, Some(&verifier))
963        .await
964        .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
965
966    // Compute expires_at
967    let expires_at = {
968        let now = std::time::SystemTime::now()
969            .duration_since(std::time::UNIX_EPOCH)
970            .unwrap_or_default()
971            .as_secs();
972        let expires_epoch = now + token_response.expires_in.unwrap_or(900);
973        let days = expires_epoch / 86400;
974        let rem = expires_epoch % 86400;
975        let h = rem / 3600;
976        let m = (rem % 3600) / 60;
977        let s = rem % 60;
978        let z = days as i64 + 719468;
979        let era = if z >= 0 { z } else { z - 146096 } / 146097;
980        let doe = (z - era * 146097) as u64;
981        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
982        let y = yoe as i64 + era * 400;
983        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
984        let mp = (5 * doy + 2) / 153;
985        let d = doy - (153 * mp + 2) / 5 + 1;
986        let mon = if mp < 10 { mp + 3 } else { mp - 9 };
987        let yr = if mon <= 2 { y + 1 } else { y };
988        format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", yr, mon, d, h, m, s)
989    };
990
991    // Store via FileTokenStore (same as `trustee mcp auth`)
992    use pep::{FileTokenStore, StoredToken, TokenStore};
993
994    let stored = StoredToken::new(
995        &token_response.access_token,
996        token_response.refresh_token.clone(),
997        "Bearer",
998        &expires_at,
999        token_response.scope.clone(),
1000    );
1001
1002    let agent_name = {
1003        let user_key = state.resolve_user_key(&headers).await;
1004        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1005        let session = session_arc.lock().await;
1006        session.agent_name.clone()
1007    };
1008    let token_store = FileTokenStore::new(&agent_name);
1009
1010    if let Err(e) = token_store.save(cred_name, &stored) {
1011        tracing::error!("Failed to store MCP token: {}", e);
1012        return Ok(Redirect::temporary(&format!(
1013            "/?mcp_error={}",
1014            urlencoding::encode(&format!("Failed to store token: {}", e))
1015        ))
1016        .into_response());
1017    }
1018
1019    tracing::info!(
1020        "MCP authentication successful for credential '{}' (expires {})",
1021        cred_name, expires_at
1022    );
1023
1024    Ok(Response::builder()
1025        .status(StatusCode::FOUND)
1026        .header(header::LOCATION, format!("/?mcp_connected={}", urlencoding::encode(cred_name)))
1027        .body(Body::empty())
1028        .unwrap())
1029}
1030
1031/// GET /auth/mcp/status — return connection status for all MCP credentials.
1032async fn mcp_status_handler(
1033    State(state): State<crate::ServerState>,
1034    headers: axum::http::HeaderMap,
1035) -> Response {
1036    use pep::{FileTokenStore, TokenStore};
1037
1038    // Require auth
1039    if let Err(code) = crate::auth::check_auth(&state.auth, &headers).await {
1040        return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response();
1041    }
1042
1043    // Parse MCP config from session
1044    let config_toml = {
1045        let user_key = state.resolve_user_key(&headers).await;
1046        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1047        let session = session_arc.lock().await;
1048        match &session.config_toml {
1049            Some(t) => t.clone(),
1050            None => return (StatusCode::INTERNAL_SERVER_ERROR, "Config not loaded").into_response(),
1051        }
1052    };
1053
1054    let mcp_config: toml::Value = match toml::from_str(&config_toml) {
1055        Ok(v) => v,
1056        Err(_) => return Json(serde_json::json!([])).into_response(),
1057    };
1058
1059    let agent_name = {
1060        let user_key = state.resolve_user_key(&headers).await;
1061        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1062        let session = session_arc.lock().await;
1063        session.agent_name.clone()
1064    };
1065    let token_store = FileTokenStore::new(&agent_name);
1066
1067    // Build server → credential mapping
1068    let servers = mcp_config
1069        .get("mcp")
1070        .and_then(|m| m.get("servers"))
1071        .and_then(|s| s.as_array());
1072    let credentials = mcp_config
1073        .get("mcp")
1074        .and_then(|m| m.get("credentials"))
1075        .and_then(|c| c.as_table());
1076
1077    let mut cred_servers: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
1078    if let Some(servers) = servers {
1079        for server in servers {
1080            let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
1081            let cred_ref = server.get("credentials").and_then(|c| c.as_str()).unwrap_or("");
1082            if !cred_ref.is_empty() {
1083                cred_servers
1084                    .entry(cred_ref.to_string())
1085                    .or_default()
1086                    .push(name.to_string());
1087            }
1088        }
1089    }
1090
1091    let mut result = Vec::new();
1092
1093    if let Some(creds) = credentials {
1094        for (cred_name, cred_config) in creds {
1095            let cred_type = cred_config.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1096            let servers_using = cred_servers.get(cred_name).cloned().unwrap_or_default();
1097
1098            if cred_type == "web-session" {
1099                // Session credentials are always "connected" if auth is enabled
1100                let connected = state.auth.is_some();
1101                result.push(serde_json::json!({
1102                    "credential": cred_name,
1103                    "type": cred_type,
1104                    "connected": connected,
1105                    "servers": servers_using,
1106                }));
1107            } else if cred_type == "web-interactive" || cred_type == "interactive" {
1108                // Check token store
1109                let status = match token_store.load(cred_name) {
1110                    Ok(Some(token)) => {
1111                        let expired = token.is_expired();
1112                        serde_json::json!({
1113                            "credential": cred_name,
1114                            "type": cred_type,
1115                            "connected": !expired,
1116                            "expires_at": token.expires_at,
1117                            "servers": servers_using,
1118                        })
1119                    }
1120                    _ => serde_json::json!({
1121                        "credential": cred_name,
1122                        "type": cred_type,
1123                        "connected": false,
1124                        "servers": servers_using,
1125                    }),
1126                };
1127                result.push(status);
1128            }
1129        }
1130    }
1131
1132    Json(serde_json::Value::Array(result)).into_response()
1133}
1134
1135/// POST /auth/mcp/logout?cred=<name> — remove stored MCP tokens.
1136async fn mcp_logout_handler(
1137    State(state): State<crate::ServerState>,
1138    Query(query): Query<McpLoginQuery>,
1139    headers: axum::http::HeaderMap,
1140) -> Response {
1141    use pep::{FileTokenStore, TokenStore};
1142
1143    // Require auth
1144    if let Err(code) = crate::auth::check_auth(&state.auth, &headers).await {
1145        return (code, Json(serde_json::json!({"error": "Unauthorized"}))).into_response();
1146    }
1147
1148    let agent_name = {
1149        let user_key = state.resolve_user_key(&headers).await;
1150        let (_sid, session_arc, _, _) = state.ensure_active_session(&user_key).await;
1151        let session = session_arc.lock().await;
1152        session.agent_name.clone()
1153    };
1154    let token_store = FileTokenStore::new(&agent_name);
1155
1156    match token_store.delete(&query.cred) {
1157        Ok(()) => {
1158            tracing::info!("Removed MCP credentials for '{}'", query.cred);
1159            Json(serde_json::json!({"success": true})).into_response()
1160        }
1161        Err(e) => {
1162            tracing::error!("Failed to remove MCP credentials: {}", e);
1163            (
1164                StatusCode::INTERNAL_SERVER_ERROR,
1165                Json(serde_json::json!({"error": e.to_string()})),
1166            )
1167                .into_response()
1168        }
1169    }
1170}
1171
1172// ---------------------------------------------------------------------------
1173// MCP auth helpers
1174// ---------------------------------------------------------------------------
1175
1176/// In-memory store for MCP PKCE state (state token → verifier + credential name).
1177/// Entries expire after 10 minutes. Not persisted across restarts.
1178struct McpPkceStore {
1179    entries: tokio::sync::Mutex<std::collections::HashMap<String, McpPkceEntry>>,
1180}
1181
1182struct McpPkceEntry {
1183    verifier: String,
1184    cred_name: String,
1185    created_at: std::time::Instant,
1186}
1187
1188impl McpPkceStore {
1189    fn new() -> Self {
1190        Self {
1191            entries: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1192        }
1193    }
1194
1195    /// Insert a PKCE entry. Cleans up entries older than 10 minutes.
1196    async fn insert(&self, state: String, verifier: String, cred_name: String) {
1197        let mut map = self.entries.lock().await;
1198        // Cleanup expired entries (older than 10 min)
1199        let cutoff = std::time::Instant::now() - std::time::Duration::from_secs(600);
1200        map.retain(|_, v| v.created_at > cutoff);
1201        map.insert(state, McpPkceEntry {
1202            verifier,
1203            cred_name,
1204            created_at: std::time::Instant::now(),
1205        });
1206    }
1207
1208    /// Take and remove a PKCE entry (single-use).
1209    async fn take(&self, state: &str) -> Option<McpPkceEntry> {
1210        let mut map = self.entries.lock().await;
1211        map.remove(state)
1212    }
1213}
1214
1215/// Global singleton PKCE store for MCP browser logins.
1216static MCP_PKCE: std::sync::OnceLock<McpPkceStore> = std::sync::OnceLock::new();
1217
1218/// Get or initialize the global MCP PKCE store.
1219fn mcp_pkce() -> &'static McpPkceStore {
1220    MCP_PKCE.get_or_init(McpPkceStore::new)
1221}
1222
1223/// Simplified MCP credential info (parsed from TOML).
1224enum McpCredentialInfo {
1225    WebInteractive {
1226        issuer_url: String,
1227        client_id: String,
1228        client_secret: Option<String>,
1229        scope: String,
1230    },
1231    Other(String),
1232}
1233
1234/// Load a specific MCP credential from the session's config_toml.
1235async fn load_mcp_credential(
1236    state: &crate::ServerState,
1237    cred_name: &str,
1238) -> Result<McpCredentialInfo, AuthError> {
1239    let config_toml = state
1240        .config_toml
1241        .clone()
1242        .ok_or(AuthError::AuthNotConfigured)?;
1243
1244    let config: toml::Value = toml::from_str(&config_toml)
1245        .map_err(|e| AuthError::OidcError(format!("Config parse error: {}", e)))?;
1246
1247    let cred = config
1248        .get("mcp")
1249        .and_then(|m| m.get("credentials"))
1250        .and_then(|c| c.as_table())
1251        .and_then(|c| c.get(cred_name))
1252        .ok_or_else(|| AuthError::OidcError(format!("Credential '{}' not found", cred_name)))?;
1253
1254    let cred_type = cred.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
1255
1256    match cred_type {
1257        "web-interactive" => {
1258            let issuer_url = cred
1259                .get("issuer_url")
1260                .and_then(|v| v.as_str())
1261                .ok_or_else(|| AuthError::OidcError("Missing issuer_url".into()))?
1262                .to_string();
1263            let client_id = cred
1264                .get("client_id")
1265                .and_then(|v| v.as_str())
1266                .ok_or_else(|| AuthError::OidcError("Missing client_id".into()))?
1267                .to_string();
1268            let client_secret = cred
1269                .get("client_secret")
1270                .and_then(|v| v.as_str())
1271                .map(String::from);
1272            let scope = cred
1273                .get("scope")
1274                .and_then(|v| v.as_str())
1275                .unwrap_or("openid profile email")
1276                .to_string();
1277
1278            Ok(McpCredentialInfo::WebInteractive {
1279                issuer_url,
1280                client_id,
1281                client_secret,
1282                scope,
1283            })
1284        }
1285        other => Ok(McpCredentialInfo::Other(other.to_string())),
1286    }
1287}
1288
1289// ---------------------------------------------------------------------------
1290// Helpers
1291// ---------------------------------------------------------------------------
1292
1293/// Create an HttpOnly auth cookie.
1294fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
1295    Cookie::build((name.to_string(), value.to_string()))
1296        .path("/")
1297        .http_only(true)
1298        .same_site(SameSite::Lax)
1299        .secure(secure)
1300        .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
1301        .build()
1302}
1303
1304// ---------------------------------------------------------------------------
1305// Error handling
1306// ---------------------------------------------------------------------------
1307
1308/// Authentication errors.
1309#[derive(Debug)]
1310pub enum AuthError {
1311    MissingCode,
1312    MissingState,
1313    InvalidState,
1314    OidcError(String),
1315    TokenExchangeFailed(String),
1316    AuthNotConfigured,
1317}
1318
1319impl IntoResponse for AuthError {
1320    fn into_response(self) -> Response {
1321        let (_status, msg) = match self {
1322            AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
1323            AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
1324            AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
1325            AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
1326            AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
1327            AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
1328        };
1329        Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
1330    }
1331}