From cf3d9bb16b5e42002db637bf33ff61822aad5bbe Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 13 Apr 2025 15:31:58 +0200 Subject: [PATCH] Add initial OIDC support #33 --- Cargo.lock | 369 +++++++++++++++++- Cargo.toml | 1 + README.md | 20 +- crates/frontend/Cargo.toml | 2 + .../public/templates/pages/login.html | 5 + crates/frontend/src/config.rs | 13 + crates/frontend/src/lib.rs | 13 + crates/frontend/src/oidc/mod.rs | 242 ++++++++++++ crates/frontend/src/routes/login.rs | 18 +- src/commands/mod.rs | 3 +- 10 files changed, 672 insertions(+), 14 deletions(-) create mode 100644 crates/frontend/src/oidc/mod.rs diff --git a/Cargo.lock b/Cargo.lock index e8a35b8..d86d1fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,12 +503,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -813,6 +825,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -833,6 +857,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -886,6 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -959,6 +1011,50 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -968,6 +1064,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1021,6 +1138,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "figment" version = "0.10.19" @@ -1175,6 +1308,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1226,6 +1360,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -1238,7 +1383,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", @@ -1257,13 +1402,19 @@ dependencies = [ "futures-core", "futures-sink", "http 1.3.1", - "indexmap", + "indexmap 2.9.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.2" @@ -1281,7 +1432,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1626,6 +1777,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.9.0" @@ -1633,7 +1795,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", + "serde", ] [[package]] @@ -1663,6 +1826,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1912,6 +2084,26 @@ dependencies = [ "libm", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.15", + "http 1.3.1", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -1933,6 +2125,37 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd50d4a5e7730e754f94d977efe61f611aadd3131f6a2b464f6e3a4167e8ef7" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 1.3.1", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "opentelemetry" version = "0.29.1" @@ -2017,12 +2240,45 @@ dependencies = [ "tracing", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2259,6 +2515,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -2307,7 +2572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -2562,6 +2827,16 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2809,7 +3084,7 @@ dependencies = [ "async-trait", "derive_more 2.0.1", "futures-util", - "itertools", + "itertools 0.14.0", "log", "quick-xml", "reqwest", @@ -2833,6 +3108,8 @@ dependencies = [ "futures-core", "hex", "mime_guess", + "openidconnect", + "reqwest", "rust-embed", "rustical_store", "serde", @@ -2997,6 +3274,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.26" @@ -3012,6 +3303,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.219" @@ -3035,6 +3336,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -3056,6 +3376,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3194,9 +3544,9 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.15.2", "hashlink", - "indexmap", + "indexmap 2.9.0", "log", "memchr", "once_cell", @@ -3630,7 +3980,7 @@ version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ - "indexmap", + "indexmap 2.9.0", "serde", "serde_spanned", "toml_datetime", @@ -3863,6 +4213,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ca51c9b..a8ae2f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ reqwest = { version = "0.12", features = [ "charset", "http2", ], default-features = false } +openidconnect = "4.0" [dependencies] rustical_store = { workspace = true } diff --git a/README.md b/README.md index ee09bec..bee79c5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ a CalDAV/CardDAV server - adequately fast (I'd say blazingly fastâ„¢ :fire: if I did the benchmarks to back that claim up) - deleted calendars are recoverable - Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token) +- experimental OpenID Connect support ## Installation @@ -121,6 +122,23 @@ Since push messages are currently not encrypted you might potentially want to en allowed_push_servers = ["https://your-instance-ntfy.sh"] ``` +### OpenID Connect + +There's experimental support to log in through an OIDC IdP. +Currently, the `preferred_username` is used as a user id (which is suboptimal, so you should be aware of that) and cannot be configured. + +```toml +[frontend.oidc] +name = "e.g. Authelia" +issuer = "https://auth.your.domain" +client_id = "rustical" +client_secret = "secret" +scopes = ["openid", "profile"] +allow_sign_up = false +``` + +On the IdP side you have to create a client with the redirect uri `/frontend/login/oidc/callback` (subject to change). + ## Debugging Set the log level with following environment variables: @@ -144,7 +162,7 @@ opentelemetry = true - provides the REPORT method - Calendaring Extensions to WebDAV (CalDAV): [RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791) - Scheduling Extensions to CalDAV: [RFC 6638](https://datatracker.ietf.org/doc/html/rfc6638) - - not sure yet whether to implement this + - not sur`e yet whether to implement this - Collection Synchronization WebDAV [RFC 6578](https://datatracker.ietf.org/doc/html/rfc6578) - We need to implement sync-token, etc. - This is important for more efficient synchronisation diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index 616ca8c..8a49fa1 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true publish = false [dependencies] +openidconnect.workspace = true askama.workspace = true askama_web.workspace = true actix-session = { workspace = true } @@ -19,3 +20,4 @@ rust-embed.workspace = true futures-core.workspace = true hex.workspace = true mime_guess.workspace = true +reqwest.workspace = true diff --git a/crates/frontend/public/templates/pages/login.html b/crates/frontend/public/templates/pages/login.html index 6d38165..c2ef5a1 100644 --- a/crates/frontend/public/templates/pages/login.html +++ b/crates/frontend/public/templates/pages/login.html @@ -9,4 +9,9 @@ + +{% if let Some(OidcProviderData {name, redirect_url}) = oidc_data %} +Login with {{ name }} +{% endif %} + {% endblock %} diff --git a/crates/frontend/src/config.rs b/crates/frontend/src/config.rs index 477f91c..8fb3581 100644 --- a/crates/frontend/src/config.rs +++ b/crates/frontend/src/config.rs @@ -1,9 +1,20 @@ +use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope}; use serde::{Deserialize, Serialize}; fn default_enabled() -> bool { true } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct OidcConfig { + pub name: String, + pub issuer: IssuerUrl, + pub client_id: ClientId, + pub client_secret: Option, + pub scopes: Vec, + pub allow_sign_up: bool, +} + #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(deny_unknown_fields)] pub struct FrontendConfig { @@ -12,4 +23,6 @@ pub struct FrontendConfig { pub secret_key: [u8; 64], #[serde(default = "default_enabled")] pub enabled: bool, + #[serde(default)] + pub oidc: Option, } diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 746a9a4..d0e7a69 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -12,6 +12,7 @@ use actix_web::{ use askama::Template; use askama_web::WebTemplate; use assets::{Assets, EmbedService}; +use oidc::{route_get_oidc, route_get_oidc_callback}; use routes::{ addressbook::{route_addressbook, route_addressbook_restore}, calendar::{route_calendar, route_calendar_restore}, @@ -25,6 +26,7 @@ use std::sync::Arc; mod assets; mod config; +mod oidc; mod routes; pub use config::FrontendConfig; @@ -130,6 +132,7 @@ pub fn configure_frontend::new("/assets".to_owned())) .service(web::resource("").route(web::method(Method::GET).to(route_root))) .service( @@ -158,6 +161,16 @@ pub fn configure_frontend)), + ) + .service( + web::resource("/login/oidc") + .name("frontend_login_oidc") + .route(web::method(Method::GET).to(route_get_oidc)), + ) + .service( + web::resource("/login/oidc/callback") + .name("frontend_oidc_callback") + .route(web::method(Method::GET).to(route_get_oidc_callback::)), ), ); } diff --git a/crates/frontend/src/oidc/mod.rs b/crates/frontend/src/oidc/mod.rs new file mode 100644 index 0000000..a2a73c2 --- /dev/null +++ b/crates/frontend/src/oidc/mod.rs @@ -0,0 +1,242 @@ +use crate::{FrontendConfig, config::OidcConfig}; +use actix_session::{Session, SessionInsertError}; +use actix_web::{ + HttpRequest, HttpResponse, Responder, ResponseError, + body::BoxBody, + error::UrlGenerationError, + http::StatusCode, + web::{Data, Query, Redirect}, +}; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClaimsVerificationError, ConfigurationError, CsrfToken, + EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse, + UserInfoClaims, + core::{CoreClient, CoreGenderClaim, CoreProviderMetadata, CoreResponseType}, + url::ParseError, +}; +use rustical_store::auth::{AuthenticationProvider, User, user::PrincipalType::Individual}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum OidcError { + #[error("Cannot generate redirect url, something's not configured correctly")] + OidcParseError(#[from] ParseError), + + #[error("Cannot generate redirect url, something's not configured correctly")] + ActixUrlGenerationError(#[from] UrlGenerationError), + + #[error("RustiCal is not configured correctly for OIDC")] + IncorrectConfiguration, + + #[error(transparent)] + OidcConfigurationError(#[from] ConfigurationError), + + #[error(transparent)] + OidcClaimsVerificationError(#[from] ClaimsVerificationError), + + #[error(transparent)] + SessionInsertError(#[from] SessionInsertError), + + #[error(transparent)] + StoreError(#[from] rustical_store::Error), + + #[error("{0}")] + Other(&'static str), +} + +impl ResponseError for OidcError { + fn status_code(&self) -> StatusCode { + StatusCode::INTERNAL_SERVER_ERROR + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).body(self.to_string()) + } +} + +pub(crate) struct OidcProviderData<'a> { + pub name: &'a str, + pub redirect_url: String, +} + +const SESSION_KEY_OIDC_STATE: &str = "oidc_state"; + +#[derive(Debug, Deserialize, Serialize)] +struct OidcState { + state: CsrfToken, + nonce: Nonce, + pkce_verifier: PkceCodeVerifier, +} + +fn get_http_client() -> reqwest::Client { + reqwest::ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("Something went wrong :(") +} + +async fn get_oidc_client( + OidcConfig { + issuer, + client_id, + client_secret, + .. + }: OidcConfig, + http_client: &reqwest::Client, + redirect_uri: RedirectUrl, +) -> Result< + CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, + >, + OidcError, +> { + let provider_metadata = CoreProviderMetadata::discover_async(issuer, http_client) + .await + .map_err(|_| OidcError::Other("Failed to discover OpenID provider"))?; + + Ok(CoreClient::from_provider_metadata( + provider_metadata.clone(), + client_id.clone(), + client_secret.clone(), + ) + .set_redirect_uri(redirect_uri)) +} + +/// Endpoint that redirects to the authorize endpoint of the OIDC service +pub async fn route_get_oidc( + req: HttpRequest, + config: Data, + session: Session, +) -> Result { + let oidc_config = config + .oidc + .clone() + .ok_or(OidcError::IncorrectConfiguration)?; + + let http_client = get_http_client(); + let oidc_client = get_oidc_client( + oidc_config.clone(), + &http_client, + RedirectUrl::new(req.url_for_static("frontend_oidc_callback")?.to_string())?, + ) + .await?; + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (auth_url, csrf_token, nonce) = oidc_client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scopes(oidc_config.scopes.clone()) + .set_pkce_challenge(pkce_challenge) + .url(); + + session.insert( + SESSION_KEY_OIDC_STATE, + OidcState { + state: csrf_token, + nonce, + pkce_verifier, + }, + )?; + + Ok(Redirect::to(auth_url.to_string()).see_other()) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AuthCallbackQuery { + code: AuthorizationCode, + iss: IssuerUrl, + // scope: String, + // state: String, +} + +pub async fn route_get_oidc_callback( + req: HttpRequest, + config: Data, + session: Session, + auth_provider: Data, + Query(AuthCallbackQuery { code, iss }): Query, +) -> Result { + let oidc_config = config + .oidc + .clone() + .ok_or(OidcError::IncorrectConfiguration)?; + assert_eq!(iss, oidc_config.issuer); + let oidc_state = session + .remove_as::(SESSION_KEY_OIDC_STATE) + .ok_or(OidcError::Other("No local OIDC state"))? + .map_err(|_| OidcError::Other("Error parsing OIDC state"))?; + + let http_client = get_http_client(); + let oidc_client = get_oidc_client( + oidc_config.clone(), + &http_client, + RedirectUrl::new(req.url_for_static("frontend_oidc_callback")?.to_string())?, + ) + .await?; + + let token_response = oidc_client + .exchange_code(code)? + .set_pkce_verifier(oidc_state.pkce_verifier) + .request_async(&http_client) + .await + .map_err(|_| OidcError::Other("Error requesting token"))?; + let id_claims = token_response + .id_token() + .ok_or(OidcError::Other("OIDC provider did not return an ID token"))? + .claims(&oidc_client.id_token_verifier(), &oidc_state.nonce)?; + + let user_info_claims: UserInfoClaims = oidc_client + .user_info( + token_response.access_token().clone(), + Some(id_claims.subject().clone()), + )? + .request_async(&http_client) + .await + .map_err(|_| OidcError::Other("Error fetching user info"))?; + + let user_id = user_info_claims + .preferred_username() + .ok_or(OidcError::Other("Missing preferred_username claim"))? + .to_string(); + + let mut user = auth_provider.get_principal(&user_id).await?; + if user.is_none() { + let new_user = User { + id: user_id, + displayname: None, + app_tokens: vec![], + password: None, + principal_type: Individual, + memberships: vec![], + }; + + auth_provider.insert_principal(new_user.clone()).await?; + user = Some(new_user); + } + + // Complete login flow + if let Some(user) = user { + session.insert("user", user.id.clone())?; + + Ok( + Redirect::to(req.url_for("frontend_user", &[user.id])?.to_string()) + .temporary() + .respond_to(&req) + .map_into_boxed_body(), + ) + } else { + // Add user provisioning + Ok(HttpResponse::build(StatusCode::UNAUTHORIZED).body("User does not exist")) + } +} diff --git a/crates/frontend/src/routes/login.rs b/crates/frontend/src/routes/login.rs index e99422c..ee24925 100644 --- a/crates/frontend/src/routes/login.rs +++ b/crates/frontend/src/routes/login.rs @@ -1,3 +1,4 @@ +use crate::{FrontendConfig, oidc::OidcProviderData}; use actix_session::Session; use actix_web::{ HttpRequest, HttpResponse, Responder, @@ -11,10 +12,21 @@ use serde::Deserialize; #[derive(Template, WebTemplate)] #[template(path = "pages/login.html")] -struct LoginPage; +struct LoginPage<'a> { + oidc_data: Option>, +} -pub async fn route_get_login() -> impl Responder { - LoginPage +pub async fn route_get_login(req: HttpRequest, config: Data) -> impl Responder { + LoginPage { + oidc_data: config.oidc.as_ref().map(|oidc| OidcProviderData { + name: &oidc.name, + redirect_url: req + .url_for_static("frontend_login_oidc") + .unwrap() + .to_string(), + }), + } + .respond_to(&req) } #[derive(Deserialize)] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b89a150..d179d32 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,7 +2,7 @@ use argon2::password_hash::SaltString; use clap::{Parser, ValueEnum}; use password_hash::PasswordHasher; use pbkdf2::Params; -use rand::{rngs::OsRng, RngCore}; +use rand::{RngCore, rngs::OsRng}; use rustical_frontend::FrontendConfig; use rustical_store::auth::TomlUserStoreConfig; @@ -35,6 +35,7 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { frontend: FrontendConfig { secret_key: generate_frontend_secret(), enabled: true, + oidc: None, }, dav_push: DavPushConfig::default(), nextcloud_login: Default::default(),