1use 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#[derive(Debug, Clone)]
39pub struct AuthConfig {
40 pub issuer_url: String,
42 pub client_id: String,
44 pub client_secret: Option<String>,
46 pub redirect_uri: String,
48 pub scope: String,
50 pub cookie_name: String,
52 pub dev_config: DevConfig,
54 pub validation_options: JwtValidationOptions,
56 pub pkce_cookie_secret: String,
58}
59
60impl AuthConfig {
61 pub fn from_toml(config_toml: &str) -> Option<Self> {
66 let table: toml::Table = toml::from_str(config_toml).ok()?;
67
68 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 if let Some(ref dc) = dev_config {
80 if dc.local_dev_mode {
81 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 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 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 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#[derive(Clone)]
171pub struct AuthState {
172 pub oidc_client: OidcClient,
174 pub resource_server: ResourceServerClient,
176 pub client_config: OidcClientConfig,
178 pub config: AuthConfig,
180 pub pkce_manager: PkceCookieManager,
182 pub session_manager: Arc<WebSessionManager>,
184}
185
186impl AuthState {
187 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 pub fn is_dev_mode(&self) -> bool {
215 self.config.dev_config.local_dev_mode
216 }
217
218 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 let _ = self
233 .resource_server
234 .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
235 .await;
236
237 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 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
249 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#[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
322const SESSION_COOKIE_MAX_AGE: StdDuration = StdDuration::from_secs(3600);
324
325pub 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); };
346
347 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 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 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 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 match auth.session_manager.get_token(&session_id).await {
404 Ok(access_token) => match auth.validate_token(&access_token).await {
405 Ok(_) => {
406 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 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
451async fn resolve_access_token(
457 auth: &AuthState,
458 headers: &axum::http::HeaderMap,
459) -> Result<String, StatusCode> {
460 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 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
493fn 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
504pub 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#[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
526async 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 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 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 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
584async 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 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 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 let verifier = auth
617 .pkce_manager
618 .verify(&pkce_value, &oauth_state)
619 .ok_or(AuthError::InvalidState)?;
620
621 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 let max_age = SESSION_COOKIE_MAX_AGE;
638
639 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 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
662async 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 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 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 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 let access_token = if bearer.is_some() {
717 cookie_value
719 } else {
720 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 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
757async 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 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
794fn 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#[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}