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        Ok(claims)
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Auth checking — called by protected route handlers
231// ---------------------------------------------------------------------------
232
233/// Authenticated user info extracted from the token.
234#[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
255/// Check authentication for a protected endpoint.
256///
257/// Returns `Ok(())` if auth is not configured (open mode), or if a valid
258/// token is present. Returns `Err(StatusCode::UNAUTHORIZED)` if auth is
259/// configured but no valid token is found.
260///
261/// Token sources (in order):
262/// 1. `Authorization: Bearer <token>` header
263/// 2. `trustee_token=<token>` cookie
264///
265/// Dev mode tokens use the format `dev:email:name:username`.
266pub 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(()); // Auth not configured — allow
272    };
273
274    // Extract token
275    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    // Dev mode token
293    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    // Real JWT — validate via PEP ResourceServerClient
302    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
311/// Extract token value from a cookie header string.
312fn 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
322// ---------------------------------------------------------------------------
323// Auth routes: /auth/login, /auth/callback, /auth/me, /auth/logout
324// ---------------------------------------------------------------------------
325
326/// Build the auth routes as a nested Router.
327pub 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/// Query parameters for OIDC callback.
336#[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
344/// GET /auth/login — initiate OIDC login with PKCE, or create dev session.
345async 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    // Dev mode — create synthetic session
351    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    // Production — redirect to IdP with PKCE
370    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    // Set PKCE state cookie (HttpOnly, SameSite=Lax)
380    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
399/// GET /auth/callback — exchange authorization code for tokens, set cookie.
400async 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    // Check for errors from IdP
408    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    // Retrieve PKCE cookie
423    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    // Verify PKCE cookie (HMAC + expiry + state match)
431    let verifier = auth
432        .pkce_manager
433        .verify(&pkce_value, &oauth_state)
434        .ok_or(AuthError::InvalidState)?;
435
436    // Exchange code for tokens
437    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    // Set auth cookie
451    let cookie = create_auth_cookie(&auth.config.cookie_name, &auth_token, max_age, !auth.is_dev_mode());
452
453    // Clear PKCE cookie (single-use)
454    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
472/// GET /auth/me — return current user info.
473async 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        // Auth not configured — always authenticated (no auth required)
479        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    // Also try Authorization: Bearer header
492    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    // Dev mode token
509    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    // Real JWT — validate and return claims
525    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
547/// POST /auth/logout — clear auth cookie.
548async 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
572// ---------------------------------------------------------------------------
573// Helpers
574// ---------------------------------------------------------------------------
575
576/// Create an HttpOnly auth cookie.
577fn 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// ---------------------------------------------------------------------------
588// Error handling
589// ---------------------------------------------------------------------------
590
591/// Authentication errors.
592#[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}