From 5f4201428ad955d82bce8b9ef11ce05f72428a84 Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Sat, 9 Aug 2025 00:31:35 +0200 Subject: [PATCH] oidc authentication --- backend/Cargo.lock | 519 +++++++++++++++++- backend/Cargo.toml | 2 + .../migrations/20250808160437_users_oidc.sql | 2 + backend/src/bin/backend/main.rs | 17 +- backend/src/lib/domain/mod.rs | 1 + backend/src/lib/domain/oidc/mod.rs | 4 + backend/src/lib/domain/oidc/models.rs | 69 +++ backend/src/lib/domain/oidc/ports.rs | 39 ++ backend/src/lib/domain/oidc/requests.rs | 74 +++ backend/src/lib/domain/oidc/service.rs | 73 +++ .../src/lib/domain/warren/models/user/mod.rs | 12 +- .../models/user/requests/create_or_update.rs | 62 +++ .../models/user/requests/get_oidc_redirect.rs | 43 ++ .../warren/models/user/requests/login_oidc.rs | 71 +++ .../domain/warren/models/user/requests/mod.rs | 6 + .../models/user/requests/verify_password.rs | 2 + .../src/lib/domain/warren/ports/metrics.rs | 3 + backend/src/lib/domain/warren/ports/mod.rs | 17 +- .../src/lib/domain/warren/ports/notifier.rs | 6 +- .../src/lib/domain/warren/ports/repository.rs | 12 +- backend/src/lib/domain/warren/service/auth.rs | 137 +++-- backend/src/lib/inbound/http/errors.rs | 34 +- .../lib/inbound/http/handlers/auth/login.rs | 1 + .../src/lib/inbound/http/handlers/auth/mod.rs | 7 + .../inbound/http/handlers/auth/oidc_login.rs | 62 +++ .../http/handlers/auth/oidc_redirect.rs | 23 + .../src/lib/outbound/metrics_debug_logger.rs | 36 +- backend/src/lib/outbound/mod.rs | 1 + .../src/lib/outbound/notifier_debug_logger.rs | 53 +- backend/src/lib/outbound/oidc.rs | 187 +++++++ backend/src/lib/outbound/postgres/auth.rs | 152 ++++- frontend/lib/api/auth/logout.ts | 1 + frontend/lib/api/auth/oidc.ts | 74 +++ frontend/pages/login.vue | 48 +- 34 files changed, 1766 insertions(+), 84 deletions(-) create mode 100644 backend/migrations/20250808160437_users_oidc.sql create mode 100644 backend/src/lib/domain/oidc/mod.rs create mode 100644 backend/src/lib/domain/oidc/models.rs create mode 100644 backend/src/lib/domain/oidc/ports.rs create mode 100644 backend/src/lib/domain/oidc/requests.rs create mode 100644 backend/src/lib/domain/oidc/service.rs create mode 100644 backend/src/lib/domain/warren/models/user/requests/create_or_update.rs create mode 100644 backend/src/lib/domain/warren/models/user/requests/get_oidc_redirect.rs create mode 100644 backend/src/lib/domain/warren/models/user/requests/login_oidc.rs create mode 100644 backend/src/lib/inbound/http/handlers/auth/oidc_login.rs create mode 100644 backend/src/lib/inbound/http/handlers/auth/oidc_redirect.rs create mode 100644 backend/src/lib/outbound/oidc.rs create mode 100644 frontend/lib/api/auth/oidc.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0b207d1..0b3fc15 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -186,6 +186,22 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "biscuit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e28fc7c56c61743a01d0d1b73e4fed68b8a4f032ea3a2d4bb8c6520a33fc05a" +dependencies = [ + "chrono", + "data-encoding", + "num-bigint", + "num-traits", + "once_cell", + "ring", + "serde", + "serde_json", +] + [[package]] name = "bitflags" version = "2.9.1" @@ -256,6 +272,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -275,6 +292,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -330,6 +357,47 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "der" version = "0.7.10" @@ -491,6 +559,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -602,7 +685,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -739,6 +834,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -747,14 +859,22 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -867,6 +987,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -909,6 +1035,22 @@ dependencies = [ "libc", ] +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1038,7 +1180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -1059,6 +1201,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1069,6 +1228,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1137,6 +1306,69 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25913f03f3d3bcc2e55021067193b31f79a27283c186c91ece5f93b69b035b44" +dependencies = [ + "base64", + "biscuit", + "chrono", + "lazy_static", + "mime", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", + "validator", +] + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "overload" version = "0.1.1" @@ -1261,6 +1493,28 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1279,6 +1533,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1306,7 +1566,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1347,6 +1607,56 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.12.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.8" @@ -1386,6 +1696,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1398,12 +1717,44 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.219" @@ -1430,6 +1781,7 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -1599,7 +1951,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.12", "time", "tokio", "tokio-stream", @@ -1684,7 +2036,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.12", "time", "tracing", "uuid", @@ -1724,7 +2076,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.12", "time", "tracing", "uuid", @@ -1751,7 +2103,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.12", "time", "tracing", "url", @@ -1775,6 +2127,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1797,6 +2155,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" @@ -1809,13 +2170,46 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1925,6 +2319,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -1980,12 +2384,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", @@ -2061,6 +2467,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.18.0" @@ -2106,6 +2518,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" @@ -2115,6 +2533,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2134,6 +2553,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2152,6 +2601,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "warren" version = "0.1.0" @@ -2168,18 +2626,20 @@ dependencies = [ "futures-util", "hex", "mime_guess", + "openid", "regex", "rustix", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.12", "tokio", "tokio-util", "tower", "tower-http", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -2189,6 +2649,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -2221,6 +2690,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -2253,6 +2735,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.0" @@ -2492,6 +2984,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + [[package]] name = "writeable" version = "0.6.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 886e374..c7c21dc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -24,6 +24,7 @@ dotenv = "0.15.0" futures-util = "0.3.31" hex = "0.4.3" mime_guess = "2.0.5" +openid = "0.17.0" regex = "1.11.1" rustix = { version = "1.0.8", features = ["fs"] } serde = { version = "1.0.219", features = ["derive"] } @@ -36,4 +37,5 @@ tower = "0.5.2" tower-http = { version = "0.6.6", features = ["cors", "fs", "trace"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" +url = "2.5.4" uuid = { version = "1.17.0", features = ["serde"] } diff --git a/backend/migrations/20250808160437_users_oidc.sql b/backend/migrations/20250808160437_users_oidc.sql new file mode 100644 index 0000000..86248d6 --- /dev/null +++ b/backend/migrations/20250808160437_users_oidc.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ALTER COLUMN hash DROP NOT NULL; +ALTER TABLE users ADD COLUMN oidc_sub VARCHAR UNIQUE; diff --git a/backend/src/bin/backend/main.rs b/backend/src/bin/backend/main.rs index edb190c..32c818c 100644 --- a/backend/src/bin/backend/main.rs +++ b/backend/src/bin/backend/main.rs @@ -6,6 +6,7 @@ use warren::{ file_system::{FileSystem, FileSystemConfig}, metrics_debug_logger::MetricsDebugLogger, notifier_debug_logger::NotifierDebugLogger, + oidc::{Oidc, OidcConfig}, postgres::{Postgres, PostgresConfig}, }, }; @@ -39,8 +40,20 @@ async fn main() -> anyhow::Result<()> { fs_service.clone(), ); - let auth_service = - domain::warren::service::auth::Service::new(postgres, metrics, notifier, config.auth); + let oidc_service = if let Ok(oidc_config) = OidcConfig::from_env() { + let repo = Oidc::new(&oidc_config).await?; + Some(domain::oidc::service::Service::new(repo, metrics, notifier)) + } else { + None + }; + + let auth_service = domain::warren::service::auth::Service::new( + postgres, + metrics, + notifier, + config.auth, + oidc_service, + ); let server_config = HttpServerConfig::new( &config.server_address, diff --git a/backend/src/lib/domain/mod.rs b/backend/src/lib/domain/mod.rs index 66a8f3b..b5d778b 100644 --- a/backend/src/lib/domain/mod.rs +++ b/backend/src/lib/domain/mod.rs @@ -1 +1,2 @@ +pub mod oidc; pub mod warren; diff --git a/backend/src/lib/domain/oidc/mod.rs b/backend/src/lib/domain/oidc/mod.rs new file mode 100644 index 0000000..1354180 --- /dev/null +++ b/backend/src/lib/domain/oidc/mod.rs @@ -0,0 +1,4 @@ +pub mod models; +pub mod ports; +pub mod requests; +pub mod service; diff --git a/backend/src/lib/domain/oidc/models.rs b/backend/src/lib/domain/oidc/models.rs new file mode 100644 index 0000000..66d7e91 --- /dev/null +++ b/backend/src/lib/domain/oidc/models.rs @@ -0,0 +1,69 @@ +use crate::domain::warren::models::user::{UserEmail, UserName}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct UserInfo { + sub: String, + name: UserName, + email: UserEmail, + preferred_username: Option, + picture: Option, + locale: Option, + updated_at: Option, + warren_admin: Option, +} + +impl UserInfo { + pub fn new( + sub: String, + name: UserName, + email: UserEmail, + preferred_username: Option, + picture: Option, + locale: Option, + updated_at: Option, + warren_admin: Option, + ) -> Self { + Self { + sub, + name, + email, + preferred_username, + picture, + locale, + updated_at, + warren_admin, + } + } + + pub fn sub(&self) -> &String { + &self.sub + } + + pub fn name(&self) -> &UserName { + &self.name + } + + pub fn email(&self) -> &UserEmail { + &self.email + } + + pub fn preferred_username(&self) -> Option<&UserName> { + self.preferred_username.as_ref() + } + + pub fn picture(&self) -> Option<&String> { + self.picture.as_ref() + } + + pub fn locale(&self) -> Option<&String> { + self.locale.as_ref() + } + + pub fn updated_at(&self) -> Option { + self.updated_at + } + + pub fn warren_admin(&self) -> Option { + self.warren_admin + } +} diff --git a/backend/src/lib/domain/oidc/ports.rs b/backend/src/lib/domain/oidc/ports.rs new file mode 100644 index 0000000..9979149 --- /dev/null +++ b/backend/src/lib/domain/oidc/ports.rs @@ -0,0 +1,39 @@ +use super::requests::{ + GetRedirectError, GetRedirectRequest, GetRedirectResponse, GetUserInfoError, + GetUserInfoRequest, GetUserInfoResponse, +}; + +pub trait OidcService: Clone + Send + Sync + 'static { + fn get_redirect( + &self, + request: GetRedirectRequest, + ) -> impl Future> + Send; + fn get_user_info( + &self, + request: GetUserInfoRequest, + ) -> impl Future> + Send; +} + +pub trait OidcRepository: Clone + Send + Sync + 'static { + fn get_redirect( + &self, + request: GetRedirectRequest, + ) -> impl Future> + Send; + fn get_user_info( + &self, + request: GetUserInfoRequest, + ) -> impl Future> + Send; +} + +pub trait OidcMetrics: Clone + Send + Sync + 'static { + fn record_get_redirect_success(&self) -> impl Future + Send; + fn record_get_redirect_failure(&self) -> impl Future + Send; + + fn record_get_user_info_success(&self) -> impl Future + Send; + fn record_get_user_info_failure(&self) -> impl Future + Send; +} + +pub trait OidcNotifier: Clone + Send + Sync + 'static { + fn get_redirect(&self, response: &GetRedirectResponse) -> impl Future + Send; + fn get_user_info(&self, response: &GetUserInfoResponse) -> impl Future + Send; +} diff --git a/backend/src/lib/domain/oidc/requests.rs b/backend/src/lib/domain/oidc/requests.rs new file mode 100644 index 0000000..d6d9e51 --- /dev/null +++ b/backend/src/lib/domain/oidc/requests.rs @@ -0,0 +1,74 @@ +use thiserror::Error; + +use super::models::UserInfo; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetRedirectRequest {} + +impl GetRedirectRequest { + pub fn new() -> Self { + Self {} + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetRedirectResponse { + url: String, +} + +impl GetRedirectResponse { + pub fn new(url: String) -> Self { + Self { url } + } + + pub fn url(&self) -> &String { + &self.url + } +} + +#[derive(Debug, Error)] +pub enum GetRedirectError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetUserInfoRequest { + code: String, + state: Option, +} + +impl GetUserInfoRequest { + pub fn new(code: String, state: Option) -> Self { + Self { code, state } + } + + pub fn code(&self) -> &String { + &self.code + } + + pub fn state(&self) -> Option<&String> { + self.state.as_ref() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetUserInfoResponse { + info: UserInfo, +} + +impl GetUserInfoResponse { + pub fn new(info: UserInfo) -> Self { + Self { info } + } + + pub fn info(&self) -> &UserInfo { + &self.info + } +} + +#[derive(Debug, Error)] +pub enum GetUserInfoError { + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/oidc/service.rs b/backend/src/lib/domain/oidc/service.rs new file mode 100644 index 0000000..d2ae5c1 --- /dev/null +++ b/backend/src/lib/domain/oidc/service.rs @@ -0,0 +1,73 @@ +use super::{ + ports::{OidcMetrics, OidcNotifier, OidcRepository, OidcService}, + requests::{ + GetRedirectError, GetRedirectRequest, GetRedirectResponse, GetUserInfoError, + GetUserInfoRequest, GetUserInfoResponse, + }, +}; + +#[derive(Debug, Clone)] +pub struct Service +where + R: OidcRepository, + M: OidcMetrics, + N: OidcNotifier, +{ + repository: R, + metrics: M, + notifier: N, +} + +impl Service +where + R: OidcRepository, + M: OidcMetrics, + N: OidcNotifier, +{ + pub fn new(repository: R, metrics: M, notifier: N) -> Self { + Self { + repository, + metrics, + notifier, + } + } +} + +impl OidcService for Service +where + R: OidcRepository, + M: OidcMetrics, + N: OidcNotifier, +{ + async fn get_redirect( + &self, + request: GetRedirectRequest, + ) -> Result { + let result = self.repository.get_redirect(request).await; + + if let Ok(response) = result.as_ref() { + self.metrics.record_get_redirect_success().await; + self.notifier.get_redirect(response).await; + } else { + self.metrics.record_get_redirect_failure().await; + } + + result + } + + async fn get_user_info( + &self, + request: GetUserInfoRequest, + ) -> Result { + let result = self.repository.get_user_info(request).await; + + if let Ok(response) = result.as_ref() { + self.metrics.record_get_user_info_success().await; + self.notifier.get_user_info(response).await; + } else { + self.metrics.record_get_user_info_failure().await; + } + + result + } +} diff --git a/backend/src/lib/domain/warren/models/user/mod.rs b/backend/src/lib/domain/warren/models/user/mod.rs index 3f4d3e4..43effc9 100644 --- a/backend/src/lib/domain/warren/models/user/mod.rs +++ b/backend/src/lib/domain/warren/models/user/mod.rs @@ -11,16 +11,21 @@ use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::FromRow)] pub struct User { + oidc_sub: Option, id: Uuid, name: UserName, email: UserEmail, - hash: String, + hash: Option, admin: bool, updated_at: NaiveDateTime, created_at: NaiveDateTime, } impl User { + pub fn oidc_sub(&self) -> Option<&String> { + self.oidc_sub.as_ref() + } + pub fn id(&self) -> &Uuid { &self.id } @@ -33,8 +38,8 @@ impl User { &self.email } - pub fn password_hash(&self) -> &str { - &self.hash + pub fn password_hash(&self) -> Option<&String> { + self.hash.as_ref() } pub fn admin(&self) -> bool { @@ -74,6 +79,7 @@ impl UserName { } /// A valid email +// TODO: Maybe move this somewhere else (emails are used here and for OIDC) #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display, sqlx::Type)] #[sqlx(transparent)] pub struct UserEmail(String); diff --git a/backend/src/lib/domain/warren/models/user/requests/create_or_update.rs b/backend/src/lib/domain/warren/models/user/requests/create_or_update.rs new file mode 100644 index 0000000..8c9e592 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/requests/create_or_update.rs @@ -0,0 +1,62 @@ +use thiserror::Error; + +use crate::domain::{ + oidc::models::UserInfo, + warren::models::user::{UserEmail, UserName}, +}; + +/// An admin request to create a new OIDC user or update an existing one if the `sub` already exists +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CreateOrUpdateUserOidcRequest { + sub: String, + name: UserName, + email: UserEmail, + admin: bool, +} + +impl CreateOrUpdateUserOidcRequest { + pub fn new(sub: String, name: UserName, email: UserEmail, admin: bool) -> Self { + Self { + sub, + name, + email, + admin, + } + } + + pub fn sub(&self) -> &String { + &self.sub + } + + pub fn name(&self) -> &UserName { + &self.name + } + + pub fn email(&self) -> &UserEmail { + &self.email + } + + pub fn admin(&self) -> bool { + self.admin + } +} + +impl From<&UserInfo> for CreateOrUpdateUserOidcRequest { + fn from(value: &UserInfo) -> Self { + let name = value.preferred_username().unwrap_or(value.name()).clone(); + Self::new( + value.sub().clone(), + name, + value.email().clone(), + value.warren_admin().unwrap_or(false), + ) + } +} + +#[derive(Debug, Error)] +pub enum CreateOrUpdateUserOidcError { + #[error("This email is already taken")] + EmailTaken, + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user/requests/get_oidc_redirect.rs b/backend/src/lib/domain/warren/models/user/requests/get_oidc_redirect.rs new file mode 100644 index 0000000..179d4e3 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/requests/get_oidc_redirect.rs @@ -0,0 +1,43 @@ +use thiserror::Error; + +use crate::domain::oidc::requests::GetRedirectError; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetOidcRedirectRequest {} + +impl GetOidcRedirectRequest { + pub fn new() -> Self { + Self {} + } +} + +impl From for crate::domain::oidc::requests::GetRedirectRequest { + fn from(_value: GetOidcRedirectRequest) -> Self { + Self::new() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GetOidcRedirectResponse { + url: String, +} + +impl GetOidcRedirectResponse { + pub fn new(url: String) -> Self { + Self { url } + } + + pub fn url(&self) -> &String { + &self.url + } +} + +#[derive(Debug, Error)] +pub enum GetOidcRedirectError { + #[error("OIDC is not enabled")] + Disabled, + #[error(transparent)] + GetRedirect(#[from] GetRedirectError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user/requests/login_oidc.rs b/backend/src/lib/domain/warren/models/user/requests/login_oidc.rs new file mode 100644 index 0000000..31031a6 --- /dev/null +++ b/backend/src/lib/domain/warren/models/user/requests/login_oidc.rs @@ -0,0 +1,71 @@ +use thiserror::Error; + +use crate::domain::{ + oidc::requests::{GetUserInfoError, GetUserInfoRequest}, + warren::models::{ + auth_session::{AuthSession, requests::CreateAuthSessionError}, + user::User, + }, +}; + +use super::CreateOrUpdateUserOidcError; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LoginUserOidcRequest { + pub(super) code: String, + pub(super) state: Option, +} + +impl LoginUserOidcRequest { + pub fn new(code: String, state: Option) -> Self { + Self { code, state } + } + + pub fn code(&self) -> &String { + &self.code + } + + pub fn state(&self) -> Option<&String> { + self.state.as_ref() + } +} + +impl From for GetUserInfoRequest { + fn from(value: LoginUserOidcRequest) -> Self { + GetUserInfoRequest::new(value.code, value.state) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LoginUserOidcResponse { + session: AuthSession, + user: User, +} + +impl LoginUserOidcResponse { + pub fn new(session: AuthSession, user: User) -> Self { + Self { session, user } + } + + pub fn session(&self) -> &AuthSession { + &self.session + } + + pub fn user(&self) -> &User { + &self.user + } +} + +#[derive(Debug, Error)] +pub enum LoginUserOidcError { + #[error("OIDC is not enabled")] + Disabled, + #[error(transparent)] + GetUserInfo(#[from] GetUserInfoError), + #[error(transparent)] + CreateOrUpdateUser(#[from] CreateOrUpdateUserOidcError), + #[error(transparent)] + CreateAuthToken(#[from] CreateAuthSessionError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/models/user/requests/mod.rs b/backend/src/lib/domain/warren/models/user/requests/mod.rs index 4e75ce1..e34e147 100644 --- a/backend/src/lib/domain/warren/models/user/requests/mod.rs +++ b/backend/src/lib/domain/warren/models/user/requests/mod.rs @@ -1,17 +1,23 @@ mod create; +mod create_or_update; mod delete; mod edit; +mod get_oidc_redirect; mod list; mod list_all; mod login; +mod login_oidc; mod register; mod verify_password; pub use create::*; +pub use create_or_update::*; pub use delete::*; pub use edit::*; +pub use get_oidc_redirect::*; pub use list::*; pub use list_all::*; pub use login::*; +pub use login_oidc::*; pub use register::*; pub use verify_password::*; diff --git a/backend/src/lib/domain/warren/models/user/requests/verify_password.rs b/backend/src/lib/domain/warren/models/user/requests/verify_password.rs index a71b0a4..d7336ef 100644 --- a/backend/src/lib/domain/warren/models/user/requests/verify_password.rs +++ b/backend/src/lib/domain/warren/models/user/requests/verify_password.rs @@ -35,6 +35,8 @@ impl From for VerifyUserPasswordRequest { #[derive(Debug, Error)] pub enum VerifyUserPasswordError { + #[error("This user does not use password authentication")] + PasswordNotAllowed, #[error("There is no user with this email: {0}")] NotFound(UserEmail), #[error("The password is incorrect")] diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index edf121e..e2c2a3e 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -86,6 +86,9 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static { fn record_user_login_success(&self) -> impl Future + Send; fn record_user_login_failure(&self) -> impl Future + Send; + fn record_user_login_oidc_success(&self) -> impl Future + Send; + fn record_user_login_oidc_failure(&self) -> impl Future + Send; + fn record_user_creation_success(&self) -> impl Future + Send; fn record_user_creation_failure(&self) -> impl Future + Send; diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 9437434..0b4febf 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -21,9 +21,11 @@ use super::models::{ }, user::{ CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, - EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, - ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, LoginUserError, - LoginUserRequest, LoginUserResponse, RegisterUserError, RegisterUserRequest, User, + EditUserRequest, GetOidcRedirectError, GetOidcRedirectRequest, GetOidcRedirectResponse, + ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, + ListUsersError, ListUsersRequest, LoginUserError, LoginUserOidcError, LoginUserOidcRequest, + LoginUserOidcResponse, LoginUserRequest, LoginUserResponse, RegisterUserError, + RegisterUserRequest, User, }, user_warren::{ UserWarren, @@ -141,6 +143,11 @@ pub trait AuthService: Clone + Send + Sync + 'static { warren_service: &WS, ) -> impl Future>> + Send; + fn get_oidc_redirect( + &self, + request: GetOidcRedirectRequest, + ) -> impl Future> + Send; + fn register_user( &self, request: RegisterUserRequest, @@ -149,6 +156,10 @@ pub trait AuthService: Clone + Send + Sync + 'static { &self, request: LoginUserRequest, ) -> impl Future> + Send; + fn login_user_oidc( + &self, + request: LoginUserOidcRequest, + ) -> impl Future> + Send; /// An action that creates a user (MUST REQUIRE ADMIN PRIVILEGES) fn create_user( diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index ad66ed4..8f8208e 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::domain::warren::models::{ auth_session::requests::FetchAuthSessionResponse, file::{AbsoluteFilePath, LsResponse}, - user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, + user::{ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User}, user_warren::UserWarren, warren::{ Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse, @@ -81,6 +81,10 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static { fn user_registered(&self, user: &User) -> impl Future + Send; fn user_logged_in(&self, response: &LoginUserResponse) -> impl Future + Send; + fn user_logged_in_oidc( + &self, + response: &LoginUserOidcResponse, + ) -> impl Future + Send; fn user_created(&self, creator: &User, created: &User) -> impl Future + Send; fn user_edited(&self, editor: &User, edited: &User) -> impl Future + Send; fn user_deleted(&self, deleter: &User, user: &User) -> impl Future + Send; diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index 1e10375..3dc27e2 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -12,10 +12,10 @@ use crate::domain::warren::models::{ SaveRequest, SaveResponse, TouchError, TouchRequest, }, user::{ - CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, - EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, - ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, User, - VerifyUserPasswordError, VerifyUserPasswordRequest, + CreateOrUpdateUserOidcError, CreateOrUpdateUserOidcRequest, CreateUserError, + CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, EditUserRequest, + ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, + ListUsersError, ListUsersRequest, User, VerifyUserPasswordError, VerifyUserPasswordRequest, }, user_warren::{ UserWarren, @@ -81,6 +81,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static { } pub trait AuthRepository: Clone + Send + Sync + 'static { + fn create_or_update_user_oidc( + &self, + request: CreateOrUpdateUserOidcRequest, + ) -> impl Future> + Send; fn create_user( &self, request: CreateUserRequest, diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index f48fa38..150aaa1 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -1,42 +1,47 @@ use crate::{ config::Config, - domain::warren::{ - models::{ - auth_session::{ - AuthError, AuthRequest, AuthSession, - requests::{ - CreateAuthSessionError, CreateAuthSessionRequest, FetchAuthSessionError, - FetchAuthSessionRequest, FetchAuthSessionResponse, SessionExpirationTime, + domain::{ + oidc::ports::OidcService, + warren::{ + models::{ + auth_session::{ + AuthError, AuthRequest, AuthSession, + requests::{ + CreateAuthSessionError, CreateAuthSessionRequest, FetchAuthSessionError, + FetchAuthSessionRequest, FetchAuthSessionResponse, SessionExpirationTime, + }, + }, + file::FileStream, + user::{ + CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, + EditUserError, EditUserRequest, GetOidcRedirectError, GetOidcRedirectRequest, + GetOidcRedirectResponse, ListAllUsersAndWarrensError, + ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, ListUsersError, + ListUsersRequest, LoginUserError, LoginUserOidcError, LoginUserOidcRequest, + LoginUserOidcResponse, LoginUserRequest, LoginUserResponse, RegisterUserError, + RegisterUserRequest, User, + }, + user_warren::{ + UserWarren, + requests::{ + CreateUserWarrenError, CreateUserWarrenRequest, DeleteUserWarrenError, + DeleteUserWarrenRequest, EditUserWarrenError, EditUserWarrenRequest, + FetchUserWarrenRequest, FetchUserWarrensError, FetchUserWarrensRequest, + }, + }, + warren::{ + CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest, + EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest, + FetchWarrensRequest, Warren, WarrenCatError, WarrenCatRequest, WarrenCpError, + WarrenCpRequest, WarrenCpResponse, WarrenLsError, WarrenLsRequest, + WarrenLsResponse, WarrenMkdirError, WarrenMkdirRequest, WarrenMkdirResponse, + WarrenMvError, WarrenMvRequest, WarrenMvResponse, WarrenRmError, + WarrenRmRequest, WarrenRmResponse, WarrenSaveError, WarrenSaveRequest, + WarrenSaveResponse, WarrenTouchError, WarrenTouchRequest, WarrenTouchResponse, }, }, - file::FileStream, - user::{ - CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, - EditUserError, EditUserRequest, ListAllUsersAndWarrensError, - ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, ListUsersError, - ListUsersRequest, LoginUserError, LoginUserRequest, LoginUserResponse, - RegisterUserError, RegisterUserRequest, User, - }, - user_warren::{ - UserWarren, - requests::{ - CreateUserWarrenError, CreateUserWarrenRequest, DeleteUserWarrenError, - DeleteUserWarrenRequest, EditUserWarrenError, EditUserWarrenRequest, - FetchUserWarrenRequest, FetchUserWarrensError, FetchUserWarrensRequest, - }, - }, - warren::{ - CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest, - EditWarrenError, EditWarrenRequest, FetchWarrenError, FetchWarrenRequest, - FetchWarrensRequest, Warren, WarrenCatError, WarrenCatRequest, WarrenCpError, - WarrenCpRequest, WarrenCpResponse, WarrenLsError, WarrenLsRequest, - WarrenLsResponse, WarrenMkdirError, WarrenMkdirRequest, WarrenMkdirResponse, - WarrenMvError, WarrenMvRequest, WarrenMvResponse, WarrenRmError, WarrenRmRequest, - WarrenRmResponse, WarrenSaveError, WarrenSaveRequest, WarrenSaveResponse, - WarrenTouchError, WarrenTouchRequest, WarrenTouchResponse, - }, + ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService}, }, - ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService}, }, }; @@ -45,7 +50,7 @@ const AUTH_SESSION_EXPIRATION_KEY: &str = "AUTH_SESSION_EXPIRATION"; /// The authentication service configuration /// /// * `session_lifetime`: The amount of milliseconds a client session is valid -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthConfig { session_lifetime: SessionExpirationTime, } @@ -69,39 +74,50 @@ impl AuthConfig { } #[derive(Debug, Clone)] -pub struct Service +pub struct Service where R: AuthRepository, M: AuthMetrics, N: AuthNotifier, + OIDC: OidcService, { repository: R, metrics: M, notifier: N, + oidc: Option, config: AuthConfig, } -impl Service +impl Service where R: AuthRepository, M: AuthMetrics, N: AuthNotifier, + OIDC: OidcService, { - pub fn new(repository: R, metrics: M, notifier: N, config: AuthConfig) -> Self { + pub fn new( + repository: R, + metrics: M, + notifier: N, + config: AuthConfig, + oidc: Option, + ) -> Self { Self { repository, metrics, notifier, config, + oidc, } } } -impl AuthService for Service +impl AuthService for Service where R: AuthRepository, M: AuthMetrics, N: AuthNotifier, + OIDC: OidcService, { async fn create_warren( &self, @@ -197,6 +213,18 @@ where result } + async fn get_oidc_redirect( + &self, + request: GetOidcRedirectRequest, + ) -> Result { + let oidc = self.oidc.as_ref().ok_or(GetOidcRedirectError::Disabled)?; + + oidc.get_redirect(request.into()) + .await + .map(|response| GetOidcRedirectResponse::new(response.url().clone())) + .map_err(Into::into) + } + async fn register_user(&self, request: RegisterUserRequest) -> Result { let result = self.repository.create_user(request.into()).await; @@ -238,6 +266,37 @@ where result.map_err(Into::into) } + async fn login_user_oidc( + &self, + request: LoginUserOidcRequest, + ) -> Result { + let oidc = self.oidc.as_ref().ok_or(LoginUserOidcError::Disabled)?; + + let user_info = oidc.get_user_info(request.into()).await?; + + let user = self + .repository + .create_or_update_user_oidc(user_info.info().into()) + .await?; + + let result = self + .create_auth_session(CreateAuthSessionRequest::new( + user.clone(), + self.config.session_lifetime(), + )) + .await + .map(|session| LoginUserOidcResponse::new(session, user)); + + if let Ok(response) = result.as_ref() { + self.metrics.record_user_login_oidc_success().await; + self.notifier.user_logged_in_oidc(response).await; + } else { + self.metrics.record_user_login_oidc_failure().await; + } + + result.map_err(Into::into) + } + async fn create_user( &self, request: AuthRequest, diff --git a/backend/src/lib/inbound/http/errors.rs b/backend/src/lib/inbound/http/errors.rs index ba1a04a..61d9191 100644 --- a/backend/src/lib/inbound/http/errors.rs +++ b/backend/src/lib/inbound/http/errors.rs @@ -2,7 +2,10 @@ use crate::{ domain::warren::models::{ auth_session::{AuthError, requests::FetchAuthSessionError}, file::{LsError, MkdirError, RmError}, - user::{CreateUserError, LoginUserError, RegisterUserError, VerifyUserPasswordError}, + user::{ + CreateUserError, GetOidcRedirectError, LoginUserError, LoginUserOidcError, + RegisterUserError, VerifyUserPasswordError, + }, user_warren::requests::FetchUserWarrenError, warren::{ FetchWarrenError, FetchWarrensError, WarrenLsError, WarrenMkdirError, WarrenMvError, @@ -117,6 +120,9 @@ impl From for ApiError { fn from(value: LoginUserError) -> Self { match value { LoginUserError::VerifyUser(e) => match e { + VerifyUserPasswordError::PasswordNotAllowed => { + Self::NotFound("This user does not use password authentication".to_string()) + } VerifyUserPasswordError::NotFound(_) => { Self::NotFound("Could not find a user with that email".to_string()) } @@ -131,6 +137,18 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(value: LoginUserOidcError) -> Self { + match value { + LoginUserOidcError::Disabled => Self::BadRequest("OIDC is disabled".to_string()), + LoginUserOidcError::CreateOrUpdateUser(e) => Self::InternalServerError(e.to_string()), + LoginUserOidcError::GetUserInfo(e) => Self::InternalServerError(e.to_string()), + LoginUserOidcError::CreateAuthToken(e) => Self::InternalServerError(e.to_string()), + LoginUserOidcError::Unknown(e) => Self::InternalServerError(e.to_string()), + } + } +} + impl From for ApiError { fn from(value: FetchAuthSessionError) -> Self { match value { @@ -177,3 +195,17 @@ impl From for ApiError { } } } + +impl From for ApiError { + fn from(value: GetOidcRedirectError) -> Self { + match value { + GetOidcRedirectError::GetRedirect(e) => match e { + crate::domain::oidc::requests::GetRedirectError::Unknown(e) => { + Self::InternalServerError(e.to_string()) + } + }, + GetOidcRedirectError::Disabled => Self::BadRequest("OIDC is disabled".to_string()), + GetOidcRedirectError::Unknown(e) => Self::InternalServerError(e.to_string()), + } + } +} diff --git a/backend/src/lib/inbound/http/handlers/auth/login.rs b/backend/src/lib/inbound/http/handlers/auth/login.rs index 319785b..db81def 100644 --- a/backend/src/lib/inbound/http/handlers/auth/login.rs +++ b/backend/src/lib/inbound/http/handlers/auth/login.rs @@ -68,6 +68,7 @@ impl LoginUserHttpRequestBody { } #[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub struct LoginResponseBody { token: String, user: UserData, diff --git a/backend/src/lib/inbound/http/handlers/auth/mod.rs b/backend/src/lib/inbound/http/handlers/auth/mod.rs index 959a1ca..956fb4c 100644 --- a/backend/src/lib/inbound/http/handlers/auth/mod.rs +++ b/backend/src/lib/inbound/http/handlers/auth/mod.rs @@ -1,8 +1,13 @@ mod fetch_session; mod login; +mod oidc_login; +mod oidc_redirect; mod register; + use fetch_session::fetch_session; use login::login; +use oidc_login::oidc_login; +use oidc_redirect::oidc_redirect; use register::register; use axum::{ @@ -20,4 +25,6 @@ pub fn routes() -> Router> .route("/register", post(register)) .route("/login", post(login)) .route("/session", get(fetch_session)) + .route("/oidc", get(oidc_redirect)) + .route("/oidc/login", get(oidc_login)) } diff --git a/backend/src/lib/inbound/http/handlers/auth/oidc_login.rs b/backend/src/lib/inbound/http/handlers/auth/oidc_login.rs new file mode 100644 index 0000000..ca60259 --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/auth/oidc_login.rs @@ -0,0 +1,62 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + domain::warren::{ + models::user::{LoginUserOidcRequest, LoginUserOidcResponse}, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + handlers::UserData, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OidcLoginRequestBody { + code: String, + state: Option, +} + +impl OidcLoginRequestBody { + pub fn into_domain(self) -> LoginUserOidcRequest { + LoginUserOidcRequest::new(self.code, self.state) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct OidcLoginResponseBody { + token: String, + user: UserData, + expires_at: i64, +} + +impl From for OidcLoginResponseBody { + fn from(value: LoginUserOidcResponse) -> Self { + Self { + token: value.session().session_id().to_string(), + user: value.user().to_owned().into(), + expires_at: value.session().expires_at().and_utc().timestamp_millis(), + } + } +} + +pub async fn oidc_login( + State(state): State>, + Query(request): Query, +) -> Result, ApiError> { + let domain_request = request.into_domain(); + + state + .auth_service + .login_user_oidc(domain_request) + .await + .map(|response| ApiSuccess::new(StatusCode::OK, response.into())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/inbound/http/handlers/auth/oidc_redirect.rs b/backend/src/lib/inbound/http/handlers/auth/oidc_redirect.rs new file mode 100644 index 0000000..de7b82a --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/auth/oidc_redirect.rs @@ -0,0 +1,23 @@ +use axum::{extract::State, http::StatusCode}; + +use crate::{ + domain::warren::{ + models::user::GetOidcRedirectRequest, + ports::{AuthService, WarrenService}, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +pub async fn oidc_redirect( + State(state): State>, +) -> Result, ApiError> { + state + .auth_service + .get_oidc_redirect(GetOidcRedirectRequest::new()) + .await + .map(|response| ApiSuccess::new(StatusCode::FOUND, response.url().clone())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index 24fac57..bdd1ef1 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -1,4 +1,7 @@ -use crate::domain::warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics}; +use crate::domain::{ + oidc::ports::OidcMetrics, + warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics}, +}; #[derive(Debug, Clone, Copy)] pub struct MetricsDebugLogger; @@ -175,17 +178,17 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] Warren creation by admin failed"); } - async fn record_auth_warren_edit_success(&self) -> () { + async fn record_auth_warren_edit_success(&self) { tracing::debug!("[Metrics] Warren edit by admin succeeded"); } - async fn record_auth_warren_edit_failure(&self) -> () { + async fn record_auth_warren_edit_failure(&self) { tracing::debug!("[Metrics] Warren edit by admin failed"); } - async fn record_auth_warren_deletion_success(&self) -> () { + async fn record_auth_warren_deletion_success(&self) { tracing::debug!("[Metrics] Warren deletion by admin succeeded"); } - async fn record_auth_warren_deletion_failure(&self) -> () { + async fn record_auth_warren_deletion_failure(&self) { tracing::debug!("[Metrics] Warren deletion by admin failed"); } @@ -203,6 +206,13 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] User login failed"); } + async fn record_user_login_oidc_success(&self) { + tracing::debug!("[Metrics] User login succeeded"); + } + async fn record_user_login_oidc_failure(&self) { + tracing::debug!("[Metrics] User login failed"); + } + async fn record_user_creation_success(&self) { tracing::debug!("[Metrics] User creation succeeded"); } @@ -350,3 +360,19 @@ impl AuthMetrics for MetricsDebugLogger { tracing::debug!("[Metrics] Auth warren cp failed"); } } + +impl OidcMetrics for MetricsDebugLogger { + async fn record_get_redirect_success(&self) { + tracing::debug!("[Metrics] OIDC get redirect succeeded"); + } + async fn record_get_redirect_failure(&self) { + tracing::debug!("[Metrics] OIDC get redirect failed"); + } + + async fn record_get_user_info_success(&self) { + tracing::debug!("[Metrics] OIDC get user info succeeded"); + } + async fn record_get_user_info_failure(&self) { + tracing::debug!("[Metrics] OIDC get user info failed"); + } +} diff --git a/backend/src/lib/outbound/mod.rs b/backend/src/lib/outbound/mod.rs index 14c3109..ae72e56 100644 --- a/backend/src/lib/outbound/mod.rs +++ b/backend/src/lib/outbound/mod.rs @@ -1,4 +1,5 @@ pub mod file_system; pub mod metrics_debug_logger; pub mod notifier_debug_logger; +pub mod oidc; pub mod postgres; diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 69667b7..4887359 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -1,17 +1,25 @@ use uuid::Uuid; -use crate::domain::warren::{ - models::{ - auth_session::requests::FetchAuthSessionResponse, - file::{AbsoluteFilePath, LsResponse}, - user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User}, - user_warren::UserWarren, - warren::{ - Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse, - WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse, - }, +use crate::domain::{ + oidc::{ + ports::OidcNotifier, + requests::{GetRedirectResponse, GetUserInfoResponse}, + }, + warren::{ + models::{ + auth_session::requests::FetchAuthSessionResponse, + file::{AbsoluteFilePath, LsResponse}, + user::{ + ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User, + }, + user_warren::UserWarren, + warren::{ + Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse, + WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse, + }, + }, + ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier}, }, - ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier}, }; #[derive(Debug, Clone, Copy)] @@ -220,6 +228,13 @@ impl AuthNotifier for NotifierDebugLogger { tracing::debug!("[Notifier] Logged in user {}", response.user().name()); } + async fn user_logged_in_oidc(&self, response: &LoginUserOidcResponse) { + tracing::debug!( + "[Notifier] Logged in user {} with OIDC", + response.user().name() + ); + } + async fn auth_session_created(&self, user_id: &Uuid) { tracing::debug!("[Notifier] Created auth session for user {}", user_id); } @@ -354,3 +369,19 @@ impl AuthNotifier for NotifierDebugLogger { ) } } + +impl OidcNotifier for NotifierDebugLogger { + async fn get_redirect(&self, response: &GetRedirectResponse) { + tracing::debug!("[Notifier] Got OIDC redirect: {}", response.url()); + } + async fn get_user_info(&self, response: &GetUserInfoResponse) { + tracing::debug!( + "[Notifier] Got OIDC user info: {} ({})", + response + .info() + .preferred_username() + .unwrap_or(response.info().name()), + response.info().sub(), + ); + } +} diff --git a/backend/src/lib/outbound/oidc.rs b/backend/src/lib/outbound/oidc.rs new file mode 100644 index 0000000..7ef8d02 --- /dev/null +++ b/backend/src/lib/outbound/oidc.rs @@ -0,0 +1,187 @@ +use std::{str::FromStr as _, sync::Arc}; + +use anyhow::Context as _; +use openid::{Client, CompactJson, CustomClaims, Discovered, Options, StandardClaims}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + config::Config, + domain::{ + oidc::{ + models::UserInfo, + ports::OidcRepository, + requests::{ + GetRedirectError, GetRedirectRequest, GetRedirectResponse, GetUserInfoError, + GetUserInfoRequest, GetUserInfoResponse, + }, + }, + warren::models::user::{UserEmail, UserName}, + }, +}; + +type OidcClient = Client; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcConfig { + issuer_url: String, + client_id: String, + client_secret: String, + redirect_url: String, + origin_url: String, +} + +impl OidcConfig { + pub fn from_env() -> anyhow::Result { + let issuer_url = Config::load_env("OIDC_ISSUER_URL")?; + let client_id = Config::load_env("OIDC_CLIENT_ID")?; + let client_secret = Config::load_env("OIDC_CLIENT_SECRET")?; + let origin = Config::load_env("OIDC_ORIGIN_URL")?; + let redirect_url = Config::load_env("OIDC_REDIRECT_URL")?; + + Ok(Self::new( + issuer_url, + client_id, + client_secret, + redirect_url, + origin, + )) + } + + pub fn new( + issuer_url: String, + client_id: String, + client_secret: String, + redirect_url: String, + origin_url: String, + ) -> Self { + Self { + issuer_url, + client_id, + client_secret, + redirect_url, + origin_url, + } + } +} + +#[derive(Debug, Clone)] +pub struct Oidc { + client: OidcClient, + + auth_options: Arc, +} + +impl Oidc { + pub async fn new(config: &OidcConfig) -> anyhow::Result { + let client = OidcClient::discover( + config.client_id.clone(), + config.client_secret.clone(), + config.redirect_url.clone(), + Url::from_str(&config.issuer_url)?, + ) + .await?; + + let auth_options = Options { + scope: Some("openid profile email".into()), + state: Some(config.origin_url.clone()), + ..Default::default() + }; + + Ok(Self { + client, + auth_options: Arc::new(auth_options), + }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Claims { + warren_admin: Option, + #[serde(flatten)] + standard_claims: StandardClaims, +} + +impl CustomClaims for Claims { + fn standard_claims(&self) -> &StandardClaims { + &self.standard_claims + } +} + +impl CompactJson for Claims {} + +impl OidcRepository for Oidc { + async fn get_redirect( + &self, + _request: GetRedirectRequest, + ) -> Result { + let url = self.client.auth_url(&self.auth_options); + + Ok(GetRedirectResponse::new(url.into())) + } + + async fn get_user_info( + &self, + request: GetUserInfoRequest, + ) -> Result { + let mut token: openid::Token = self + .client + .request_token(request.code()) + .await + .context("Failed to request token")? + .into(); + + let id_token = token + .id_token + .as_mut() + .context("Token didn't have id_token field")?; + + self.client + .decode_token(id_token) + .context("Failed to decode token")?; + + id_token + .payload_mut() + .context("Failed to get id_token payload")? + .standard_claims + .azp = Some(self.client.client_id.clone()); + + self.client + .validate_token(id_token, None, None) + .context("Failed to validate token")?; + + let user_info = try_get_user_info( + id_token + .payload() + .context("Failed to get id token payload")?, + )?; + + Ok(GetUserInfoResponse::new(user_info)) + } +} + +fn try_get_user_info(claims: &Claims) -> anyhow::Result { + let sub = claims.standard_claims.sub.clone(); + let raw = &claims.standard_claims.userinfo; + + let name = UserName::new(raw.name.as_ref().context("Missing name")?)?; + let email = UserEmail::new(raw.email.as_ref().context("Missing email")?)?; + let preferred_username = if let Some(preferred_username) = raw.preferred_username.as_ref() { + Some(UserName::new(preferred_username)?) + } else { + None + }; + + let user_info = UserInfo::new( + sub, + name, + email, + preferred_username, + raw.picture.as_ref().map(|url| url.clone().into()), + raw.locale.clone(), + raw.updated_at, + claims.warren_admin, + ); + + Ok(user_info) +} diff --git a/backend/src/lib/outbound/postgres/auth.rs b/backend/src/lib/outbound/postgres/auth.rs index 9a95062..6e6f375 100644 --- a/backend/src/lib/outbound/postgres/auth.rs +++ b/backend/src/lib/outbound/postgres/auth.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use anyhow::{Context as _, anyhow}; +use anyhow::{Context as _, anyhow, bail}; use argon2::{ Argon2, PasswordHash, PasswordVerifier as _, password_hash::{ @@ -23,8 +23,9 @@ use crate::domain::warren::{ }, }, user::{ - CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, - EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, + CreateOrUpdateUserOidcError, CreateOrUpdateUserOidcRequest, CreateUserError, + CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, EditUserRequest, + ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, User, UserEmail, UserName, UserPassword, VerifyUserPasswordError, VerifyUserPasswordRequest, }, @@ -66,6 +67,30 @@ impl AuthRepository for Postgres { Ok(user) } + async fn create_or_update_user_oidc( + &self, + request: CreateOrUpdateUserOidcRequest, + ) -> Result { + let mut connection = self + .pool + .acquire() + .await + .context("Failed to get a PostgreSQL connection")?; + + let user = self + .create_or_update_user( + &mut connection, + request.sub(), + request.name(), + request.email(), + request.admin(), + ) + .await + .context(format!("Failed to create or update user"))?; + + Ok(user) + } + async fn edit_user(&self, request: EditUserRequest) -> Result { let mut connection = self .pool @@ -127,7 +152,11 @@ impl AuthRepository for Postgres { } })?; - self.check_user_password_against_hash(request.password(), user.password_hash())?; + self.check_user_password_against_hash( + request.password(), + user.password_hash() + .ok_or(VerifyUserPasswordError::PasswordNotAllowed)?, + )?; Ok(user) } @@ -423,6 +452,121 @@ impl Postgres { Ok(user) } + async fn create_or_update_user( + &self, + connection: &mut PgConnection, + sub: &String, + name: &UserName, + email: &UserEmail, + is_admin: bool, + ) -> anyhow::Result { + let mut tx = connection.begin().await?; + + let existing_user: Option = sqlx::query_as( + " + SELECT + * + FROM + users + WHERE + oidc_sub = $1 OR + email = $2 + ", + ) + .bind(sub) + .bind(email) + .fetch_optional(&mut *tx) + .await?; + + let user: User = match existing_user { + Some(existing_user) => { + if let Some(existing_oidc_sub) = existing_user.oidc_sub() { + if existing_oidc_sub != sub { + // TODO: Return a proper error + bail!("The user is already linked to another OIDC subject"); + } + + sqlx::query_as( + " + UPDATE + users + SET + name = $3, + email = $4, + admin = $5 + WHERE + id = $1 AND + oidc_sub = $2 + RETURNING + * + ", + ) + .bind(existing_user.id()) + .bind(sub) + .bind(name) + .bind(email) + .bind(is_admin) + .fetch_one(&mut *tx) + .await? + } else { + sqlx::query_as( + " + UPDATE + users + SET + oidc_sub = $2, + name = $3, + email = $4, + admin = $5 + WHERE + id = $1 AND + oidc_sub IS NULL + RETURNING + * + ", + ) + .bind(existing_user.id()) + .bind(sub) + .bind(name) + .bind(email) + .bind(is_admin) + .fetch_one(&mut *tx) + .await? + } + } + None => { + sqlx::query_as( + " + INSERT INTO users ( + oidc_sub, + name, + email, + admin + ) + VALUES ( + $1, + $2, + $3, + $4 + ) + RETURNING + * + ", + ) + .bind(sub) + .bind(name) + .bind(email) + .bind(is_admin) + .fetch_one(&mut *tx) + .await? + } + }; + + tx.commit().await?; + + Ok(user) + } + async fn edit_user( &self, connection: &mut PgConnection, diff --git a/frontend/lib/api/auth/logout.ts b/frontend/lib/api/auth/logout.ts index 47044b9..13f6696 100644 --- a/frontend/lib/api/auth/logout.ts +++ b/frontend/lib/api/auth/logout.ts @@ -1,6 +1,7 @@ export async function logout() { useAuthSession().value = null; useAdminStore().$reset(); + useWarrenStore().$reset(); await navigateTo({ path: '/login', }); diff --git a/frontend/lib/api/auth/oidc.ts b/frontend/lib/api/auth/oidc.ts new file mode 100644 index 0000000..45d0890 --- /dev/null +++ b/frontend/lib/api/auth/oidc.ts @@ -0,0 +1,74 @@ +import type { ApiResponse } from '~/shared/types/api'; +import { getApiHeaders } from '..'; +import { toast } from 'vue-sonner'; +import type { AuthUser } from '~/shared/types/auth'; + +export async function getRedirectUrl(): Promise< + { success: true; url: string } | { success: false } +> { + const { data, error } = await useFetch>( + getApiUrl('auth/oidc'), + { + method: 'GET', + headers: getApiHeaders(false), + responseType: 'json', + } + ); + + if (data.value == null) { + toast.error('OpenID Connect', { + description: error.value?.data ?? 'Failed to get OIDC redirect', + }); + + return { + success: false, + }; + } + + return { + success: true, + url: data.value.data, + }; +} + +export async function oidcLoginUser( + code: string, + state: string +): Promise<{ success: boolean }> { + const { data, error } = await useFetch< + ApiResponse<{ token: string; user: AuthUser; expiresAt: number }> + >(getApiUrl(`auth/oidc/login?code=${code}&state=${state}`), { + method: 'GET', + headers: getApiHeaders(false), + responseType: 'json', + retry: false, + }); + + if (data.value == null) { + toast.error('OpenID Connect', { + description: error.value?.data ?? 'Failed to login with OIDC', + }); + + return { + success: false, + }; + } + + const token = data.value.data.token; + const { user, expiresAt } = data.value.data; + + useAuthSession().value = { + type: 'WarrenAuth', + id: token, + user, + expiresAt, + }; + + toast.success('OpenID Connect', { + description: `Successfully logged in`, + }); + + return { + success: true, + }; +} diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index e4053b3..a6a1275 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -10,6 +10,7 @@ import { import { toTypedSchema } from '@vee-validate/yup'; import { useForm } from 'vee-validate'; import { loginUser } from '~/lib/api/auth/login'; +import { getRedirectUrl, oidcLoginUser } from '~/lib/api/auth/oidc'; import { loginSchema } from '~/lib/schemas/auth'; definePageMeta({ @@ -20,10 +21,31 @@ useHead({ title: 'Login - Warren', }); +const route = useRoute(); + // TODO: Get this from the backend -const OPEN_ID = false; +const OPEN_ID = true; const loggingIn = ref(false); +if ( + route.query.code && + typeof route.query.code === 'string' && + route.query.state && + typeof route.query.state === 'string' +) { + console.log('SEND'); + loggingIn.value = true; + const { success } = await oidcLoginUser( + route.query.code, + route.query.state + ); + loggingIn.value = false; + + if (success) { + await navigateTo({ path: '/' }); + } +} + const form = useForm({ validationSchema: toTypedSchema(loginSchema), }); @@ -43,6 +65,24 @@ const onSubmit = form.handleSubmit(async (values) => { loggingIn.value = false; }); + +async function oidcClicked() { + if (loggingIn.value) { + return; + } + + loggingIn.value = true; + + const result = await getRedirectUrl(); + + if (result.success) { + await navigateTo(result.url, { + external: true, + }); + } else { + loggingIn.value = false; + } +}