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
This commit is contained in:
Generated
+252
-15
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -75,3 +75,40 @@ pub struct CurrentUser {
|
||||
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<String>,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
@@ -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<User, DomainError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
/// Claims extracted from the validated OIDC ID token
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcIdClaims {
|
||||
pub sub: String,
|
||||
pub email: Option<String>,
|
||||
pub preferred_username: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String, DomainError>;
|
||||
|
||||
/// Exchange an authorization code for tokens
|
||||
async fn exchange_code(&self, code: &str) -> Result<OidcTokenSet, DomainError>;
|
||||
|
||||
/// Validate an ID token and extract claims
|
||||
async fn validate_id_token(&self, id_token: &str) -> Result<OidcIdClaims, DomainError>;
|
||||
|
||||
/// Fetch user info from the UserInfo endpoint (fallback for missing ID token claims)
|
||||
async fn fetch_user_info(&self, access_token: &str) -> Result<OidcIdClaims, DomainError>;
|
||||
|
||||
/// Get the OIDC provider display name
|
||||
fn provider_name(&self) -> &str;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -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<dyn UserStoragePort>,
|
||||
@@ -13,6 +14,8 @@ pub struct AuthApplicationService {
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
folder_service: Option<Arc<dyn FolderUseCase>>,
|
||||
oidc_service: Option<Arc<dyn OidcServicePort>>,
|
||||
oidc_config: Option<OidcConfig>,
|
||||
}
|
||||
|
||||
impl AuthApplicationService {
|
||||
@@ -28,6 +31,8 @@ impl AuthApplicationService {
|
||||
password_hasher,
|
||||
token_service,
|
||||
folder_service: None,
|
||||
oidc_service: None,
|
||||
oidc_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +42,33 @@ impl AuthApplicationService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Configura el servicio OIDC
|
||||
pub fn with_oidc(mut self, oidc_service: Arc<dyn OidcServicePort>, 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<dyn OidcServicePort>> {
|
||||
self.oidc_service.as_ref()
|
||||
}
|
||||
|
||||
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
|
||||
// Verificar usuario duplicado
|
||||
if self.user_storage.get_user_by_username(&dto.username).await.is_ok() {
|
||||
@@ -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<String, DomainError> {
|
||||
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<String, DomainError> {
|
||||
// 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<AuthResponseDto, DomainError> {
|
||||
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::<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<bool>().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::<bool>().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::<bool>().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
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct User {
|
||||
updated_at: DateTime<Utc>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
active: bool,
|
||||
oidc_provider: Option<String>,
|
||||
oidc_subject: Option<String>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
active: bool,
|
||||
oidc_provider: Option<String>,
|
||||
oidc_subject: Option<String>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +241,19 @@ impl User {
|
||||
&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.
|
||||
///
|
||||
/// The new password should be hashed externally using PasswordHasherPort
|
||||
|
||||
@@ -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<User>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -43,6 +44,19 @@ pub async fn create_auth_services(
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
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<String> = 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<User, DomainError> {
|
||||
UserRepository::get_user_by_oidc_subject(self, provider, subject)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@ pub mod write_behind_cache;
|
||||
pub mod chunked_upload_service;
|
||||
pub mod image_transcode_service;
|
||||
pub mod dedup_service;
|
||||
pub mod oidc_service;
|
||||
@@ -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<String>,
|
||||
jwks_uri: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWKS structures for RS256 validation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct JwksDocument {
|
||||
keys: Vec<JwkKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct JwkKey {
|
||||
kty: String,
|
||||
#[serde(rename = "use")]
|
||||
key_use: Option<String>,
|
||||
kid: Option<String>,
|
||||
alg: Option<String>,
|
||||
n: Option<String>, // RSA modulus (base64url)
|
||||
e: Option<String>, // RSA exponent (base64url)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token exchange response
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
id_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
token_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ID token claims (standard OIDC)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IdTokenClaims {
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
// Standard JWT fields
|
||||
#[allow(dead_code)]
|
||||
iss: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
aud: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
exp: Option<i64>,
|
||||
#[allow(dead_code)]
|
||||
iat: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UserInfo response
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserInfoResponse {
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Service
|
||||
// ============================================================================
|
||||
|
||||
pub struct OidcService {
|
||||
config: OidcConfig,
|
||||
http_client: reqwest::Client,
|
||||
/// Cached discovery document
|
||||
discovery: RwLock<Option<OidcDiscovery>>,
|
||||
/// Cached JWKS
|
||||
jwks: RwLock<Option<JwksDocument>>,
|
||||
}
|
||||
|
||||
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<OidcDiscovery, DomainError> {
|
||||
// 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<JwksDocument, DomainError> {
|
||||
// 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<String> {
|
||||
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<String, DomainError> {
|
||||
// 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<OidcTokenSet, DomainError> {
|
||||
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<OidcIdClaims, DomainError> {
|
||||
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::<IdTokenClaims>(
|
||||
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<OidcIdClaims, DomainError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Arc<AppState>> {
|
||||
.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
|
||||
@@ -142,6 +147,13 @@ async fn login(
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
Ok(auth_response) => {
|
||||
@@ -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<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
Query(query): Query<OidcCallbackQueryDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user