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 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 async fn fill_userinfo_fields(&self, claims: &mut JwtClaims, token: &str) {
237 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#[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
310pub 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(()); };
328
329 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 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 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
366fn 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
377pub 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#[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
399async 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 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 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 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
457async 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 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 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 let verifier = auth
490 .pkce_manager
491 .verify(&pkce_value, &oauth_state)
492 .ok_or(AuthError::InvalidState)?;
493
494 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 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 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
531async 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 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 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 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 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
606async 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
631fn 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#[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}