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::{DevConfig, JwtClaims, JwtValidationOptions, OidcClientConfig};
29use serde::Deserialize;
30use time::Duration as TimeDuration;
31
32#[derive(Debug, Clone)]
38pub struct AuthConfig {
39 pub issuer_url: String,
41 pub client_id: String,
43 pub client_secret: Option<String>,
45 pub redirect_uri: String,
47 pub scope: String,
49 pub cookie_name: String,
51 pub dev_config: DevConfig,
53 pub validation_options: JwtValidationOptions,
55 pub pkce_cookie_secret: String,
57}
58
59impl AuthConfig {
60 pub fn from_toml(config_toml: &str) -> Option<Self> {
65 let table: toml::Table = toml::from_str(config_toml).ok()?;
66
67 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 if let Some(ref dc) = dev_config {
79 if dc.local_dev_mode {
80 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 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 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 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#[derive(Clone)]
170pub struct AuthState {
171 pub oidc_client: OidcClient,
173 pub resource_server: ResourceServerClient,
175 pub client_config: OidcClientConfig,
177 pub config: AuthConfig,
179 pub pkce_manager: PkceCookieManager,
181}
182
183impl AuthState {
184 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 pub fn is_dev_mode(&self) -> bool {
203 self.config.dev_config.local_dev_mode
204 }
205
206 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 let _ = self
221 .resource_server
222 .enrich_claims_with_userinfo(&mut claims, token, &self.config.issuer_url, None)
223 .await;
224
225 Ok(claims)
226 }
227}
228
229#[derive(Debug, Clone)]
235pub struct AuthUser {
236 pub sub: String,
237 pub email: Option<String>,
238 pub name: Option<String>,
239 pub username: Option<String>,
240 pub is_dev: bool,
241}
242
243impl From<JwtClaims> for AuthUser {
244 fn from(claims: JwtClaims) -> Self {
245 Self {
246 sub: claims.sub,
247 email: claims.email,
248 name: claims.name,
249 username: claims.preferred_username,
250 is_dev: false,
251 }
252 }
253}
254
255pub async fn check_auth(
267 auth: &Option<Arc<AuthState>>,
268 headers: &axum::http::HeaderMap,
269) -> Result<(), StatusCode> {
270 let Some(auth) = auth.as_ref() else {
271 return Ok(()); };
273
274 let token = headers
276 .get(header::AUTHORIZATION)
277 .and_then(|v| v.to_str().ok())
278 .and_then(|v| v.strip_prefix("Bearer "))
279 .map(|s| s.to_string())
280 .or_else(|| {
281 headers
282 .get(header::COOKIE)
283 .and_then(|v| v.to_str().ok())
284 .and_then(|cookies| extract_token_from_cookies(cookies, &auth.config.cookie_name))
285 });
286
287 let Some(token) = token else {
288 tracing::warn!("No auth token found in request");
289 return Err(StatusCode::UNAUTHORIZED);
290 };
291
292 if token.starts_with("dev:") {
294 let parts: Vec<&str> = token.splitn(4, ':').collect();
295 if parts.len() >= 4 {
296 return Ok(());
297 }
298 return Err(StatusCode::UNAUTHORIZED);
299 }
300
301 match auth.validate_token(&token).await {
303 Ok(_) => Ok(()),
304 Err(e) => {
305 tracing::warn!("Token validation failed: {}", e);
306 Err(StatusCode::UNAUTHORIZED)
307 }
308 }
309}
310
311fn extract_token_from_cookies(cookie_header: &str, cookie_name: &str) -> Option<String> {
313 for cookie in cookie_header.split(';') {
314 let cookie = cookie.trim();
315 if let Some(value) = cookie.strip_prefix(&format!("{}=", cookie_name)) {
316 return Some(value.to_string());
317 }
318 }
319 None
320}
321
322pub fn auth_routes() -> axum::Router<crate::ServerState> {
328 axum::Router::new()
329 .route("/login", axum::routing::get(login_handler))
330 .route("/callback", axum::routing::get(callback_handler))
331 .route("/me", axum::routing::get(me_handler))
332 .route("/logout", axum::routing::post(logout_handler))
333}
334
335#[derive(Debug, Deserialize)]
337pub struct CallbackQuery {
338 pub code: Option<String>,
339 pub state: Option<String>,
340 pub error: Option<String>,
341 pub error_description: Option<String>,
342}
343
344async fn login_handler(
346 State(state): State<crate::ServerState>,
347) -> Result<Response, AuthError> {
348 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
349
350 if auth.is_dev_mode() {
352 tracing::info!("Dev mode: creating dev session");
353 let dev = &auth.config.dev_config;
354 let dev_token = format!(
355 "dev:{}:{}:{}",
356 dev.local_dev_email.as_deref().unwrap_or("dev@localhost"),
357 dev.local_dev_name.as_deref().unwrap_or("Dev User"),
358 dev.local_dev_username.as_deref().unwrap_or("dev")
359 );
360 let cookie = create_auth_cookie(&auth.config.cookie_name, &dev_token, StdDuration::from_secs(86400), false);
361 return Ok(Response::builder()
362 .status(StatusCode::FOUND)
363 .header(header::LOCATION, "/")
364 .header(header::SET_COOKIE, cookie.to_string())
365 .body(Body::empty())
366 .unwrap());
367 }
368
369 let pkce_session = auth.pkce_manager.create();
371 let challenge = OidcClient::generate_code_challenge(&pkce_session.verifier);
372
373 let auth_url = auth
374 .oidc_client
375 .build_authorization_url(&auth.client_config, &pkce_session.state, Some(&challenge))
376 .await
377 .map_err(|e| AuthError::OidcError(e.to_string()))?;
378
379 let pkce_cookie = Cookie::build((
381 auth.pkce_manager.cookie_name().to_string(),
382 pkce_session.cookie_value,
383 ))
384 .path("/")
385 .http_only(true)
386 .same_site(SameSite::Lax)
387 .secure(!auth.is_dev_mode())
388 .max_age(TimeDuration::seconds(auth.pkce_manager.ttl().as_secs() as i64))
389 .build();
390
391 Ok(Response::builder()
392 .status(StatusCode::TEMPORARY_REDIRECT)
393 .header(header::LOCATION, &auth_url)
394 .header(header::SET_COOKIE, pkce_cookie.to_string())
395 .body(Body::empty())
396 .unwrap())
397}
398
399async fn callback_handler(
401 State(state): State<crate::ServerState>,
402 Query(query): Query<CallbackQuery>,
403 headers: axum::http::HeaderMap,
404) -> Result<Response, AuthError> {
405 let auth = state.auth.as_ref().ok_or(AuthError::AuthNotConfigured)?;
406
407 if let Some(error) = query.error {
409 let desc = query.error_description.unwrap_or_default();
410 tracing::error!("OIDC error: {} - {}", error, desc);
411 return Ok(Redirect::temporary(&format!(
412 "/?error={}&error_description={}",
413 urlencoding::encode(&error),
414 urlencoding::encode(&desc)
415 ))
416 .into_response());
417 }
418
419 let code = query.code.ok_or(AuthError::MissingCode)?;
420 let oauth_state = query.state.ok_or(AuthError::MissingState)?;
421
422 let cookie_header = headers
424 .get(header::COOKIE)
425 .and_then(|v| v.to_str().ok())
426 .unwrap_or("");
427 let pkce_value = extract_token_from_cookies(cookie_header, auth.pkce_manager.cookie_name())
428 .ok_or(AuthError::InvalidState)?;
429
430 let verifier = auth
432 .pkce_manager
433 .verify(&pkce_value, &oauth_state)
434 .ok_or(AuthError::InvalidState)?;
435
436 tracing::info!("Exchanging authorization code for tokens");
438 let token_response = auth
439 .oidc_client
440 .exchange_code_for_tokens(&auth.client_config, &code, Some(&verifier))
441 .await
442 .map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
443
444 let auth_token = token_response.access_token;
445 let max_age = token_response
446 .expires_in
447 .map(StdDuration::from_secs)
448 .unwrap_or(StdDuration::from_secs(3600));
449
450 let cookie = create_auth_cookie(&auth.config.cookie_name, &auth_token, max_age, !auth.is_dev_mode());
452
453 let clear_pkce = Cookie::build((auth.pkce_manager.cookie_name().to_string(), ""))
455 .path("/")
456 .http_only(true)
457 .same_site(SameSite::Lax)
458 .max_age(TimeDuration::seconds(-1))
459 .build();
460
461 tracing::info!("Authentication successful, redirecting to /");
462
463 Ok(Response::builder()
464 .status(StatusCode::FOUND)
465 .header(header::LOCATION, "/")
466 .header(header::SET_COOKIE, cookie.to_string())
467 .header(header::SET_COOKIE, clear_pkce.to_string())
468 .body(Body::empty())
469 .unwrap())
470}
471
472async fn me_handler(
474 State(state): State<crate::ServerState>,
475 headers: axum::http::HeaderMap,
476) -> Response {
477 let Some(ref auth) = state.auth else {
478 return axum::Json(serde_json::json!({
480 "authenticated": true,
481 "auth_enabled": false
482 }))
483 .into_response();
484 };
485
486 let cookie_header = headers
487 .get(header::COOKIE)
488 .and_then(|v| v.to_str().ok())
489 .unwrap_or("");
490
491 let bearer = headers
493 .get(header::AUTHORIZATION)
494 .and_then(|v| v.to_str().ok())
495 .and_then(|v| v.strip_prefix("Bearer "))
496 .map(String::from);
497
498 let token = bearer.or_else(|| extract_token_from_cookies(cookie_header, &auth.config.cookie_name));
499
500 let Some(token) = token else {
501 return axum::Json(serde_json::json!({
502 "authenticated": false,
503 "auth_enabled": true
504 }))
505 .into_response();
506 };
507
508 if token.starts_with("dev:") {
510 let parts: Vec<&str> = token.splitn(4, ':').collect();
511 if parts.len() >= 4 {
512 return axum::Json(serde_json::json!({
513 "authenticated": true,
514 "auth_enabled": true,
515 "email": parts[1],
516 "name": parts[2],
517 "username": parts[3],
518 "dev_mode": true
519 }))
520 .into_response();
521 }
522 }
523
524 match auth.validate_token(&token).await {
526 Ok(claims) => axum::Json(serde_json::json!({
527 "authenticated": true,
528 "auth_enabled": true,
529 "sub": claims.sub,
530 "email": claims.email,
531 "name": claims.name,
532 "username": claims.preferred_username,
533 "dev_mode": false
534 }))
535 .into_response(),
536 Err(e) => {
537 tracing::debug!("Token validation failed for /auth/me: {}", e);
538 axum::Json(serde_json::json!({
539 "authenticated": false,
540 "auth_enabled": true
541 }))
542 .into_response()
543 }
544 }
545}
546
547async fn logout_handler(
549 State(state): State<crate::ServerState>,
550) -> Response {
551 let cookie_name = state
552 .auth
553 .as_ref()
554 .map(|a| a.config.cookie_name.as_str())
555 .unwrap_or("trustee_token");
556
557 let cookie = Cookie::build((cookie_name.to_string(), ""))
558 .path("/")
559 .http_only(true)
560 .same_site(SameSite::Lax)
561 .max_age(TimeDuration::seconds(-1))
562 .build();
563
564 Response::builder()
565 .status(StatusCode::FOUND)
566 .header(header::LOCATION, "/")
567 .header(header::SET_COOKIE, cookie.to_string())
568 .body(Body::empty())
569 .unwrap()
570}
571
572fn create_auth_cookie(name: &str, value: &str, max_age: StdDuration, secure: bool) -> Cookie<'static> {
578 Cookie::build((name.to_string(), value.to_string()))
579 .path("/")
580 .http_only(true)
581 .same_site(SameSite::Lax)
582 .secure(secure)
583 .max_age(TimeDuration::seconds(max_age.as_secs() as i64))
584 .build()
585}
586
587#[derive(Debug)]
593pub enum AuthError {
594 MissingCode,
595 MissingState,
596 InvalidState,
597 OidcError(String),
598 TokenExchangeFailed(String),
599 AuthNotConfigured,
600}
601
602impl IntoResponse for AuthError {
603 fn into_response(self) -> Response {
604 let (_status, msg) = match self {
605 AuthError::MissingCode => (StatusCode::BAD_REQUEST, "Missing authorization code"),
606 AuthError::MissingState => (StatusCode::BAD_REQUEST, "Missing state parameter"),
607 AuthError::InvalidState => (StatusCode::BAD_REQUEST, "Invalid or expired state"),
608 AuthError::OidcError(_) => (StatusCode::SERVICE_UNAVAILABLE, "Authentication service error"),
609 AuthError::TokenExchangeFailed(_) => (StatusCode::BAD_REQUEST, "Token exchange failed"),
610 AuthError::AuthNotConfigured => (StatusCode::NOT_IMPLEMENTED, "Authentication not configured"),
611 };
612 Redirect::temporary(&format!("/?error={}", urlencoding::encode(msg))).into_response()
613 }
614}