From 8ef62109a38145eac80d6e3e73f0cbe973c40de1 Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 10 Feb 2026 20:32:32 +0100 Subject: [PATCH] feat(auth): add OpenID Connect (OIDC) authentication support Implements OIDC Authorization Code Flow for external identity providers (Authentik, Keycloak, etc.) with JIT user provisioning. New features: - OidcService with OpenID Discovery, JWKS caching, RS256 ID token validation - Authorization Code Flow: /api/auth/oidc/authorize -> IdP -> /api/auth/oidc/callback - JIT user provisioning from OIDC claims (sub, email, name, groups) - OIDC group-to-role mapping (admin_groups config) - Provider info endpoint: GET /api/auth/oidc/providers - Option to disable password login entirely (OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN) - Auto-provision toggle (OXICLOUD_OIDC_AUTO_PROVISION) - Email collision detection (security: prevents account takeover) Configuration (env vars): - OXICLOUD_OIDC_ENABLED, OXICLOUD_OIDC_ISSUER_URL - OXICLOUD_OIDC_CLIENT_ID, OXICLOUD_OIDC_CLIENT_SECRET - OXICLOUD_OIDC_REDIRECT_URI, OXICLOUD_OIDC_SCOPES - OXICLOUD_OIDC_FRONTEND_URL, OXICLOUD_OIDC_PROVIDER_NAME - OXICLOUD_OIDC_AUTO_PROVISION, OXICLOUD_OIDC_ADMIN_GROUPS - OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN DB migration: - ALTER TABLE auth.users ADD oidc_provider, oidc_subject columns - UNIQUE index on (oidc_provider, oidc_subject) Files changed: 14 files, ~1400 lines added Dependencies: reqwest 0.12 (rustls-tls-webpki-roots), base64 0.22 --- Cargo.lock | 267 ++++++++++- Cargo.toml | 2 + db/schema.sql | 6 + src/application/dtos/user_dto.rs | 37 ++ src/application/ports/auth_ports.rs | 44 ++ .../services/auth_application_service.rs | 231 ++++++++- src/common/config.rs | 91 ++++ src/domain/entities/user.rs | 89 ++++ src/domain/repositories/user_repository.rs | 3 + src/infrastructure/auth_factory.rs | 14 + .../repositories/pg/user_pg_repository.rs | 91 +++- src/infrastructure/services/mod.rs | 3 +- src/infrastructure/services/oidc_service.rs | 439 ++++++++++++++++++ src/interfaces/api/handlers/auth_handler.rs | 123 ++++- 14 files changed, 1407 insertions(+), 33 deletions(-) create mode 100644 src/infrastructure/services/oidc_service.rs diff --git a/Cargo.lock b/Cargo.lock index 7273a6a7..60368d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -438,7 +444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -620,7 +626,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -694,7 +700,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -883,8 +889,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -894,9 +902,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -916,7 +926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1083,19 +1093,44 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.5", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1271,6 +1306,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1311,7 +1362,7 @@ dependencies = [ "p256", "p384", "pem", - "rand", + "rand 0.8.5", "rsa", "serde", "serde_json", @@ -1404,6 +1455,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rust2" version = "0.13.0" @@ -1580,7 +1637,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1636,6 +1693,7 @@ dependencies = [ "async-stream", "async-trait", "axum", + "base64", "bytes", "chrono", "dotenv", @@ -1654,7 +1712,8 @@ dependencies = [ "mime_guess", "mockall", "quick-xml", - "rand_core", + "rand_core 0.6.4", + "reqwest", "serde", "serde_json", "sha2", @@ -1732,7 +1791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1921,6 +1980,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -1943,8 +2057,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1954,7 +2078,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1966,6 +2100,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2001,6 +2144,44 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.5", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2038,13 +2219,19 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2087,6 +2274,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2259,7 +2447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2443,7 +2631,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -2483,7 +2671,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -2561,6 +2749,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2705,6 +2896,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2761,12 +2962,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -2989,6 +3192,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.108" @@ -3021,6 +3238,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index f81a85a0..8c205cbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,8 @@ md5 = "0.8.0" sha2 = "0.10.9" hex = "0.4.3" http-body-util = "0.1.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] } +base64 = "0.22.1" [features] default = [] diff --git a/db/schema.sql b/db/schema.sql index 80353078..6f5486db 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -120,6 +120,12 @@ COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilizati COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization'; COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization'; +-- OIDC identity linking columns +ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oidc_provider VARCHAR(255); +ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON auth.users(oidc_provider, oidc_subject) + WHERE oidc_provider IS NOT NULL AND oidc_subject IS NOT NULL; + -- NOTE: No default users are created. The first user to register through -- the admin setup wizard will become the administrator. diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index fa2010d8..d72ca538 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -74,4 +74,41 @@ pub struct CurrentUser { pub username: String, pub email: String, pub role: String, +} + +// ============================================================================ +// OIDC DTOs +// ============================================================================ + +/// Response with the OIDC authorization URL for client redirect +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcAuthorizeResponseDto { + pub authorize_url: String, + pub state: String, +} + +/// Query parameters received on the OIDC callback +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcCallbackQueryDto { + pub code: String, + pub state: String, +} + +/// Information about available OIDC providers +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcProviderInfoDto { + pub enabled: bool, + pub provider_name: String, + pub authorize_endpoint: String, + pub password_login_enabled: bool, +} + +/// Claims extracted from the validated OIDC ID token +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OidcUserInfoDto { + pub sub: String, + pub preferred_username: Option, + pub email: Option, + pub name: Option, + pub groups: Vec, } \ No newline at end of file diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 0150240d..7226cb49 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -94,6 +94,50 @@ pub trait UserStoragePort: Send + Sync + 'static { /// Cambia la contraseña de un usuario async fn change_password(&self, user_id: &str, password_hash: &str) -> Result<(), DomainError>; + + /// Finds a user by OIDC provider + subject pair + async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result; +} + +// ============================================================================ +// OIDC Port +// ============================================================================ + +/// Represents the token set returned by the OIDC provider after code exchange +#[derive(Debug, Clone)] +pub struct OidcTokenSet { + pub access_token: String, + pub id_token: String, + pub refresh_token: Option, +} + +/// Claims extracted from the validated OIDC ID token +#[derive(Debug, Clone)] +pub struct OidcIdClaims { + pub sub: String, + pub email: Option, + pub preferred_username: Option, + pub name: Option, + pub groups: Vec, +} + +/// 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; + + /// Exchange an authorization code for tokens + async fn exchange_code(&self, code: &str) -> Result; + + /// Validate an ID token and extract claims + async fn validate_id_token(&self, id_token: &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; + + /// Get the OIDC provider display name + fn provider_name(&self) -> &str; } #[async_trait] diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index b8bd8d7e..82e1d6cf 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,11 +1,12 @@ use std::sync::Arc; use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::session::Session; -use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort}; +use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort, OidcServicePort, OidcIdClaims}; use crate::application::dtos::user_dto::{UserDto, RegisterDto, LoginDto, AuthResponseDto, ChangePasswordDto, RefreshTokenDto}; use crate::application::dtos::folder_dto::CreateFolderDto; use crate::application::ports::inbound::FolderUseCase; use crate::common::errors::{DomainError, ErrorKind}; +use crate::common::config::OidcConfig; pub struct AuthApplicationService { user_storage: Arc, @@ -13,6 +14,8 @@ pub struct AuthApplicationService { password_hasher: Arc, token_service: Arc, folder_service: Option>, + oidc_service: Option>, + oidc_config: Option, } impl AuthApplicationService { @@ -28,6 +31,8 @@ impl AuthApplicationService { password_hasher, token_service, folder_service: None, + oidc_service: None, + oidc_config: None, } } @@ -36,6 +41,33 @@ impl AuthApplicationService { self.folder_service = Some(folder_service); self } + + /// Configura el servicio OIDC + pub fn with_oidc(mut self, oidc_service: Arc, oidc_config: OidcConfig) -> Self { + self.oidc_service = Some(oidc_service); + self.oidc_config = Some(oidc_config); + self + } + + /// Returns whether OIDC is configured and enabled + pub fn oidc_enabled(&self) -> bool { + self.oidc_service.is_some() && self.oidc_config.as_ref().map_or(false, |c| c.enabled) + } + + /// Returns whether password login is disabled (OIDC-only mode) + pub fn password_login_disabled(&self) -> bool { + self.oidc_config.as_ref().map_or(false, |c| c.disable_password_login) + } + + /// Returns the OIDC config if available + pub fn oidc_config(&self) -> Option<&OidcConfig> { + self.oidc_config.as_ref() + } + + /// Returns the OIDC service if available + pub fn oidc_service(&self) -> Option<&Arc> { + self.oidc_service.as_ref() + } pub async fn register(&self, dto: RegisterDto) -> Result { // Verificar usuario duplicado @@ -519,4 +551,201 @@ impl AuthApplicationService { let users = self.user_storage.list_users(limit, offset).await?; Ok(users.into_iter().map(UserDto::from).collect()) } + + // ======================================================================== + // 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 { + let oidc = self.oidc_service.as_ref().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 + use rand_core::{OsRng, RngCore}; + let mut nonce = [0u8; 32]; + OsRng.fill_bytes(&mut nonce); + Ok(hex::encode(nonce)) + } + + /// 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 { + let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", "OIDC service not configured", + ))?; + let oidc_config = self.oidc_config.as_ref().ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", "OIDC config not available", + ))?; + + // 1. Exchange authorization code for tokens + let token_set = oidc.exchange_code(code).await?; + + // 2. Validate ID token and extract claims + let claims = oidc.validate_id_token(&token_set.id_token).await?; + + // 3. Try to enrich claims from UserInfo endpoint if email is missing + let claims = if claims.email.is_none() { + match oidc.fetch_user_info(&token_set.access_token).await { + Ok(user_info) => OidcIdClaims { + email: user_info.email.or(claims.email), + preferred_username: user_info.preferred_username.or(claims.preferred_username), + name: user_info.name.or(claims.name), + groups: if user_info.groups.is_empty() { claims.groups } else { user_info.groups }, + ..claims + }, + Err(e) => { + tracing::warn!("Failed to fetch UserInfo (continuing with ID token claims): {}", e); + claims + } + } + } else { + claims + }; + + let provider_name = oidc.provider_name().to_string(); + + // 4. Determine username and email + let oidc_username = claims.preferred_username.clone() + .or(claims.name.clone()) + .unwrap_or_else(|| format!("oidc_{}", &claims.sub[..8.min(claims.sub.len())])); + let oidc_email = claims.email.clone() + .unwrap_or_else(|| format!("{}@oidc.local", oidc_username)); + + // 5. Look up existing user by OIDC subject + let user = match self.user_storage.get_user_by_oidc_subject(&provider_name, &claims.sub).await { + Ok(mut existing_user) => { + // User exists — update last login + existing_user.register_login(); + self.user_storage.update_user(existing_user.clone()).await?; + existing_user + } + Err(_) => { + // User doesn't exist — try to match by email + let matched_user = self.user_storage.get_user_by_email(&oidc_email).await.ok(); + + 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), + )); + } + + // No match — JIT provision if enabled + if !oidc_config.auto_provision { + return Err(DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "Auto-provisioning is disabled. Contact admin to create your account.", + )); + } + + // Determine role from OIDC groups + let role = self.map_oidc_role(&claims.groups, oidc_config); + + let quota = if role == UserRole::Admin { + 107374182400 // 100GB + } else { + 1024 * 1024 * 1024 // 1GB + }; + + // Sanitize username (max 32 chars, ensure uniqueness) + let mut username = oidc_username.chars().take(32).collect::(); + if username.len() < 3 { + username = format!("user_{}", &claims.sub[..8.min(claims.sub.len())]); + } + + // 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); + } + + let new_user = User::new_oidc( + username.clone(), + oidc_email, + role, + quota, + provider_name.clone(), + claims.sub.clone(), + ).map_err(|e| DomainError::new( + ErrorKind::InvalidInput, "OIDC", + format!("Failed to create OIDC user: {}", e), + ))?; + + let created_user = self.user_storage.create_user(new_user).await?; + + // Create personal folder + self.create_personal_folder(&username, created_user.id()).await; + + tracing::info!("OIDC user provisioned: {} (provider: {}, sub: {})", + created_user.id(), provider_name, claims.sub); + + created_user + } + }; + + // 6. Issue internal tokens (same as regular login) + let access_token = self.token_service.generate_access_token(&user)?; + let refresh_token = self.token_service.generate_refresh_token(); + + let session = Session::new( + user.id().to_string(), + refresh_token.clone(), + None, + None, + self.token_service.refresh_token_expiry_days(), + ); + self.session_storage.create_session(session).await?; + + Ok(AuthResponseDto { + user: UserDto::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: self.token_service.refresh_token_expiry_secs(), + }) + } + + /// Map OIDC groups to internal role + fn map_oidc_role(&self, groups: &[String], config: &OidcConfig) -> UserRole { + if config.admin_groups.is_empty() { + return UserRole::User; + } + let admin_groups: Vec<&str> = config.admin_groups.split(',').map(|s| s.trim()).collect(); + for group in groups { + if admin_groups.iter().any(|ag| ag.eq_ignore_ascii_case(group)) { + return UserRole::Admin; + } + } + UserRole::User + } + + /// Helper to create a personal folder for a new user + async fn create_personal_folder(&self, username: &str, user_id: &str) { + if let Some(folder_service) = &self.folder_service { + let folder_name = format!("Mi Carpeta - {}", username); + match folder_service.create_folder(CreateFolderDto { + name: folder_name.clone(), + parent_id: None, + }).await { + Ok(folder) => { + tracing::info!("Personal folder created for user {}: {} (ID: {})", + user_id, folder.name, folder.id); + } + Err(e) => { + tracing::error!("Failed to create personal folder for user {}: {}", user_id, e); + } + } + } + } } \ No newline at end of file diff --git a/src/common/config.rs b/src/common/config.rs index 12596c03..596831cc 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -264,6 +264,51 @@ impl Default for AuthConfig { } } +/// Configuración de OpenID Connect (OIDC) +#[derive(Debug, Clone)] +pub struct OidcConfig { + /// Whether OIDC authentication is enabled + pub enabled: bool, + /// OIDC Issuer URL (e.g. https://authentik.example.com/application/o/oxicloud/) + pub issuer_url: String, + /// OIDC Client ID + pub client_id: String, + /// OIDC Client Secret + pub client_secret: String, + /// Redirect URI after OIDC authentication (must match IdP config) + pub redirect_uri: String, + /// OIDC scopes to request + pub scopes: String, + /// Frontend URL to redirect after successful OIDC login (tokens appended as fragment) + pub frontend_url: String, + /// Whether to auto-create users on first OIDC login (JIT provisioning) + pub auto_provision: bool, + /// Comma-separated list of OIDC groups that map to admin role + pub admin_groups: String, + /// Whether to disable password-based login entirely + pub disable_password_login: bool, + /// OIDC provider display name (shown in UI) + pub provider_name: String, +} + +impl Default for OidcConfig { + fn default() -> Self { + Self { + enabled: false, + issuer_url: String::new(), + client_id: String::new(), + client_secret: String::new(), + redirect_uri: "http://localhost:8086/api/auth/oidc/callback".to_string(), + scopes: "openid profile email".to_string(), + frontend_url: "http://localhost:8086".to_string(), + auto_provision: true, + admin_groups: String::new(), + disable_password_login: false, + provider_name: "SSO".to_string(), + } + } +} + /// Configuración de funcionalidades (feature flags) #[derive(Debug, Clone)] pub struct FeaturesConfig { @@ -313,6 +358,8 @@ pub struct AppConfig { pub auth: AuthConfig, /// Configuración de funcionalidades pub features: FeaturesConfig, + /// Configuración OIDC + pub oidc: OidcConfig, } impl Default for AppConfig { @@ -330,6 +377,7 @@ impl Default for AppConfig { database: DatabaseConfig::default(), auth: AuthConfig::default(), features: FeaturesConfig::default(), + oidc: OidcConfig::default(), } } } @@ -448,6 +496,49 @@ impl AppConfig { } } + // OIDC configuration + if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") { + config.oidc.enabled = v.parse::().unwrap_or(false); + } + if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { + config.oidc.issuer_url = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { + config.oidc.client_id = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { + config.oidc.client_secret = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { + config.oidc.redirect_uri = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { + config.oidc.scopes = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { + config.oidc.frontend_url = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") { + config.oidc.auto_provision = v.parse::().unwrap_or(true); + } + if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { + config.oidc.admin_groups = v; + } + if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") { + config.oidc.disable_password_login = v.parse::().unwrap_or(false); + } + if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { + config.oidc.provider_name = v; + } + + // Validate OIDC config when enabled + if config.oidc.enabled { + if config.oidc.issuer_url.is_empty() || config.oidc.client_id.is_empty() || config.oidc.client_secret.is_empty() { + tracing::error!("OIDC is enabled but OXICLOUD_OIDC_ISSUER_URL, OXICLOUD_OIDC_CLIENT_ID, or OXICLOUD_OIDC_CLIENT_SECRET are not set"); + config.oidc.enabled = false; + } + } + config } diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index e91cbd53..9552dd3d 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -33,6 +33,8 @@ pub struct User { updated_at: DateTime, last_login_at: Option>, active: bool, + oidc_provider: Option, + oidc_subject: Option, } impl User { @@ -88,6 +90,45 @@ impl User { updated_at: now, last_login_at: None, active: true, + oidc_provider: None, + oidc_subject: None, + }) + } + + /// Create a new OIDC-authenticated user (no password required). + pub fn new_oidc( + username: String, + email: String, + role: UserRole, + storage_quota_bytes: i64, + oidc_provider: String, + oidc_subject: String, + ) -> UserResult { + if username.is_empty() || username.len() < 3 || username.len() > 32 { + return Err(UserError::InvalidUsername( + "Username debe tener entre 3 y 32 caracteres".to_string(), + )); + } + if !email.contains('@') || email.len() < 5 { + return Err(UserError::ValidationError( + "Email inválido".to_string(), + )); + } + let now = Utc::now(); + Ok(Self { + id: Uuid::new_v4().to_string(), + username, + email, + password_hash: "__OIDC_NO_PASSWORD__".to_string(), + role, + storage_quota_bytes, + storage_used_bytes: 0, + created_at: now, + updated_at: now, + last_login_at: None, + active: true, + oidc_provider: Some(oidc_provider), + oidc_subject: Some(oidc_subject), }) } @@ -117,6 +158,41 @@ impl User { updated_at, last_login_at, active, + oidc_provider: None, + oidc_subject: None, + } + } + + /// Reconstruct from DB with OIDC fields + pub fn from_data_full( + id: String, + username: String, + email: String, + password_hash: String, + role: UserRole, + storage_quota_bytes: i64, + storage_used_bytes: i64, + created_at: DateTime, + updated_at: DateTime, + last_login_at: Option>, + active: bool, + oidc_provider: Option, + oidc_subject: Option, + ) -> Self { + Self { + id, + username, + email, + password_hash, + role, + storage_quota_bytes, + storage_used_bytes, + created_at, + updated_at, + last_login_at, + active, + oidc_provider, + oidc_subject, } } @@ -164,6 +240,19 @@ impl User { pub fn password_hash(&self) -> &str { &self.password_hash } + + pub fn oidc_provider(&self) -> Option<&str> { + self.oidc_provider.as_deref() + } + + pub fn oidc_subject(&self) -> Option<&str> { + self.oidc_subject.as_deref() + } + + /// Returns true if this is an OIDC-only user (no password) + pub fn is_oidc_user(&self) -> bool { + self.oidc_provider.is_some() + } /// Update the password hash. /// diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 786fc432..9a9ebc6e 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -91,4 +91,7 @@ pub trait UserRepository: Send + Sync + 'static { /// Elimina un usuario async fn delete_user(&self, user_id: &str) -> UserRepositoryResult<()>; + + /// Finds a user by OIDC provider + subject pair + async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult; } \ No newline at end of file diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index 06a407e5..a8ce0f87 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -8,6 +8,7 @@ use crate::application::services::folder_service::FolderService; use crate::infrastructure::repositories::{UserPgRepository, SessionPgRepository}; use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; use crate::infrastructure::services::jwt_service::JwtTokenService; +use crate::infrastructure::services::oidc_service::OidcService; use crate::common::config::AppConfig; use crate::common::di::AuthServices; @@ -42,6 +43,19 @@ pub async fn create_auth_services( if let Some(folder_svc) = folder_service { auth_app_service = auth_app_service.with_folder_service(folder_svc); } + + // Configure OIDC service if enabled + if config.oidc.enabled { + tracing::info!("Initializing OIDC service (provider: {}, issuer: {})", + config.oidc.provider_name, config.oidc.issuer_url); + + let oidc_service = Arc::new(OidcService::new(config.oidc.clone())); + auth_app_service = auth_app_service.with_oidc(oidc_service, config.oidc.clone()); + + if config.oidc.disable_password_login { + tracing::warn!("Password login is DISABLED — only OIDC authentication is allowed"); + } + } // Empaquetar servicio en Arc let auth_application_service = Arc::new(auth_app_service); diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 09546ca7..b827dee3 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -74,9 +74,11 @@ impl UserRepository for UserPgRepository { INSERT INTO auth.users ( id, username, email, password_hash, role, storage_quota_bytes, storage_used_bytes, - created_at, updated_at, last_login_at, active + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject ) VALUES ( - $1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11 + $1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11, + $12, $13 ) RETURNING * "# @@ -92,6 +94,8 @@ impl UserRepository for UserPgRepository { .bind(user_clone.updated_at()) .bind(user_clone.last_login_at()) .bind(user_clone.is_active()) + .bind(user_clone.oidc_provider()) + .bind(user_clone.oidc_subject()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -114,7 +118,8 @@ impl UserRepository for UserPgRepository { SELECT id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, - created_at, updated_at, last_login_at, active + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject FROM auth.users WHERE id = $1 "# @@ -131,7 +136,7 @@ impl UserRepository for UserPgRepository { _ => UserRole::User, }; - Ok(User::from_data( + Ok(User::from_data_full( row.get("id"), row.get("username"), row.get("email"), @@ -143,6 +148,8 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), )) } @@ -153,7 +160,8 @@ impl UserRepository for UserPgRepository { SELECT id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, - created_at, updated_at, last_login_at, active + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject FROM auth.users WHERE username = $1 "# @@ -170,7 +178,7 @@ impl UserRepository for UserPgRepository { _ => UserRole::User, }; - Ok(User::from_data( + Ok(User::from_data_full( row.get("id"), row.get("username"), row.get("email"), @@ -182,6 +190,8 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), )) } @@ -192,7 +202,8 @@ impl UserRepository for UserPgRepository { SELECT id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, - created_at, updated_at, last_login_at, active + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject FROM auth.users WHERE email = $1 "# @@ -209,7 +220,7 @@ impl UserRepository for UserPgRepository { _ => UserRole::User, }; - Ok(User::from_data( + Ok(User::from_data_full( row.get("id"), row.get("username"), row.get("email"), @@ -221,6 +232,8 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), )) } @@ -322,7 +335,8 @@ impl UserRepository for UserPgRepository { SELECT id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, - created_at, updated_at, last_login_at, active + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject FROM auth.users ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -343,7 +357,7 @@ impl UserRepository for UserPgRepository { _ => UserRole::User, }; - User::from_data( + User::from_data_full( row.get("id"), row.get("username"), row.get("email"), @@ -355,6 +369,8 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), ) }) .collect(); @@ -432,7 +448,8 @@ impl UserRepository for UserPgRepository { SELECT id, username, email, password_hash, role::text as role_text, storage_quota_bytes, storage_used_bytes, - created_at, updated_at, last_login_at, active + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject FROM auth.users WHERE role::text = $1 ORDER BY created_at DESC @@ -452,7 +469,7 @@ impl UserRepository for UserPgRepository { _ => UserRole::User, }; - User::from_data( + User::from_data_full( row.get("id"), row.get("username"), row.get("email"), @@ -464,6 +481,8 @@ impl UserRepository for UserPgRepository { row.get("updated_at"), row.get("last_login_at"), row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), ) }) .collect(); @@ -486,6 +505,48 @@ impl UserRepository for UserPgRepository { Ok(()) } + + /// Finds a user by OIDC provider + subject pair + async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult { + let row = sqlx::query( + r#" + SELECT + id, username, email, password_hash, role::text as role_text, + storage_quota_bytes, storage_used_bytes, + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject + FROM auth.users + WHERE oidc_provider = $1 AND oidc_subject = $2 + "# + ) + .bind(provider) + .bind(subject) + .fetch_one(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let role_str: Option = row.try_get("role_text").unwrap_or(None); + let role = match role_str.as_deref() { + Some("admin") => UserRole::Admin, + _ => UserRole::User, + }; + + Ok(User::from_data_full( + row.get("id"), + row.get("username"), + row.get("email"), + row.get("password_hash"), + role, + row.get("storage_quota_bytes"), + row.get("storage_used_bytes"), + row.get("created_at"), + row.get("updated_at"), + row.get("last_login_at"), + row.get("active"), + row.get("oidc_provider"), + row.get("oidc_subject"), + )) + } } // Implementación del puerto de almacenamiento para la capa de aplicación @@ -536,4 +597,10 @@ impl UserStoragePort for UserPgRepository { .await .map_err(DomainError::from) } + + async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result { + UserRepository::get_user_by_oidc_subject(self, provider, subject) + .await + .map_err(DomainError::from) + } } \ No newline at end of file diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 1e113e34..affb8d02 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -15,4 +15,5 @@ pub mod thumbnail_service; pub mod write_behind_cache; pub mod chunked_upload_service; pub mod image_transcode_service; -pub mod dedup_service; \ No newline at end of file +pub mod dedup_service; +pub mod oidc_service; \ No newline at end of file diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs new file mode 100644 index 00000000..046d2dc5 --- /dev/null +++ b/src/infrastructure/services/oidc_service.rs @@ -0,0 +1,439 @@ +//! OpenID Connect (OIDC) service implementation. +//! +//! Handles OIDC discovery, authorization URL generation, code exchange, +//! ID token validation (RS256 via JWKS), and UserInfo fetching. +//! Compatible with Authentik, Keycloak, and any standard OIDC provider. + +use std::sync::RwLock; +use async_trait::async_trait; +use serde::Deserialize; + +use crate::application::ports::auth_ports::{OidcServicePort, OidcTokenSet, OidcIdClaims}; +use crate::common::config::OidcConfig; +use crate::common::errors::{DomainError, ErrorKind}; + +// ============================================================================ +// OIDC Discovery Document +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +struct OidcDiscovery { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + userinfo_endpoint: Option, + jwks_uri: String, +} + +// ============================================================================ +// JWKS structures for RS256 validation +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +struct JwksDocument { + keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct JwkKey { + kty: String, + #[serde(rename = "use")] + key_use: Option, + kid: Option, + alg: Option, + n: Option, // RSA modulus (base64url) + e: Option, // RSA exponent (base64url) +} + +// ============================================================================ +// Token exchange response +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + id_token: Option, + refresh_token: Option, + #[allow(dead_code)] + token_type: Option, + #[allow(dead_code)] + expires_in: Option, +} + +// ============================================================================ +// ID token claims (standard OIDC) +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct IdTokenClaims { + sub: String, + email: Option, + preferred_username: Option, + name: Option, + groups: Option>, + // Standard JWT fields + #[allow(dead_code)] + iss: Option, + #[allow(dead_code)] + aud: Option, + #[allow(dead_code)] + exp: Option, + #[allow(dead_code)] + iat: Option, +} + +// ============================================================================ +// UserInfo response +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct UserInfoResponse { + sub: String, + email: Option, + preferred_username: Option, + name: Option, + groups: Option>, +} + +// ============================================================================ +// OIDC Service +// ============================================================================ + +pub struct OidcService { + config: OidcConfig, + http_client: reqwest::Client, + /// Cached discovery document + discovery: RwLock>, + /// Cached JWKS + jwks: RwLock>, +} + +impl OidcService { + pub fn new(config: OidcConfig) -> Self { + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client for OIDC"); + + Self { + config, + http_client, + discovery: RwLock::new(None), + jwks: RwLock::new(None), + } + } + + /// Fetch and cache the OIDC discovery document + async fn get_discovery(&self) -> Result { + // Check cache first + { + let cache = self.discovery.read().map_err(|_| DomainError::new( + ErrorKind::InternalError, "OIDC", "Lock poisoned", + ))?; + if let Some(ref disc) = *cache { + return Ok(disc.clone()); + } + } + + // Fetch discovery document + let issuer = self.config.issuer_url.trim_end_matches('/'); + let discovery_url = format!("{}/.well-known/openid-configuration", issuer); + + tracing::info!("Fetching OIDC discovery from: {}", discovery_url); + + let resp = self.http_client.get(&discovery_url) + .send() + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to fetch OIDC discovery: {}", e), + ))?; + + if !resp.status().is_success() { + return Err(DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("OIDC discovery returned status {}", resp.status()), + )); + } + + let discovery: OidcDiscovery = resp.json().await.map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to parse OIDC discovery: {}", e), + ))?; + + // Cache it + { + let mut cache = self.discovery.write().map_err(|_| DomainError::new( + ErrorKind::InternalError, "OIDC", "Lock poisoned", + ))?; + *cache = Some(discovery.clone()); + } + + Ok(discovery) + } + + /// Fetch and cache JWKS document for ID token validation + async fn get_jwks(&self) -> Result { + // Check cache first + { + let cache = self.jwks.read().map_err(|_| DomainError::new( + ErrorKind::InternalError, "OIDC", "Lock poisoned", + ))?; + if let Some(ref jwks) = *cache { + return Ok(jwks.clone()); + } + } + + let discovery = self.get_discovery().await?; + + tracing::debug!("Fetching JWKS from: {}", discovery.jwks_uri); + + let resp = self.http_client.get(&discovery.jwks_uri) + .send() + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to fetch JWKS: {}", e), + ))?; + + let jwks: JwksDocument = resp.json().await.map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to parse JWKS: {}", e), + ))?; + + // Cache it + { + let mut cache = self.jwks.write().map_err(|_| DomainError::new( + ErrorKind::InternalError, "OIDC", "Lock poisoned", + ))?; + *cache = Some(jwks.clone()); + } + + Ok(jwks) + } + + /// Find the right RSA key from JWKS by kid header + fn find_rsa_key<'a>(jwks: &'a JwksDocument, kid: Option<&str>) -> Option<&'a JwkKey> { + jwks.keys.iter().find(|k| { + k.kty == "RSA" + && k.key_use.as_deref() != Some("enc") // exclude encryption keys + && (kid.is_none() || k.kid.as_deref() == kid) + }) + } + + /// Extract the `kid` from a JWT header without full validation + fn extract_jwt_kid(token: &str) -> Option { + let parts: Vec<&str> = token.splitn(3, '.').collect(); + if parts.len() < 2 { + return None; + } + use base64::Engine; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header_bytes = engine.decode(parts[0]).ok()?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; + header.get("kid").and_then(|v| v.as_str()).map(|s| s.to_string()) + } +} + +#[async_trait] +impl OidcServicePort for OidcService { + fn get_authorize_url(&self, state: &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 = { + let cache = self.discovery.read().map_err(|_| DomainError::new( + ErrorKind::InternalError, "OIDC", "Lock poisoned", + ))?; + match &*cache { + Some(disc) => disc.authorization_endpoint.clone(), + None => { + // Fallback: construct typical endpoint + let issuer = self.config.issuer_url.trim_end_matches('/'); + format!("{}/authorize", issuer) + } + } + }; + + let scopes = self.config.scopes.replace(',', " "); + let url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}", + auth_endpoint, + urlencoding::encode(&self.config.client_id), + urlencoding::encode(&self.config.redirect_uri), + urlencoding::encode(&scopes), + urlencoding::encode(state), + ); + + Ok(url) + } + + async fn exchange_code(&self, code: &str) -> Result { + let discovery = self.get_discovery().await?; + + tracing::debug!("Exchanging authorization code at: {}", discovery.token_endpoint); + + let resp = self.http_client.post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &self.config.redirect_uri), + ("client_id", &self.config.client_id), + ("client_secret", &self.config.client_secret), + ]) + .send() + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Token exchange failed: {}", e), + ))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::error!("OIDC token exchange error: status={}, body={}", status, body); + return Err(DomainError::new( + ErrorKind::AccessDenied, "OIDC", + format!("Token exchange failed with status {}", status), + )); + } + + let token_resp: TokenResponse = resp.json().await.map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to parse token response: {}", e), + ))?; + + let id_token = token_resp.id_token.ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", + "No id_token in token response", + ))?; + + Ok(OidcTokenSet { + access_token: token_resp.access_token, + id_token, + refresh_token: token_resp.refresh_token, + }) + } + + async fn validate_id_token(&self, id_token: &str) -> Result { + let jwks = self.get_jwks().await?; + let discovery = self.get_discovery().await?; + + // Extract kid from JWT header + let kid = Self::extract_jwt_kid(id_token); + + // Find the matching RSA key + let jwk = Self::find_rsa_key(&jwks, kid.as_deref()).ok_or_else(|| DomainError::new( + ErrorKind::AccessDenied, "OIDC", + "No suitable RSA key found in JWKS for ID token validation", + ))?; + + let n = jwk.n.as_ref().ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", "JWKS key missing 'n' component", + ))?; + let e = jwk.e.as_ref().ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", "JWKS key missing 'e' component", + ))?; + + // Build decoding key from RSA components + let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e) + .map_err(|err| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to build RSA decoding key: {}", err), + ))?; + + // Determine algorithm from JWKS (default RS256) + let alg = match jwk.alg.as_deref() { + Some("RS384") => jsonwebtoken::Algorithm::RS384, + Some("RS512") => jsonwebtoken::Algorithm::RS512, + _ => jsonwebtoken::Algorithm::RS256, + }; + + // Build validation: check expiry and issuer + let mut validation = jsonwebtoken::Validation::new(alg); + validation.set_issuer(&[&discovery.issuer]); + validation.set_audience(&[&self.config.client_id]); + + let token_data = jsonwebtoken::decode::( + id_token, + &decoding_key, + &validation, + ).map_err(|e| { + tracing::warn!("OIDC ID token validation failed: {}", e); + DomainError::new( + ErrorKind::AccessDenied, "OIDC", + format!("ID token validation failed: {}", e), + ) + })?; + + let claims = token_data.claims; + + Ok(OidcIdClaims { + sub: claims.sub, + email: claims.email, + preferred_username: claims.preferred_username, + name: claims.name, + groups: claims.groups.unwrap_or_default(), + }) + } + + async fn fetch_user_info(&self, access_token: &str) -> Result { + let discovery = self.get_discovery().await?; + + let userinfo_url = discovery.userinfo_endpoint.ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", + "No userinfo_endpoint in OIDC discovery", + ))?; + + let resp = self.http_client.get(&userinfo_url) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("UserInfo request failed: {}", e), + ))?; + + if !resp.status().is_success() { + return Err(DomainError::new( + ErrorKind::AccessDenied, "OIDC", + format!("UserInfo returned status {}", resp.status()), + )); + } + + let info: UserInfoResponse = resp.json().await.map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", + format!("Failed to parse UserInfo: {}", e), + ))?; + + Ok(OidcIdClaims { + sub: info.sub, + email: info.email, + preferred_username: info.preferred_username, + name: info.name, + groups: info.groups.unwrap_or_default(), + }) + } + + fn provider_name(&self) -> &str { + &self.config.provider_name + } +} + +// We need urlencoding — let's use a minimal inline implementation +mod urlencoding { + pub fn encode(input: &str) -> String { + let mut result = String::with_capacity(input.len() * 3); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(byte as char); + } + _ => { + result.push('%'); + result.push_str(&format!("{:02X}", byte)); + } + } + } + result + } +} diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b4b1694a..6cb95c77 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -2,14 +2,15 @@ use std::sync::Arc; use axum::{ Router, routing::{post, get, put}, - extract::{State, Json}, + extract::{State, Json, Query}, http::{StatusCode, HeaderMap, header}, - response::IntoResponse, + response::{IntoResponse, Redirect}, }; use crate::common::di::AppState; use crate::application::dtos::user_dto::{ - LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto + LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto, + OidcCallbackQueryDto, OidcProviderInfoDto, }; use crate::interfaces::errors::AppError; @@ -19,7 +20,11 @@ pub fn auth_routes() -> Router> { .route("/register", post(register)) .route("/login", post(login)) .route("/refresh", post(refresh_token)) - .route("/status", get(get_system_status)); + .route("/status", get(get_system_status)) + // OIDC endpoints (all public) + .route("/oidc/providers", get(oidc_providers)) + .route("/oidc/authorize", get(oidc_authorize)) + .route("/oidc/callback", get(oidc_callback)); // 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 @@ -141,6 +146,13 @@ async fn login( return Err(AppError::internal_error("Servicio de autenticación no configurado")); } }; + + // Check if password login is disabled (OIDC-only mode) + if auth_service.auth_application_service.password_login_disabled() { + return Err(AppError::unauthorized( + "Password login is disabled. Please use SSO/OIDC to sign in." + )); + } // Try the normal login process match auth_service.auth_application_service.login(dto.clone()).await { @@ -308,3 +320,106 @@ async fn get_system_status( Ok((StatusCode::OK, Json(status))) } + +// ============================================================================ +// OIDC Handlers +// ============================================================================ + +/// GET /api/auth/oidc/providers — Returns OIDC provider info for the UI +async fn oidc_providers( + State(state): State>, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let auth_app = &auth_service.auth_application_service; + + if !auth_app.oidc_enabled() { + return Ok(Json(OidcProviderInfoDto { + enabled: false, + provider_name: String::new(), + authorize_endpoint: String::new(), + password_login_enabled: true, + })); + } + + let config = auth_app.oidc_config().unwrap(); + + Ok(Json(OidcProviderInfoDto { + enabled: true, + provider_name: config.provider_name.clone(), + authorize_endpoint: "/api/auth/oidc/authorize".to_string(), + password_login_enabled: !config.disable_password_login, + })) +} + +/// GET /api/auth/oidc/authorize — Redirects user to the OIDC provider +async fn oidc_authorize( + State(state): State>, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let auth_app = &auth_service.auth_application_service; + + if !auth_app.oidc_enabled() { + return Err(AppError::new( + StatusCode::NOT_FOUND, + "OIDC is not enabled", + "OidcDisabled", + )); + } + + // Generate CSRF state + let state_token = auth_app.generate_oidc_state()?; + + // Build authorization URL + let authorize_url = auth_app.oidc_authorize_url(&state_token)?; + + tracing::info!("OIDC authorize redirect generated"); + + Ok(Redirect::temporary(&authorize_url)) +} + +/// GET /api/auth/oidc/callback?code=...&state=... — Handles OIDC callback +async fn oidc_callback( + State(state): State>, + Query(query): Query, +) -> Result { + let auth_service = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let auth_app = &auth_service.auth_application_service; + + if !auth_app.oidc_enabled() { + return Err(AppError::new( + StatusCode::NOT_FOUND, + "OIDC is not enabled", + "OidcDisabled", + )); + } + + tracing::info!("OIDC callback received with code"); + + // Exchange code and authenticate + let auth_response = auth_app.oidc_callback(&query.code).await + .map_err(|e| { + tracing::error!("OIDC callback failed: {}", e); + AppError::from(e) + })?; + + // Redirect to frontend with tokens as fragment + 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={}", + frontend_url, + auth_response.access_token, + auth_response.refresh_token, + auth_response.expires_in, + ); + + tracing::info!("OIDC login successful for user: {}", auth_response.user.username); + + Ok(Redirect::temporary(&redirect_url)) +}