diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index d72ca538..12bf8a33 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -94,6 +94,12 @@ pub struct OidcCallbackQueryDto { pub state: String, } +/// Request body for the OIDC one-time code exchange endpoint +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcExchangeDto { + pub code: String, +} + /// Information about available OIDC providers #[derive(Debug, Serialize, Deserialize)] pub struct OidcProviderInfoDto { diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 7226cb49..2bda849f 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -124,14 +124,16 @@ pub struct OidcIdClaims { /// Port for OIDC operations — implemented in infrastructure layer #[async_trait] pub trait OidcServicePort: Send + Sync + 'static { - /// Get the authorization URL for redirecting the user to the IdP - fn get_authorize_url(&self, state: &str) -> Result; + /// Get the authorization URL for redirecting the user to the IdP. + /// Includes PKCE code_challenge (S256) and nonce for ID token binding. + fn get_authorize_url(&self, state: &str, nonce: &str, pkce_challenge: &str) -> Result; - /// Exchange an authorization code for tokens - async fn exchange_code(&self, code: &str) -> Result; + /// Exchange an authorization code for tokens, providing PKCE code_verifier. + async fn exchange_code(&self, code: &str, pkce_verifier: &str) -> Result; - /// Validate an ID token and extract claims - async fn validate_id_token(&self, id_token: &str) -> Result; + /// Validate an ID token and extract claims. + /// If `expected_nonce` is provided, verifies the `nonce` claim matches. + async fn validate_id_token(&self, id_token: &str, expected_nonce: Option<&str>) -> Result; /// Fetch user info from the UserInfo endpoint (fallback for missing ID token claims) async fn fetch_user_info(&self, access_token: &str) -> Result; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 4ba9989c..4f8091b3 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,5 +1,8 @@ use std::sync::Arc; use std::sync::RwLock; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Instant; use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::session::Session; use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort, OidcServicePort, OidcIdClaims}; @@ -9,6 +12,24 @@ use crate::application::ports::inbound::FolderUseCase; use crate::common::errors::{DomainError, ErrorKind}; use crate::common::config::OidcConfig; +/// Maximum age for pending OIDC flows (10 minutes) +const OIDC_FLOW_TTL_SECS: u64 = 600; +/// Maximum age for pending one-time token codes (60 seconds) +const OIDC_TOKEN_TTL_SECS: u64 = 60; + +/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce) +struct PendingOidcFlow { + created_at: Instant, + pkce_verifier: String, + nonce: String, +} + +/// Tracks a pending one-time token exchange after successful OIDC callback +struct PendingOidcToken { + auth_response: AuthResponseDto, + created_at: Instant, +} + /// Interior state for OIDC — protected by RwLock for hot-reload. struct OidcState { service: Option>, @@ -22,6 +43,10 @@ pub struct AuthApplicationService { token_service: Arc, folder_service: Option>, oidc: RwLock, + /// Pending OIDC authorization flows keyed by state token (CSRF + PKCE + nonce) + pending_oidc_flows: Mutex>, + /// Pending one-time token codes for secure token delivery after OIDC callback + pending_oidc_tokens: Mutex>, } impl AuthApplicationService { @@ -38,6 +63,8 @@ impl AuthApplicationService { token_service, folder_service: None, oidc: RwLock::new(OidcState { service: None, config: None }), + pending_oidc_flows: Mutex::new(HashMap::new()), + pending_oidc_tokens: Mutex::new(HashMap::new()), } } @@ -582,28 +609,83 @@ impl AuthApplicationService { // OIDC Methods // ======================================================================== - /// Generate the OIDC authorization URL for redirecting the user to the IdP. - /// The `state` parameter is a signed JWT to prevent CSRF. - pub fn oidc_authorize_url(&self, state: &str) -> Result { + /// Prepare the OIDC authorization flow: generates CSRF state, PKCE pair, + /// nonce, stores them in pending_oidc_flows, and returns the authorize URL. + pub fn prepare_oidc_authorize(&self) -> Result { let oidc = self.oidc_service().ok_or_else(|| DomainError::new( ErrorKind::InternalError, "OIDC", "OIDC service not configured", ))?; - oidc.get_authorize_url(state) - } - /// Generate a signed OIDC state token (JWT containing a random nonce) - pub fn generate_oidc_state(&self) -> Result { - // Re-use the token service's secret for HMAC signing - // State = base64(random_bytes) — simple and sufficient for CSRF protection + // Generate CSRF state token use rand_core::{OsRng, RngCore}; - let mut nonce = [0u8; 32]; - OsRng.fill_bytes(&mut nonce); - Ok(hex::encode(nonce)) + let mut state_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut state_bytes); + let state_token = hex::encode(state_bytes); + + // Generate nonce for ID token binding + let mut nonce_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = hex::encode(nonce_bytes); + + // Generate PKCE pair (RFC 7636, S256) + let mut verifier_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut verifier_bytes); + let pkce_verifier = base64_url_encode(&verifier_bytes); + let pkce_challenge = { + use sha2::{Sha256, Digest}; + let hash = Sha256::digest(pkce_verifier.as_bytes()); + base64_url_encode(&hash) + }; + + // Store pending flow + { + let mut flows = self.pending_oidc_flows.lock().unwrap(); + // Cleanup expired entries + let now = Instant::now(); + flows.retain(|_, f| now.duration_since(f.created_at).as_secs() < OIDC_FLOW_TTL_SECS); + + flows.insert(state_token.clone(), PendingOidcFlow { + created_at: now, + pkce_verifier, + nonce: nonce.clone(), + }); + } + + // Build authorization URL with state, nonce, and PKCE challenge + let authorize_url = oidc.get_authorize_url(&state_token, &nonce, &pkce_challenge)?; + + tracing::info!("OIDC authorize flow prepared (state={}...)", &state_token[..8]); + + Ok(authorize_url) } - /// Handle the OIDC callback: exchange code, validate ID token, - /// find or create user (JIT provisioning), and issue internal tokens. - pub async fn oidc_callback(&self, code: &str) -> Result { + /// Handle the OIDC callback: validate CSRF state, exchange code with PKCE, + /// validate ID token nonce, find or create user (JIT provisioning), + /// issue internal tokens, and return a one-time exchange code. + pub async fn oidc_callback(&self, code: &str, state: &str) -> Result { + // 0. Validate CSRF state and retrieve PKCE verifier + nonce + let (pkce_verifier, nonce) = { + let mut flows = self.pending_oidc_flows.lock().unwrap(); + let flow = flows.remove(state).ok_or_else(|| { + tracing::warn!("OIDC callback with invalid/expired state token"); + DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "Invalid or expired OIDC state — possible CSRF attack. Please try logging in again.", + ) + })?; + + // Check TTL + if Instant::now().duration_since(flow.created_at).as_secs() >= OIDC_FLOW_TTL_SECS { + tracing::warn!("OIDC callback with expired state token"); + return Err(DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "OIDC authorization flow expired. Please try logging in again.", + )); + } + + (flow.pkce_verifier, flow.nonce) + }; + // Clone the Arc and config out of the RwLock so we don't hold the lock across await points let (oidc, oidc_config) = { let state = self.oidc.read().unwrap(); @@ -616,11 +698,11 @@ impl AuthApplicationService { (svc, cfg) }; - // 1. Exchange authorization code for tokens - let token_set = oidc.exchange_code(code).await?; + // 1. Exchange authorization code for tokens (with PKCE verifier) + let token_set = oidc.exchange_code(code, &pkce_verifier).await?; - // 2. Validate ID token and extract claims - let claims = oidc.validate_id_token(&token_set.id_token).await?; + // 2. Validate ID token and extract claims (with nonce verification) + let claims = oidc.validate_id_token(&token_set.id_token, Some(&nonce)).await?; // 3. Try to enrich claims from UserInfo endpoint if email is missing let claims = if claims.email.is_none() { @@ -664,7 +746,6 @@ impl AuthApplicationService { if let Some(_existing) = matched_user { // Email match but no OIDC link — for security, don't auto-link - // The user must link their account manually or admin must do it return Err(DomainError::new( ErrorKind::AlreadyExists, "OIDC", format!("A user with email '{}' already exists. Contact admin to link your OIDC identity.", oidc_email), @@ -696,7 +777,6 @@ impl AuthApplicationService { // Check for username collision if self.user_storage.get_user_by_username(&username).await.is_ok() { - // Append random suffix let suffix = &claims.sub[..4.min(claims.sub.len())]; username = format!("{}_{}", &username[..username.len().min(27)], suffix); } @@ -738,13 +818,57 @@ impl AuthApplicationService { ); self.session_storage.create_session(session).await?; - Ok(AuthResponseDto { + let auth_response = AuthResponseDto { user: UserDto::from(user), access_token, refresh_token, token_type: "Bearer".to_string(), expires_in: self.token_service.refresh_token_expiry_secs(), - }) + }; + + // 7. Store auth response behind a one-time exchange code (Fix #4: no tokens in URL) + let mut code_bytes = [0u8; 32]; + use rand_core::{OsRng, RngCore}; + OsRng.fill_bytes(&mut code_bytes); + let exchange_code = hex::encode(code_bytes); + + { + let mut tokens = self.pending_oidc_tokens.lock().unwrap(); + // Cleanup expired entries + let now = Instant::now(); + tokens.retain(|_, t| now.duration_since(t.created_at).as_secs() < OIDC_TOKEN_TTL_SECS); + + tokens.insert(exchange_code.clone(), PendingOidcToken { + auth_response, + created_at: now, + }); + } + + tracing::info!("OIDC login successful, one-time exchange code generated"); + + Ok(exchange_code) + } + + /// Exchange a one-time code for the authentication tokens. + /// The code is single-use and expires after 60 seconds. + pub fn exchange_oidc_token(&self, one_time_code: &str) -> Result { + let mut tokens = self.pending_oidc_tokens.lock().unwrap(); + let pending = tokens.remove(one_time_code).ok_or_else(|| { + DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "Invalid or expired exchange code. Please try logging in again.", + ) + })?; + + // Check TTL + if Instant::now().duration_since(pending.created_at).as_secs() >= OIDC_TOKEN_TTL_SECS { + return Err(DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "Exchange code expired. Please try logging in again.", + )); + } + + Ok(pending.auth_response) } /// Map OIDC groups to internal role @@ -779,4 +903,10 @@ impl AuthApplicationService { } } } +} + +/// URL-safe base64 encoding without padding (RFC 4648 §5) +fn base64_url_encode(input: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) } \ No newline at end of file diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 046d2dc5..5408b324 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -71,6 +71,7 @@ struct IdTokenClaims { preferred_username: Option, name: Option, groups: Option>, + nonce: Option, // Standard JWT fields #[allow(dead_code)] iss: Option, @@ -237,7 +238,7 @@ impl OidcService { #[async_trait] impl OidcServicePort for OidcService { - fn get_authorize_url(&self, state: &str) -> Result { + fn get_authorize_url(&self, state: &str, nonce: &str, pkce_challenge: &str) -> Result { // We need the authorization_endpoint. If not cached, we'll construct it from issuer. // In practice, the discovery should be pre-fetched during startup. let auth_endpoint = { @@ -256,18 +257,20 @@ impl OidcServicePort for OidcService { let scopes = self.config.scopes.replace(',', " "); let url = format!( - "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}", + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256", auth_endpoint, urlencoding::encode(&self.config.client_id), urlencoding::encode(&self.config.redirect_uri), urlencoding::encode(&scopes), urlencoding::encode(state), + urlencoding::encode(nonce), + urlencoding::encode(pkce_challenge), ); Ok(url) } - async fn exchange_code(&self, code: &str) -> Result { + async fn exchange_code(&self, code: &str, pkce_verifier: &str) -> Result { let discovery = self.get_discovery().await?; tracing::debug!("Exchanging authorization code at: {}", discovery.token_endpoint); @@ -279,6 +282,7 @@ impl OidcServicePort for OidcService { ("redirect_uri", &self.config.redirect_uri), ("client_id", &self.config.client_id), ("client_secret", &self.config.client_secret), + ("code_verifier", pkce_verifier), ]) .send() .await @@ -314,7 +318,7 @@ impl OidcServicePort for OidcService { }) } - async fn validate_id_token(&self, id_token: &str) -> Result { + async fn validate_id_token(&self, id_token: &str, expected_nonce: Option<&str>) -> Result { let jwks = self.get_jwks().await?; let discovery = self.get_discovery().await?; @@ -367,6 +371,24 @@ impl OidcServicePort for OidcService { let claims = token_data.claims; + // Verify nonce to prevent token replay attacks + if let Some(expected) = expected_nonce { + match &claims.nonce { + Some(actual) if actual == expected => { /* OK */ } + Some(actual) => { + tracing::warn!("OIDC nonce mismatch: expected={}, got={}", expected, actual); + return Err(DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "ID token nonce mismatch — possible replay attack", + )); + } + None => { + tracing::warn!("OIDC nonce missing from ID token (expected={})", expected); + // Some providers don't include nonce; log warning but don't fail + } + } + } + Ok(OidcIdClaims { sub: claims.sub, email: claims.email, diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 6cb95c77..286cfb17 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -10,7 +10,7 @@ use axum::{ use crate::common::di::AppState; use crate::application::dtos::user_dto::{ LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto, - OidcCallbackQueryDto, OidcProviderInfoDto, + OidcCallbackQueryDto, OidcProviderInfoDto, OidcExchangeDto, }; use crate::interfaces::errors::AppError; @@ -24,7 +24,8 @@ pub fn auth_routes() -> Router> { // OIDC endpoints (all public) .route("/oidc/providers", get(oidc_providers)) .route("/oidc/authorize", get(oidc_authorize)) - .route("/oidc/callback", get(oidc_callback)); + .route("/oidc/callback", get(oidc_callback)) + .route("/oidc/exchange", post(oidc_exchange)); // Rutas que SÍ requieren autenticación - usamos route_layer para aplicar middleware // El middleware usará el state que se pase con .with_state() desde main.rs @@ -55,6 +56,15 @@ async fn register( return Err(AppError::internal_error("Servicio de autenticación no configurado")); } }; + + // Fix #5: Block password registration when OIDC-only mode is active + if auth_service.auth_application_service.password_login_disabled() { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Password registration is disabled. Please use SSO/OIDC to sign in.", + "PasswordRegistrationDisabled", + )); + } // Check if this is a fresh install tracing::info!("New user registration detected, checking if it's a fresh install"); @@ -370,11 +380,8 @@ async fn oidc_authorize( )); } - // Generate CSRF state - let state_token = auth_app.generate_oidc_state()?; - - // Build authorization URL - let authorize_url = auth_app.oidc_authorize_url(&state_token)?; + // Prepare OIDC authorization flow (generates CSRF state, PKCE pair, nonce) + let authorize_url = auth_app.prepare_oidc_authorize()?; tracing::info!("OIDC authorize redirect generated"); @@ -401,25 +408,44 @@ async fn oidc_callback( tracing::info!("OIDC callback received with code"); - // Exchange code and authenticate - let auth_response = auth_app.oidc_callback(&query.code).await + // Exchange code, validate state/nonce/PKCE, authenticate user + let exchange_code = auth_app.oidc_callback(&query.code, &query.state).await .map_err(|e| { tracing::error!("OIDC callback failed: {}", e); AppError::from(e) })?; - // Redirect to frontend with tokens as fragment + // Redirect to frontend with one-time exchange code (NOT raw tokens) let config = auth_app.oidc_config().unwrap(); let frontend_url = config.frontend_url.trim_end_matches('/'); let redirect_url = format!( - "{}/#access_token={}&refresh_token={}&token_type=Bearer&expires_in={}", + "{}/?oidc_code={}", frontend_url, - auth_response.access_token, - auth_response.refresh_token, - auth_response.expires_in, + exchange_code, ); - tracing::info!("OIDC login successful for user: {}", auth_response.user.username); + tracing::info!("OIDC login successful, redirecting with exchange code"); Ok(Redirect::temporary(&redirect_url)) } + +/// POST /api/auth/oidc/exchange — Exchange one-time code for auth tokens +/// Request body: { "code": "" } +async fn oidc_exchange( + State(state): State>, + Json(body): Json, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let auth_response = auth_service.auth_application_service + .exchange_oidc_token(&body.code) + .map_err(|e| { + tracing::warn!("OIDC token exchange failed: {}", e); + AppError::from(e) + })?; + + tracing::info!("OIDC token exchange successful for user: {}", auth_response.user.username); + + Ok((StatusCode::OK, Json(auth_response))) +}