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::{DevConfig, JwtClaims, JwtValidationOptions, OidcClientConfig};
29use serde::Deserialize;
30use time::Duration as TimeDuration;
31
32// ---------------------------------------------------------------------------
33// Configuration
34// ---------------------------------------------------------------------------
35
36/// Authentication configuration parsed from `[oidc]` and `[dev]` TOML sections.
37#[derive(Debug, Clone)]
38pub struct AuthConfig {
39    /// OIDC provider issuer URL
40    pub issuer_url: String,
41    /// OAuth2 client ID
42    pub client_id: String,
43    /// OAuth2 client secret (None → public client, PKCE only)
44    pub client_secret: Option<String>,
45    /// Redirect URI for OIDC callback
46    pub redirect_uri: String,
47    /// OAuth2 scopes
48    pub scope: String,
49    /// Token cookie name
50    pub cookie_name: String,
51    /// Development mode configuration
52    pub dev_config: DevConfig,
53    /// JWT validation options
54    pub validation_options: JwtValidationOptions,
55    /// Secret for signing PKCE state cookies
56    pub pkce_cookie_secret: String,
57}
58
59impl AuthConfig {
60    /// Parse auth config from the merged trustee TOML string.
61    ///
62    /// Reads `[oidc]` and `[dev]` sections. If neither is present, returns None
63    /// (auth disabled — all endpoints open).
64    pub fn from_toml(config_toml: &str) -> Option<Self> {
65        let table: toml::Table = toml::from_str(config_toml).ok()?;
66
67        // Check for dev mode
68        let dev_config = table.get("dev").and_then(|d| d.as_table()).map(|d| {
69            DevConfig {
70                local_dev_mode: d.get("local_dev_mode").and_then(|v| v.as_bool()).unwrap_or(false),
71                local_dev_email: d.get("local_dev_email").and_then(|v| v.as_str()).map(String::from),
72                local_dev_name: d.get("local_dev_name").and_then(|v| v.as_str()).map(String::from),
73                local_dev_username: d.get("local_dev_username").and_then(|v| v.as_str()).map(String::from),
74            }
75        });
76
77        // Dev mode without OIDC — return early with dev-only config
78        if let Some(ref dc) = dev_config {
79            if dc.local_dev_mode {
80                // Try to get OIDC config too (for login endpoint), but it's optional in dev mode
81                let oidc = Self::parse_oidc_section(&table);
82                return Some(Self {
83                    issuer_url: oidc.as_ref().map(|o| o.0.clone()).unwrap_or_else(|| "https://auth.example.com".into()),
84                    client_id: oidc.as_ref().map(|o| o.1.clone()).unwrap_or_else(|| "trustee".into()),
85                    client_secret: oidc.as_ref().and_then(|o| o.2.clone()),
86                    redirect_uri: oidc.as_ref().map(|o| o.3.clone()).unwrap_or_else(|| "http://localhost:3000/auth/callback".into()),
87                    scope: oidc.as_ref().map(|o| o.4.clone()).unwrap_or_else(|| "openid profile email".into()),
88                    cookie_name: "trustee_token".into(),
89                    dev_config: dc.clone(),
90                    validation_options: JwtValidationOptions::default(),
91                    pkce_cookie_secret: oidc.as_ref().map(|o| o.6.clone()).unwrap_or_else(|| "trustee-default-pkce-secret-change-me".into()),
92                });
93            }
94        }
95
96        // Production mode — requires [oidc] section
97        let (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret) =
98            Self::parse_oidc_section(&table)?;
99
100        Some(Self {
101            issuer_url,
102            client_id,
103            client_secret,
104            redirect_uri,
105            scope,
106            cookie_name: "trustee_token".into(),
107            dev_config: dev_config.unwrap_or_default(),
108            validation_options,
109            pkce_cookie_secret: pkce_secret,
110        })
111    }
112
113    /// Parse the `[oidc]` section from a TOML table.
114    /// Returns (issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret).
115    fn parse_oidc_section(
116        table: &toml::Table,
117    ) -> Option<(String, String, Option<String>, String, String, JwtValidationOptions, String)> {
118        let oidc = table.get("oidc")?.as_table()?;
119
120        let issuer_url = oidc.get("issuer_url")?.as_str()?.to_string();
121        let client_id = oidc.get("client_id")?.as_str()?.to_string();
122        let client_secret = oidc.get("client_secret").and_then(|v| v.as_str()).map(String::from);
123        let redirect_uri = oidc
124            .get("redirect_url")
125            .and_then(|v| v.as_str())
126            .unwrap_or("http://localhost:3000/auth/callback")
127            .to_string();
128        let scope = oidc
129            .get("scope")
130            .and_then(|v| v.as_str())
131            .unwrap_or("openid profile email")
132            .to_string();
133
134        let mut validation_options = JwtValidationOptions::default();
135        if let Some(skip) = oidc.get("skip_issuer_validation").and_then(|v| v.as_bool()) {
136            validation_options.skip_issuer_validation = skip;
137        }
138        if let Some(skip) = oidc.get("skip_audience_validation").and_then(|v| v.as_bool()) {
139            validation_options.skip_audience_validation = skip;
140        }
141        validation_options.expected_audience = oidc
142            .get("expected_audience")
143            .and_then(|v| v.as_str())
144            .map(String::from);
145
146        let pkce_secret = oidc
147            .get("pkce_cookie_secret")
148            .and_then(|v| v.as_str())
149            .unwrap_or("trustee-default-pkce-secret-change-me")
150            .to_string();
151
152        Some((issuer_url, client_id, client_secret, redirect_uri, scope, validation_options, pkce_secret))
153    }
154
155    /// Build OIDC client configuration for PEP's OidcClient.
156    pub fn oidc_client_config(&self) -> OidcClientConfig {
157        OidcClientConfig {
158            issuer_url: self.issuer_url.clone(),
159            client_id: self.client_id.clone(),
160            client_secret: self.client_secret.clone(),
161            redirect_uri: self.redirect_uri.clone(),
162            scope: self.scope.clone(),
163            code_challenge_method: "S256".to_string(),
164        }
165    }
166}
167
168/// Shared authentication state, stored in ServerState.
169#[derive(Clone)]
170pub struct AuthState {
171    /// OIDC client for login flow (authorization code + PKCE)
172    pub oidc_client: OidcClient,
173    /// Resource server client for JWT validation (lazy-initialized)
174    pub resource_server: ResourceServerClient,
175    /// OIDC client configuration
176    pub client_config: OidcClientConfig,
177    /// Auth configuration
178    pub config: AuthConfig,
179    /// Stateless PKCE cookie manager
180    pub pkce_manager: PkceCookieManager,
181}
182
183impl AuthState {
184    /// Create new auth state from configuration.
185    pub fn new(config: AuthConfig) -> Self {
186        let pkce_manager = PkceCookieManager::new(
187            config.pkce_cookie_secret.as_bytes(),
188            "trustee_pkce_state",
189            StdDuration::from_secs(600),
190        );
191
192        Self {
193            oidc_client: OidcClient::new(),
194            resource_server: ResourceServerClient::new(),
195            client_config: config.oidc_client_config(),
196            pkce_manager,
197            config,
198        }
199    }
200
201    /// Check if development mode is enabled.
202    pub fn is_dev_mode(&self) -> bool {
203        self.config.dev_config.local_dev_mode
204    }
205
206    /// Validate a JWT token using PEP's ResourceServerClient.
207    pub async fn validate_token(&self, token: &str) -> anyhow::Result<JwtClaims> {
208        let mut claims = self
209            .resource_server
210            .validate_jwt_with_options(
211                token,
212                &self.config.issuer_url,
213                &self.config.client_id,
214                &self.config.validation_options,
215            )
216            .await
217            .map_err(|e| anyhow::anyhow!("Token validation failed: {}", e))?;
218
219        // Enrich with userinfo for role/groups (cached, no-op if already present)
220        let _ = self
221            .resource_server
222            .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
223            .await;
224
225        // PEP only merges groups/role from userinfo. If name/email are missing
226        // (Kanidm JWTs only contain sub), fetch them from userinfo directly.
227        if claims.name.is_none() || claims.email.is_none() {
228            self.fill_userinfo_fields(&mut claims, token).await;
229        }
230
231        Ok(claims)
232    }
233
234    /// Fetch name/email/preferred_username from the OIDC userinfo endpoint
235    /// and fill in any that are missing from the JWT claims.
236    async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
237        // Derive userinfo URL from issuer
238        // For Kanidm: issuer_url is the discovery endpoint,
239        // userinfo is at {issuer_url}/userinfo
240        let userinfo_url = format!("{}/userinfo", self.config.issuer_url.trim_end_matches('/'));
241
242        let client = reqwest::Client::new();
243        let resp = client
244            .get(&userinfo_url)
245            .header("Authorization", format!("Bearer {}", token))
246            .header("Accept", "application/json")
247            .send()
248            .await;
249
250        let Ok(resp) = resp else {
251            tracing::debug!("Userinfo request failed for name/email enrichment");
252            return;
253        };
254
255        if !resp.status().is_success() {
256            tracing::debug!("Userinfo returned {} for name/email enrichment", resp.status());
257            return;
258        }
259
260        let Ok(userinfo): Result<serde_json::Map<String, serde_json::Value>, _> = resp.json().await else {
261            return;
262        };
263
264        tracing::debug!("Userinfo keys: {:?}", userinfo.keys().collect::<Vec<_>>());
265
266        if claims.name.is_none() {
267            if let Some(name) = userinfo.get("name").and_then(|v| v.as_str()) {
268                claims.name = Some(name.to_string());
269            }
270        }
271        if claims.email.is_none() {
272            if let Some(email) = userinfo.get("email").and_then(|v| v.as_str()) {
273                claims.email = Some(email.to_string());
274            }
275        }
276        if claims.preferred_username.is_none() {
277            if let Some(uname) = userinfo.get("preferred_username").and_then(|v| v.as_str()) {
278                claims.preferred_username = Some(uname.to_string());
279            }
280        }
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Auth checking — called by protected route handlers
286// ---------------------------------------------------------------------------
287
288/// Authenticated user info extracted from the token.
289#[derive(Debug, Clone)]
290pub struct AuthUser {
291    pub sub: String,
292    pub email: Option<String>,
293    pub name: Option<String>,
294    pub username: Option<String>,
295    pub is_dev: bool,
296}
297
298impl From<JwtClaims> for AuthUser {
299    fn from(claims: JwtClaims) -> Self {
300        Self {
301            sub: claims.sub,
302            email: claims.email,
303            name: claims.name,
304            username: claims.preferred_username,
305            is_dev: false,
306        }
307    }
308}
309
310/// Check authentication for a protected endpoint.
311///
312/// Returns `Ok(())` if auth is not configured (open mode), or if a valid
313/// token is present. Returns `Err(StatusCode::UNAUTHORIZED)` if auth is
314/// configured but no valid token is found.
315///
316/// Token sources (in order):
317/// 1. `Authorization: Bearer <token>` header
318/// 2. `trustee_token=<token>` cookie
319///
320/// Dev mode tokens use the format `dev:email:name:username`.
321pub async fn check_auth(
322    auth: &Option<Arc<AuthState>>,
323    headers: &axum::http::HeaderMap,
324) -> Result<(), StatusCode> {
325    let Some(auth) = auth.as_ref() else {
326        return Ok(()); // Auth not configured — allow
327    };
328
329    // Extract token
330    let token = headers
331        .get(header::AUTHORIZATION)
332        .and_then(|v| v.to_str().ok())
333        .and_then(|v| v.strip_prefix("Bearer "))
334        .map(|s| s.to_string())
335        .or_else(|| {
336            headers
337                .get(header::COOKIE)
338                .and_then(|v| v.to_str().ok())
339                .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name))
340        });
341
342    let Some(token) = token else {
343        tracing::warn!("No auth token found in request");
344        return Err(StatusCode::UNAUTHORIZED);
345    };
346
347    // Dev mode token
348    if token.starts_with("dev:") {
349        let parts: Vec<&str> = token.splitn(4, ':').collect();
350        if parts.len() >= 4 {
351            return Ok(());
352        }
353        return Err(StatusCode::UNAUTHORIZED);
354    }
355
356    // Real JWT — validate via PEP ResourceServerClient
357    match auth.validate_token(&token).await {
358        Ok(_) => Ok(()),
359        Err(e) => {
360            tracing::warn!("Token validation failed: {}", e);
361            Err(StatusCode::UNAUTHORIZED)
362        }
363    }
364}
365
366/// Extract token value from a cookie header string.
367fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
368    for cookie in cookie_header.split(';') {
369        let cookie = cookie.trim();
370        if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
371            return Some(value.to_string());
372        }
373    }
374    None
375}
376
377// ---------------------------------------------------------------------------
378// Auth routes: /auth/login, /auth/callback, /auth/me, /auth/logout
379// ---------------------------------------------------------------------------
380
381/// Build the auth routes as a nested Router.
382pub fn auth_routes() -> axum::Router<crate::ServerState> {
383    axum::Router::new()
384        .route("/login", axum::routing::get(login_handler))
385        .route("/callback", axum::routing::get(callback_handler))
386        .route("/me", axum::routing::get(me_handler))
387        .route("/logout", axum::routing::post(logout_handler))
388}
389
390/// Query parameters for OIDC callback.
391#[derive(Debug, Deserialize)]
392pub struct CallbackQuery {
393    pub code: Option<String>,
394    pub state: Option<String>,
395    pub error: Option<String>,
396    pub error_description: Option<String>,
397}
398
399/// GET /auth/login — initiate OIDC login with PKCE, or create dev session.
400async fn login_handler(
401    State(state): State<crate::ServerState>,
402) -> Result<Response, AuthError> {
403    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
404
405    // Dev mode — create synthetic session
406    if auth.is_dev_mode() {
407        tracing::info!("Dev mode: creating dev session");
408        let dev = &auth.config.dev_config;
409        let dev_token = format!(
410            "dev:{}:{}:{}",
411            dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
412            dev.local_dev_name.as_deref().unwrap_or("Dev User"),
413            dev.local_dev_username.as_deref().unwrap_or("dev")
414        );
415        let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
416        return Ok(Response::builder()
417            .status(StatusCode::FOUND)
418            .header(header::LOCATION, "/")
419            .header(header::SET_COOKIE, cookie.to_string())
420            .body(Body::empty())
421            .unwrap());
422    }
423
424    // Production — redirect to IdP with PKCE
425    let pkce_session = auth.pkce_manager.create();
426    let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
427
428    let auth_url = auth
429        .oidc_client
430        .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
431        .await
432        .map_err(|e| AuthError::OidcError(e.to_string()))?;
433
434    // Set PKCE state cookie (HttpOnly, SameSite=Lax)
435    // Secure flag follows the redirect_uri scheme — HTTP localhost/LAN must not
436    // set Secure or the browser drops the cookie and PKCE state is lost.
437    let secure = auth.client_config.redirect_uri.starts_with("https");
438    let pkce_cookie = Cookie::build((
439        auth.pkce_manager.cookie_name().to_string(),
440        pkce_session.cookie_value,
441    ))
442        .path("/")
443        .http_only(true)
444        .same_site(SameSite::Lax)
445        .secure(secure)
446        .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
447        .build();
448
449    Ok(Response::builder()
450        .status(StatusCode::TEMPORARY_REDIRECT)
451        .header(header::LOCATION, &auth_url)
452        .header(header::SET_COOKIE, pkce_cookie.to_string())
453        .body(Body::empty())
454        .unwrap())
455}
456
457/// GET /auth/callback — exchange authorization code for tokens, set cookie.
458async fn callback_handler(
459    State(state): State<crate::ServerState>,
460    Query(query): Query<CallbackQuery>,
461    headers: axum::http::HeaderMap,
462) -> Result<Response, AuthError> {
463    let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
464
465    // Check for errors from IdP
466    if let Some(error) = query.error {
467        let desc = query.error_description.unwrap_or_default();
468        tracing::error!("OIDC error: {} - {}", error, desc);
469        return Ok(Redirect::temporary(&format!(
470            "/?error={}&error_description={}",
471            urlencoding::encode(&error),
472            urlencoding::encode(&desc)
473        ))
474        .into_response());
475    }
476
477    let code = query.code.ok_or(AuthError::MissingCode)?;
478    let oauth_state = query.state.ok_or(AuthError::MissingState)?;
479
480    // Retrieve PKCE cookie
481    let cookie_header = headers
482        .get(header::COOKIE)
483        .and_then(|v| v.to_str().ok())
484        .unwrap_or("");
485    let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
486        .ok_or(AuthError::InvalidState)?;
487
488    // Verify PKCE cookie (HMAC + expiry + state match)
489    let verifier = auth
490        .pkce_manager
491        .verify(&pkce_value, &oauth_state)
492        .ok_or(AuthError::InvalidState)?;
493
494    // Exchange code for tokens
495    tracing::info!("Exchanging authorization code for tokens");
496    let token_response = auth
497        .oidc_client
498        .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
499        .await
500        .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
501
502    let auth_token = token_response.access_token;
503    let max_age = token_response
504        .expires_in
505        .map(StdDuration::from_secs)
506        .unwrap_or(StdDuration::from_secs(3600));
507
508    // Set auth cookie — Secure only when redirect_uri is HTTPS
509    let secure = auth.client_config.redirect_uri.starts_with("https");
510    let cookie = create_auth_cookie(&auth.config.cookie_name, &auth_token, max_age, secure);
511
512    // Clear PKCE cookie (single-use)
513    let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
514        .path("/")
515        .http_only(true)
516        .same_site(SameSite::Lax)
517        .max_age(TimeDuration::seconds(-1))
518        .build();
519
520    tracing::info!("Authentication successful, redirecting to /");
521
522    Ok(Response::builder()
523        .status(StatusCode::FOUND)
524        .header(header::LOCATION, "/")
525        .header(header::SET_COOKIE, cookie.to_string())
526        .header(header::SET_COOKIE, clear_pkce.to_string())
527        .body(Body::empty())
528        .unwrap())
529}
530
531/// GET /auth/me — return current user info.
532async fn me_handler(
533    State(state): State<crate::ServerState>,
534    headers: axum::http::HeaderMap,
535) -> Response {
536    let Some(ref auth) = state.auth else {
537        // Auth not configured — always authenticated (no auth required)
538        return axum::Json(serde_json::json!({
539            "authenticated": true,
540            "auth_enabled": false
541        }))
542        .into_response();
543    };
544
545    let cookie_header = headers
546        .get(header::COOKIE)
547        .and_then(|v| v.to_str().ok())
548        .unwrap_or("");
549
550    // Also try Authorization: Bearer header
551    let bearer = headers
552        .get(header::AUTHORIZATION)
553        .and_then(|v| v.to_str().ok())
554        .and_then(|v| v.strip_prefix("Bearer "))
555        .map(String::from);
556
557    let token = bearer.or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
558
559    let Some(token) = token else {
560        return axum::Json(serde_json::json!({
561            "authenticated": false,
562            "auth_enabled": true
563        }))
564        .into_response();
565    };
566
567    // Dev mode token
568    if token.starts_with("dev:") {
569        let parts: Vec<&str> = token.splitn(4, ':').collect();
570        if parts.len() >= 4 {
571            return axum::Json(serde_json::json!({
572                "authenticated": true,
573                "auth_enabled": true,
574                "email": parts[1],
575                "name": parts[2],
576                "username": parts[3],
577                "dev_mode": true
578            }))
579            .into_response();
580        }
581    }
582
583    // Real JWT — validate and return claims
584    match auth.validate_token(&token).await {
585        Ok(claims) => axum::Json(serde_json::json!({
586            "authenticated": true,
587            "auth_enabled": true,
588            "sub": claims.sub,
589            "email": claims.email,
590            "name": claims.name,
591            "username": claims.preferred_username,
592            "dev_mode": false
593        }))
594        .into_response(),
595        Err(e) => {
596            tracing::debug!("Token validation failed for /auth/me: {}", e);
597            axum::Json(serde_json::json!({
598                "authenticated": false,
599                "auth_enabled": true
600            }))
601            .into_response()
602        }
603    }
604}
605
606/// POST /auth/logout — clear auth cookie.
607async fn logout_handler(
608    State(state): State<crate::ServerState>,
609) -> Response {
610    let cookie_name = state
611        .auth
612        .as_ref()
613        .map(|a| a.config.cookie_name.as_str())
614        .unwrap_or("trustee_token");
615
616    let cookie = Cookie::build((cookie_name.to_string(), ""))
617        .path("/")
618        .http_only(true)
619        .same_site(SameSite::Lax)
620        .max_age(TimeDuration::seconds(-1))
621        .build();
622
623    Response::builder()
624        .status(StatusCode::FOUND)
625        .header(header::LOCATION, "/")
626        .header(header::SET_COOKIE, cookie.to_string())
627        .body(Body::empty())
628        .unwrap()
629}
630
631// ---------------------------------------------------------------------------
632// Helpers
633// ---------------------------------------------------------------------------
634
635/// Create an HttpOnly auth cookie.
636fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
637    Cookie::build((name.to_string(), value.to_string()))
638        .path("/")
639        .http_only(true)
640        .same_site(SameSite::Lax)
641        .secure(secure)
642        .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
643        .build()
644}
645
646// ---------------------------------------------------------------------------
647// Error handling
648// ---------------------------------------------------------------------------
649
650/// Authentication errors.
651#[derive(Debug)]
652pub enum AuthError {
653    MissingCode,
654    MissingState,
655    InvalidState,
656    OidcError(String),
657    TokenExchangeFailed(String),
658    AuthNotConfigured,
659}
660
661impl IntoResponse for AuthError {
662    fn into_response(self) -> Response {
663        let (_status, msg) = match self {
664            AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
665            AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
666            AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
667            AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
668            AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
669            AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
670        };
671        Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
672    }
673}