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, 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}
516
517/// Query parameters for OIDC callback.
518#[derive(Debug, Deserialize)]
519pub struct CallbackQuery {
520    pub code: Option<String>,
521    pub state: Option<String>,
522    pub error: Option<String>,
523    pub error_description: Option<String>,
524}
525
526/// GET /auth/login — initiate OIDC login with PKCE, or create dev session.
527async fn login_handler(
528    State(state): State<crate::ServerState>,
529) -> Result<Response, AuthError> {
530    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
531
532    // Dev mode — create synthetic session
533    if auth.is_dev_mode() {
534        tracing::info!("Dev mode: creating dev session");
535        let dev = &auth.config.dev_config;
536        let dev_token = format!(
537            "dev:{}:{}:{}",
538            dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
539            dev.local_dev_name.as_deref().unwrap_or("Dev User"),
540            dev.local_dev_username.as_deref().unwrap_or("dev")
541        );
542        let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
543        return Ok(Response::builder()
544            .status(StatusCode::FOUND)
545            .header(header::LOCATION, "/")
546            .header(header::SET_COOKIE, cookie.to_string())
547            .body(Body::empty())
548            .unwrap());
549    }
550
551    // Production — redirect to IdP with PKCE
552    let pkce_session = auth.pkce_manager.create();
553    let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
554
555    let auth_url = auth
556        .oidc_client
557        .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
558        .await
559        .map_err(|e| AuthError::OidcError(e.to_string()))?;
560
561    // Set PKCE state cookie (HttpOnly, SameSite=Lax)
562    // Secure flag follows the redirect_uri scheme — HTTP localhost/LAN must not
563    // set Secure or the browser drops the cookie and PKCE state is lost.
564    let secure = auth.client_config.redirect_uri.starts_with("https");
565    let pkce_cookie = Cookie::build((
566        auth.pkce_manager.cookie_name().to_string(),
567        pkce_session.cookie_value,
568    ))
569        .path("/")
570        .http_only(true)
571        .same_site(SameSite::Lax)
572        .secure(secure)
573        .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
574        .build();
575
576    Ok(Response::builder()
577        .status(StatusCode::TEMPORARY_REDIRECT)
578        .header(header::LOCATION, &auth_url)
579        .header(header::SET_COOKIE, pkce_cookie.to_string())
580        .body(Body::empty())
581        .unwrap())
582}
583
584/// GET /auth/callback — exchange authorization code for tokens, set cookie.
585async fn callback_handler(
586    State(state): State<crate::ServerState>,
587    Query(query): Query<CallbackQuery>,
588    headers: axum::http::HeaderMap,
589) -> Result<Response, AuthError> {
590    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
591
592    // Check for errors from IdP
593    if let Some(error) = query.error {
594        let desc = query.error_description.unwrap_or_default();
595        tracing::error!("OIDC error: {} - {}", error, desc);
596        return Ok(Redirect::temporary(&format!(
597            "/?error={}&error_description={}",
598            urlencoding::encode(&error),
599            urlencoding::encode(&desc)
600        ))
601        .into_response());
602    }
603
604    let code = query.code.ok_or(AuthError::MissingCode)?;
605    let oauth_state = query.state.ok_or(AuthError::MissingState)?;
606
607    // Retrieve PKCE cookie
608    let cookie_header = headers
609        .get(header::COOKIE)
610        .and_then(|v| v.to_str().ok())
611        .unwrap_or("");
612    let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
613        .ok_or(AuthError::InvalidState)?;
614
615    // Verify PKCE cookie (HMAC + expiry + state match)
616    let verifier = auth
617        .pkce_manager
618        .verify(&pkce_value, &oauth_state)
619        .ok_or(AuthError::InvalidState)?;
620
621    // Exchange code for tokens
622    tracing::info!("Exchanging authorization code for tokens");
623    let token_response = auth
624        .oidc_client
625        .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
626        .await
627        .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
628
629    let session_id = auth
630        .session_manager
631        .create_session(&token_response)
632        .await
633        .map_err(|e| AuthError::TokenExchangeFailed(format!("Session creation failed: {}", e)))?;
634
635    // Cookie lifetime matches server-side idle timeout (1 hour).
636    // The cookie is rolled on every successful request via check_auth().
637    let max_age = SESSION_COOKIE_MAX_AGE;
638
639    // Set auth cookie — Secure only when redirect_uri is HTTPS
640    let secure = auth.client_config.redirect_uri.starts_with("https");
641    let cookie = create_auth_cookie(&auth.config.cookie_name, &session_id, max_age, secure);
642
643    // Clear PKCE cookie (single-use)
644    let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
645        .path("/")
646        .http_only(true)
647        .same_site(SameSite::Lax)
648        .max_age(TimeDuration::seconds(-1))
649        .build();
650
651    tracing::info!("Authentication successful, redirecting to /");
652
653    Ok(Response::builder()
654        .status(StatusCode::FOUND)
655        .header(header::LOCATION, "/")
656        .header(header::SET_COOKIE, cookie.to_string())
657        .header(header::SET_COOKIE, clear_pkce.to_string())
658        .body(Body::empty())
659        .unwrap())
660}
661
662/// GET /auth/me — return current user info.
663async fn me_handler(
664    State(state): State<crate::ServerState>,
665    headers: axum::http::HeaderMap,
666) -> Response {
667    let Some(ref auth) = state.auth else {
668        // Auth not configured — always authenticated (no auth required)
669        return axum::Json(serde_json::json!({
670            "authenticated": true,
671            "auth_enabled": false
672        }))
673        .into_response();
674    };
675
676    let cookie_header = headers
677        .get(header::COOKIE)
678        .and_then(|v| v.to_str().ok())
679        .unwrap_or("");
680
681    // Also try Authorization: Bearer header
682    let bearer = headers
683        .get(header::AUTHORIZATION)
684        .and_then(|v| v.to_str().ok())
685        .and_then(|v| v.strip_prefix("Bearer "))
686        .map(String::from);
687
688    let token = bearer.clone().or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
689
690    let Some(cookie_value) = token else {
691        return axum::Json(serde_json::json!({
692            "authenticated": false,
693            "auth_enabled": true
694        }))
695        .into_response();
696    };
697
698    // Dev mode token (stored directly in cookie, no session manager)
699    // Only report as authenticated when dev mode is currently enabled
700    if cookie_value.starts_with("dev:") && auth.config.dev_config.local_dev_mode {
701        let parts: Vec<&str> = cookie_value.splitn(4, ':').collect();
702        if parts.len() >= 4 {
703            return axum::Json(serde_json::json!({
704                "authenticated": true,
705                "auth_enabled": true,
706                "email": parts[1],
707                "name": parts[2],
708                "username": parts[3],
709                "dev_mode": true
710            }))
711            .into_response();
712        }
713    }
714
715    // Bearer header = raw JWT; Cookie value = session_id → resolve to access token
716    let access_token = if bearer.is_some() {
717        // Already have the raw token from Bearer header
718        cookie_value
719    } else {
720        // Cookie value is a session_id — resolve via WebSessionManager
721        match auth.session_manager.get_token(&cookie_value).await {
722            Ok(token) => token,
723            Err(e) => {
724                tracing::debug!("Session token resolution failed for /auth/me: {}", e);
725                return axum::Json(serde_json::json!({
726                    "authenticated": false,
727                    "auth_enabled": true
728                }))
729                .into_response();
730            }
731        }
732    };
733
734    // Real JWT — validate and return claims
735    match auth.validate_token(&access_token).await {
736        Ok(claims) => axum::Json(serde_json::json!({
737            "authenticated": true,
738            "auth_enabled": true,
739            "sub": claims.sub,
740            "email": claims.email,
741            "name": claims.name,
742            "username": claims.preferred_username,
743            "dev_mode": false
744        }))
745        .into_response(),
746        Err(e) => {
747            tracing::debug!("Token validation failed for /auth/me: {}", e);
748            axum::Json(serde_json::json!({
749                "authenticated": false,
750                "auth_enabled": true
751            }))
752            .into_response()
753        }
754    }
755}
756
757/// POST /auth/logout — destroy session and clear auth cookie.
758async fn logout_handler(
759    State(state): State<crate::ServerState>,
760    headers: axum::http::HeaderMap,
761) -> Response {
762    let cookie_name = state
763        .auth
764        .as_ref()
765        .map(|a| a.config.cookie_name.as_str())
766        .unwrap_or("trustee_token");
767
768    // Destroy the session on the server side
769    if let Some(ref auth) = state.auth {
770        if let Some(cookie_header) = headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
771            if let Some(session_id) = extract_token_from_cookies(cookie_header, cookie_name) {
772                if !session_id.starts_with("dev:") {
773                    let _ = auth.session_manager.destroy_session(&session_id);
774                }
775            }
776        }
777    }
778
779    let cookie = Cookie::build((cookie_name.to_string(), ""))
780        .path("/")
781        .http_only(true)
782        .same_site(SameSite::Lax)
783        .max_age(TimeDuration::seconds(-1))
784        .build();
785
786    Response::builder()
787        .status(StatusCode::FOUND)
788        .header(header::LOCATION, "/")
789        .header(header::SET_COOKIE, cookie.to_string())
790        .body(Body::empty())
791        .unwrap()
792}
793
794// ---------------------------------------------------------------------------
795// Helpers
796// ---------------------------------------------------------------------------
797
798/// Create an HttpOnly auth cookie.
799fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
800    Cookie::build((name.to_string(), value.to_string()))
801        .path("/")
802        .http_only(true)
803        .same_site(SameSite::Lax)
804        .secure(secure)
805        .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
806        .build()
807}
808
809// ---------------------------------------------------------------------------
810// Error handling
811// ---------------------------------------------------------------------------
812
813/// Authentication errors.
814#[derive(Debug)]
815pub enum AuthError {
816    MissingCode,
817    MissingState,
818    InvalidState,
819    OidcError(String),
820    TokenExchangeFailed(String),
821    AuthNotConfigured,
822}
823
824impl IntoResponse for AuthError {
825    fn into_response(self) -> Response {
826        let (_status, msg) = match self {
827            AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
828            AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
829            AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
830            AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
831            AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
832            AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
833        };
834        Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
835    }
836}