diff --git a/Cargo.lock b/Cargo.lock index 3fcaa1d..428fd85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,19 +466,23 @@ dependencies = [ "async-trait", "attune-common", "axum", + "axum-extra", "chrono", "clap", "config", + "cookie", "flate2", "futures", "hex", "hmac", "jsonschema", + "jsonwebtoken", "mockall", + "openidconnect", "rand 0.10.0", "reqwest 0.13.2", "reqwest-eventsource", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "serde_yaml_ng", @@ -495,6 +499,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "utoipa", "utoipa-swagger-ui", "uuid", @@ -547,7 +552,7 @@ dependencies = [ "argon2", "async-recursion", "async-trait", - "base64", + "base64 0.22.1", "chrono", "config", "futures", @@ -559,7 +564,7 @@ dependencies = [ "regex", "reqwest 0.13.2", "ring", - "schemars", + "schemars 1.2.1", "semver", "serde", "serde_json", @@ -722,7 +727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -771,6 +776,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backon" version = "1.6.0" @@ -780,6 +808,18 @@ dependencies = [ "fastrand", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[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" @@ -1087,7 +1127,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1194,6 +1234,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "cookie-factory" version = "0.3.3" @@ -1232,7 +1283,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "chrono", "clap", "futures", @@ -1305,7 +1356,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", - "itertools", + "itertools 0.13.0", "num-traits", "oorandom", "page_size", @@ -1325,7 +1376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools", + "itertools 0.13.0", ] [[package]] @@ -1428,6 +1479,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[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.7" @@ -1448,14 +1511,51 @@ 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 0.2.17", + "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1472,13 +1572,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "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", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -1566,6 +1690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1594,7 +1719,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -1723,6 +1848,44 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1732,6 +1895,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 = "email_address" version = "0.2.9" @@ -1855,6 +2039,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 = "filetime" version = "0.2.27" @@ -2132,6 +2332,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2185,6 +2386,17 @@ dependencies = [ "polyval", ] +[[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.4.13" @@ -2197,7 +2409,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2215,6 +2427,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -2436,6 +2654,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -2444,7 +2663,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2455,7 +2674,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -2601,6 +2820,17 @@ dependencies = [ "icu_properties", ] +[[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.13.0" @@ -2657,6 +2887,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[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.13.0" @@ -2760,11 +2999,16 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "base64", + "base64 0.22.1", + "ed25519-dalek", "getrandom 0.2.17", "hmac", "js-sys", + "p256", + "p384", "pem", + "rand 0.8.5", + "rsa", "serde", "serde_json", "sha2", @@ -3211,6 +3455,26 @@ dependencies = [ "libc", ] +[[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.17", + "http", + "rand 0.8.5", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -3248,6 +3512,37 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "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 = "openssl" version = "0.10.75" @@ -3298,6 +3593,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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 = "ordered-multimap" version = "0.7.3" @@ -3320,7 +3624,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2" dependencies = [ - "base64", + "base64 0.22.1", "cbc", "cms", "der", @@ -3337,6 +3641,30 @@ dependencies = [ "x509-parser", ] +[[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 = "page_size" version = "0.6.0" @@ -3409,7 +3737,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3685,6 +4013,15 @@ dependencies = [ "syn", ] +[[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-error-attr2" version = "2.0.0" @@ -3729,7 +4066,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -3767,7 +4104,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -4024,7 +4361,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4032,16 +4369,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4051,6 +4393,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -4059,7 +4402,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -4120,6 +4463,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[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" @@ -4218,6 +4571,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -4370,6 +4732,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -4413,6 +4787,20 @@ dependencies = [ "sha2", ] +[[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 = "security-framework" version = "3.7.0" @@ -4468,6 +4856,16 @@ dependencies = [ "typeid", ] +[[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_core" version = "1.0.228" @@ -4523,6 +4921,15 @@ dependencies = [ "serde_core", ] +[[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 = "1.0.4" @@ -4544,13 +4951,44 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yaml_ng" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -4729,7 +5167,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -4742,7 +5180,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "once_cell", @@ -4806,7 +5244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "bytes", @@ -4850,7 +5288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -5581,6 +6019,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -5613,7 +6052,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_json", "utoipa-gen", @@ -5639,7 +6078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64", + "base64 0.22.1", "mime_guess", "regex", "rust-embed", @@ -5694,7 +6133,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", @@ -5870,7 +6309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -5909,7 +6348,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -5998,7 +6437,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6400,7 +6839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -6444,7 +6883,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -6475,7 +6914,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -6494,7 +6933,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -6677,7 +7116,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap", + "indexmap 2.13.0", "memchr", "zopfli", ] diff --git a/config.development.yaml b/config.development.yaml index f9972ca..5d6a059 100644 --- a/config.development.yaml +++ b/config.development.yaml @@ -47,6 +47,15 @@ security: encryption_key: test-encryption-key-32-chars-okay enable_auth: true allow_self_registration: true + oidc: + enabled: false + discovery_url: https://auth.rdrx.app/.well-known/openid-configuration + client_id: 31d194737840d32bd3afe6474826976bae346d77247a158c4dc43887278eb605 + client_secret: null + redirect_uri: http://localhost:3000/auth/callback + post_logout_redirect_uri: http://localhost:3000/login + scopes: + - groups # Packs directory (where pack action files are located) packs_base_dir: ./packs diff --git a/config.example.yaml b/config.example.yaml index 8845156..a5bbcd4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -86,6 +86,27 @@ security: # Enable authentication enable_auth: true + # Login page defaults for the web UI. Users can still override with: + # /login?auth=direct + # /login?auth= + login_page: + show_local_login: true + show_oidc_login: true + + # Optional OIDC browser login configuration + oidc: + enabled: false + discovery_url: https://auth.example.com/.well-known/openid-configuration + client_id: your-confidential-client-id + provider_name: sso + provider_label: Example SSO + provider_icon_url: https://auth.example.com/assets/logo.svg + client_secret: your-confidential-client-secret + redirect_uri: http://localhost:3000/auth/callback + post_logout_redirect_uri: http://localhost:3000/login + scopes: + - groups + # Worker configuration (optional, for worker services) # Uncomment and configure if running worker processes # worker: diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index a01a476..bc7b930 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -27,6 +27,8 @@ futures = { workspace = true } # Web framework axum = { workspace = true, features = ["multipart"] } +axum-extra = { version = "0.10", features = ["cookie"] } +cookie = "0.18" tower = { workspace = true } tower-http = { workspace = true } @@ -67,6 +69,8 @@ jsonschema = { workspace = true } # HTTP client reqwest = { workspace = true } +openidconnect = "4.0" +url = { workspace = true } # Archive/compression tar = { workspace = true } @@ -88,6 +92,7 @@ hex = "0.4" # OpenAPI/Swagger utoipa = { workspace = true, features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0", features = ["axum"] } +jsonwebtoken = { workspace = true, features = ["rust_crypto"] } [dev-dependencies] mockall = { workspace = true } diff --git a/crates/api/src/auth/middleware.rs b/crates/api/src/auth/middleware.rs index a1d7251..b18b820 100644 --- a/crates/api/src/auth/middleware.rs +++ b/crates/api/src/auth/middleware.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Request, State}, - http::{header::AUTHORIZATION, StatusCode}, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, Json, @@ -14,6 +14,8 @@ use attune_common::auth::jwt::{ extract_token_from_header, validate_token, Claims, JwtConfig, TokenType, }; +use super::oidc::{cookie_authenticated_user, ACCESS_COOKIE_NAME}; + /// Authentication middleware state #[derive(Clone)] pub struct AuthMiddleware { @@ -50,21 +52,7 @@ pub async fn require_auth( mut request: Request, next: Next, ) -> Result { - // Extract Authorization header - let auth_header = request - .headers() - .get(AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .ok_or(AuthError::MissingToken)?; - - // Extract token from Bearer scheme - let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?; - - // Validate token - let claims = validate_token(token, &auth.jwt_config).map_err(|e| match e { - super::jwt::JwtError::Expired => AuthError::ExpiredToken, - _ => AuthError::InvalidToken, - })?; + let claims = extract_claims(request.headers(), &auth.jwt_config)?; // Add claims to request extensions request @@ -90,22 +78,13 @@ impl axum::extract::FromRequestParts for RequireAuth return Ok(RequireAuth(user.clone())); } - // Otherwise, extract and validate token directly from header - // Extract Authorization header - let auth_header = parts - .headers - .get(AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .ok_or(AuthError::MissingToken)?; - - // Extract token from Bearer scheme - let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?; - - // Validate token using jwt_config from app state - let claims = validate_token(token, &state.jwt_config).map_err(|e| match e { - super::jwt::JwtError::Expired => AuthError::ExpiredToken, - _ => AuthError::InvalidToken, - })?; + let claims = if let Some(user) = + cookie_authenticated_user(&parts.headers, state).map_err(map_cookie_auth_error)? + { + user.claims + } else { + extract_claims(&parts.headers, &state.jwt_config)? + }; // Allow access, sensor, and execution-scoped tokens if claims.token_type != TokenType::Access @@ -119,6 +98,33 @@ impl axum::extract::FromRequestParts for RequireAuth } } +fn extract_claims(headers: &HeaderMap, jwt_config: &JwtConfig) -> Result { + if let Some(auth_header) = headers.get(AUTHORIZATION).and_then(|h| h.to_str().ok()) { + let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?; + return validate_token(token, jwt_config).map_err(|e| match e { + super::jwt::JwtError::Expired => AuthError::ExpiredToken, + _ => AuthError::InvalidToken, + }); + } + + if headers + .get(axum::http::header::COOKIE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|cookies| cookies.contains(ACCESS_COOKIE_NAME)) + { + return Err(AuthError::InvalidToken); + } + + Err(AuthError::MissingToken) +} + +fn map_cookie_auth_error(error: crate::middleware::error::ApiError) -> AuthError { + match error { + crate::middleware::error::ApiError::Unauthorized(_) => AuthError::InvalidToken, + _ => AuthError::InvalidToken, + } +} + /// Authentication errors #[derive(Debug)] pub enum AuthError { diff --git a/crates/api/src/auth/mod.rs b/crates/api/src/auth/mod.rs index a3ab4a3..0fa33a2 100644 --- a/crates/api/src/auth/mod.rs +++ b/crates/api/src/auth/mod.rs @@ -2,6 +2,7 @@ pub mod jwt; pub mod middleware; +pub mod oidc; pub mod password; pub use jwt::{generate_token, validate_token, Claims}; diff --git a/crates/api/src/auth/oidc.rs b/crates/api/src/auth/oidc.rs new file mode 100644 index 0000000..d508a7e --- /dev/null +++ b/crates/api/src/auth/oidc.rs @@ -0,0 +1,767 @@ +//! OpenID Connect helpers for browser login. + +use attune_common::{ + config::OidcConfig, + repositories::{ + identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput}, + Create, Update, + }, +}; +use axum::{ + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Redirect, Response}, +}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use cookie::time::Duration as CookieDuration; +use jsonwebtoken::{ + decode, decode_header, + jwk::{AlgorithmParameters, JwkSet}, + Algorithm, DecodingKey, Validation, +}; +use openidconnect::{ + core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims}, + reqwest::Client as OidcHttpClient, + AuthorizationCode, ClientId, ClientSecret, CsrfToken, LocalizedClaim, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, + TokenResponse as OidcTokenResponse, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as JsonValue}; +use sha2::{Digest, Sha256}; +use url::{form_urlencoded::byte_serialize, Url}; + +use crate::{ + auth::jwt::{generate_access_token, generate_refresh_token, validate_token}, + dto::{CurrentUserResponse, TokenResponse}, + middleware::error::ApiError, + state::SharedState, +}; + +pub const ACCESS_COOKIE_NAME: &str = "attune_access_token"; +pub const REFRESH_COOKIE_NAME: &str = "attune_refresh_token"; +pub const OIDC_ID_TOKEN_COOKIE_NAME: &str = "attune_oidc_id_token"; +pub const OIDC_STATE_COOKIE_NAME: &str = "attune_oidc_state"; +pub const OIDC_NONCE_COOKIE_NAME: &str = "attune_oidc_nonce"; +pub const OIDC_PKCE_COOKIE_NAME: &str = "attune_oidc_pkce_verifier"; +pub const OIDC_REDIRECT_COOKIE_NAME: &str = "attune_oidc_redirect_to"; + +const LOGIN_CALLBACK_PATH: &str = "/login/callback"; + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcDiscoveryDocument { + #[serde(flatten)] + pub metadata: CoreProviderMetadata, + #[serde(default)] + pub end_session_endpoint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OidcIdentityClaims { + pub issuer: String, + pub sub: String, + pub email: Option, + pub email_verified: Option, + pub name: Option, + pub preferred_username: Option, + pub groups: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct VerifiedIdTokenClaims { + iss: String, + sub: String, + #[serde(default)] + nonce: Option, + #[serde(default)] + email: Option, + #[serde(default)] + email_verified: Option, + #[serde(default)] + name: Option, + #[serde(default)] + preferred_username: Option, + #[serde(default)] + groups: Vec, +} + +#[derive(Debug, Clone)] +pub struct OidcAuthenticatedIdentity { + pub current_user: CurrentUserResponse, + pub token_response: TokenResponse, + pub id_token: String, +} + +#[derive(Debug, Clone)] +pub struct OidcLoginRedirect { + pub authorization_url: String, + pub cookies: Vec>, +} + +#[derive(Debug, Clone)] +pub struct OidcLogoutRedirect { + pub redirect_url: String, + pub cookies: Vec>, +} + +#[derive(Debug, Deserialize)] +pub struct OidcCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +pub async fn build_login_redirect( + state: &SharedState, + redirect_to: Option<&str>, +) -> Result { + let oidc = oidc_config(state)?; + let discovery = fetch_discovery_document(&oidc).await?; + let _http_client = OidcHttpClient::builder() + .redirect(openidconnect::reqwest::redirect::Policy::none()) + .build() + .map_err(|err| { + ApiError::InternalServerError(format!("Failed to build OIDC HTTP client: {err}")) + })?; + let redirect_uri = RedirectUrl::new(oidc.redirect_uri.clone()).map_err(|err| { + ApiError::InternalServerError(format!("Invalid OIDC redirect URI: {err}")) + })?; + let client_secret = oidc.client_secret.clone().ok_or_else(|| { + ApiError::InternalServerError("OIDC client secret is missing".to_string()) + })?; + let client = CoreClient::from_provider_metadata( + discovery.metadata.clone(), + ClientId::new(oidc.client_id.clone()), + Some(ClientSecret::new(client_secret)), + ) + .set_redirect_uri(redirect_uri); + + let redirect_target = sanitize_redirect_target(redirect_to); + let pkce = PkceCodeChallenge::new_random_sha256(); + let (auth_url, csrf_state, nonce) = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("openid".to_string())) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .add_scopes( + oidc.scopes + .iter() + .filter(|scope| !matches!(scope.as_str(), "openid" | "email" | "profile")) + .cloned() + .map(Scope::new), + ) + .set_pkce_challenge(pkce.0) + .url(); + + Ok(OidcLoginRedirect { + authorization_url: auth_url.to_string(), + cookies: vec![ + build_cookie( + state, + OIDC_STATE_COOKIE_NAME, + csrf_state.secret().to_string(), + 600, + true, + ), + build_cookie( + state, + OIDC_NONCE_COOKIE_NAME, + nonce.secret().to_string(), + 600, + true, + ), + build_cookie( + state, + OIDC_PKCE_COOKIE_NAME, + pkce.1.secret().to_string(), + 600, + true, + ), + build_cookie( + state, + OIDC_REDIRECT_COOKIE_NAME, + redirect_target, + 600, + false, + ), + ], + }) +} + +pub async fn handle_callback( + state: &SharedState, + headers: &HeaderMap, + query: &OidcCallbackQuery, +) -> Result { + if let Some(error) = &query.error { + let description = query + .error_description + .as_deref() + .unwrap_or("OpenID Connect login failed"); + return Err(ApiError::Unauthorized(format!("{error}: {description}"))); + } + + let code = query + .code + .as_ref() + .ok_or_else(|| ApiError::BadRequest("Missing authorization code".to_string()))?; + let returned_state = query + .state + .as_ref() + .ok_or_else(|| ApiError::BadRequest("Missing OIDC state".to_string()))?; + + let expected_state = get_cookie_value(headers, OIDC_STATE_COOKIE_NAME) + .ok_or_else(|| ApiError::Unauthorized("Missing OIDC state cookie".to_string()))?; + let expected_nonce = get_cookie_value(headers, OIDC_NONCE_COOKIE_NAME) + .ok_or_else(|| ApiError::Unauthorized("Missing OIDC nonce cookie".to_string()))?; + let pkce_verifier = get_cookie_value(headers, OIDC_PKCE_COOKIE_NAME) + .ok_or_else(|| ApiError::Unauthorized("Missing OIDC PKCE verifier cookie".to_string()))?; + + if returned_state != &expected_state { + return Err(ApiError::Unauthorized( + "OIDC state validation failed".to_string(), + )); + } + + let oidc = oidc_config(state)?; + let discovery = fetch_discovery_document(&oidc).await?; + let http_client = OidcHttpClient::builder() + .redirect(openidconnect::reqwest::redirect::Policy::none()) + .build() + .map_err(|err| { + ApiError::InternalServerError(format!("Failed to build OIDC HTTP client: {err}")) + })?; + let redirect_uri = RedirectUrl::new(oidc.redirect_uri.clone()).map_err(|err| { + ApiError::InternalServerError(format!("Invalid OIDC redirect URI: {err}")) + })?; + let client_secret = oidc.client_secret.clone().ok_or_else(|| { + ApiError::InternalServerError("OIDC client secret is missing".to_string()) + })?; + let client = CoreClient::from_provider_metadata( + discovery.metadata.clone(), + ClientId::new(oidc.client_id.clone()), + Some(ClientSecret::new(client_secret)), + ) + .set_redirect_uri(redirect_uri); + + let token_response = client + .exchange_code(AuthorizationCode::new(code.clone())) + .map_err(|err| { + ApiError::InternalServerError(format!("OIDC token request is misconfigured: {err}")) + })? + .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier)) + .request_async(&http_client) + .await + .map_err(|err| ApiError::Unauthorized(format!("OIDC token exchange failed: {err}")))?; + + let id_token = token_response.id_token().ok_or_else(|| { + ApiError::Unauthorized("OIDC provider did not return an ID token".to_string()) + })?; + + let raw_id_token = id_token.to_string(); + let claims = verify_id_token(&raw_id_token, &discovery, &oidc, &expected_nonce).await?; + + let mut oidc_claims = OidcIdentityClaims { + issuer: claims.iss, + sub: claims.sub, + email: claims.email, + email_verified: claims.email_verified, + name: claims.name, + preferred_username: claims.preferred_username, + groups: claims.groups, + }; + + if let Ok(userinfo_request) = client.user_info(token_response.access_token().to_owned(), None) { + if let Ok(userinfo) = userinfo_request.request_async(&http_client).await { + merge_userinfo_claims(&mut oidc_claims, &userinfo); + } + } + + let identity = upsert_identity(state, &oidc_claims).await?; + let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?; + let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; + + let token_response = TokenResponse::new( + access_token, + refresh_token, + state.jwt_config.access_token_expiration, + ) + .with_user( + identity.id, + identity.login.clone(), + identity.display_name.clone(), + ); + + Ok(OidcAuthenticatedIdentity { + current_user: CurrentUserResponse { + id: identity.id, + login: identity.login.clone(), + display_name: identity.display_name.clone(), + }, + id_token: raw_id_token, + token_response, + }) +} + +pub async fn build_logout_redirect( + state: &SharedState, + headers: &HeaderMap, +) -> Result { + let oidc = oidc_config(state)?; + let discovery = fetch_discovery_document(&oidc).await?; + let post_logout_redirect_uri = oidc + .post_logout_redirect_uri + .clone() + .unwrap_or_else(|| "/login".to_string()); + + let redirect_url = if let Some(end_session_endpoint) = discovery.end_session_endpoint { + let mut url = Url::parse(&end_session_endpoint).map_err(|err| { + ApiError::InternalServerError(format!("Invalid end_session_endpoint: {err}")) + })?; + { + let mut pairs = url.query_pairs_mut(); + if let Some(id_token_hint) = get_cookie_value(headers, OIDC_ID_TOKEN_COOKIE_NAME) { + pairs.append_pair("id_token_hint", &id_token_hint); + } + pairs.append_pair("post_logout_redirect_uri", &post_logout_redirect_uri); + pairs.append_pair("client_id", &oidc.client_id); + } + String::from(url) + } else { + post_logout_redirect_uri + }; + + Ok(OidcLogoutRedirect { + redirect_url, + cookies: clear_auth_cookies(state), + }) +} + +pub fn clear_auth_cookies(state: &SharedState) -> Vec> { + [ + ACCESS_COOKIE_NAME, + REFRESH_COOKIE_NAME, + OIDC_ID_TOKEN_COOKIE_NAME, + OIDC_STATE_COOKIE_NAME, + OIDC_NONCE_COOKIE_NAME, + OIDC_PKCE_COOKIE_NAME, + OIDC_REDIRECT_COOKIE_NAME, + ] + .into_iter() + .map(|name| remove_cookie(state, name)) + .collect() +} + +pub fn build_auth_cookies( + state: &SharedState, + token_response: &TokenResponse, + id_token: &str, +) -> Vec> { + let mut cookies = vec![ + build_cookie( + state, + ACCESS_COOKIE_NAME, + token_response.access_token.clone(), + state.jwt_config.access_token_expiration, + true, + ), + build_cookie( + state, + REFRESH_COOKIE_NAME, + token_response.refresh_token.clone(), + state.jwt_config.refresh_token_expiration, + true, + ), + ]; + + if !id_token.is_empty() { + cookies.push(build_cookie( + state, + OIDC_ID_TOKEN_COOKIE_NAME, + id_token.to_string(), + state.jwt_config.refresh_token_expiration, + true, + )); + } + + cookies +} + +pub fn apply_cookies_to_headers( + headers: &mut HeaderMap, + cookies: &[Cookie<'static>], +) -> Result<(), ApiError> { + for cookie in cookies { + let value = HeaderValue::from_str(&cookie.to_string()).map_err(|err| { + ApiError::InternalServerError(format!("Failed to serialize cookie header: {err}")) + })?; + headers.append(header::SET_COOKIE, value); + } + Ok(()) +} + +pub fn oidc_callback_redirect_response( + state: &SharedState, + token_response: &TokenResponse, + redirect_to: Option, + id_token: &str, +) -> Result { + let redirect_target = sanitize_redirect_target(redirect_to.as_deref()); + let redirect_url = format!( + "{LOGIN_CALLBACK_PATH}#access_token={}&refresh_token={}&expires_in={}&redirect_to={}", + encode_fragment_value(&token_response.access_token), + encode_fragment_value(&token_response.refresh_token), + token_response.expires_in, + encode_fragment_value(&redirect_target), + ); + + let mut response = Redirect::temporary(&redirect_url).into_response(); + let mut cookies = build_auth_cookies(state, token_response, id_token); + cookies.push(remove_cookie(state, OIDC_STATE_COOKIE_NAME)); + cookies.push(remove_cookie(state, OIDC_NONCE_COOKIE_NAME)); + cookies.push(remove_cookie(state, OIDC_PKCE_COOKIE_NAME)); + cookies.push(remove_cookie(state, OIDC_REDIRECT_COOKIE_NAME)); + apply_cookies_to_headers(response.headers_mut(), &cookies)?; + Ok(response) +} + +pub fn cookie_authenticated_user( + headers: &HeaderMap, + state: &SharedState, +) -> Result, ApiError> { + let Some(token) = get_cookie_value(headers, ACCESS_COOKIE_NAME) else { + return Ok(None); + }; + + let claims = validate_token(&token, &state.jwt_config).map_err(ApiError::from)?; + Ok(Some(crate::auth::middleware::AuthenticatedUser { claims })) +} + +pub fn get_cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|part| { + let mut pieces = part.trim().splitn(2, '='); + let key = pieces.next()?.trim(); + let value = pieces.next()?.trim(); + if key == name { + Some(value.to_string()) + } else { + None + } + }) + .next() +} + +fn oidc_config(state: &SharedState) -> Result { + state + .config + .security + .oidc + .clone() + .filter(|oidc| oidc.enabled) + .ok_or_else(|| { + ApiError::NotImplemented("OIDC authentication is not configured".to_string()) + }) +} + +async fn fetch_discovery_document(oidc: &OidcConfig) -> Result { + let discovery = reqwest::get(&oidc.discovery_url).await.map_err(|err| { + ApiError::InternalServerError(format!("Failed to fetch OIDC discovery document: {err}")) + })?; + + if !discovery.status().is_success() { + return Err(ApiError::InternalServerError(format!( + "OIDC discovery request failed with status {}", + discovery.status() + ))); + } + + discovery + .json::() + .await + .map_err(|err| { + ApiError::InternalServerError(format!("Failed to parse OIDC discovery document: {err}")) + }) +} + +async fn upsert_identity( + state: &SharedState, + oidc_claims: &OidcIdentityClaims, +) -> Result { + let existing_by_subject = + IdentityRepository::find_by_oidc_subject(&state.db, &oidc_claims.issuer, &oidc_claims.sub) + .await?; + let desired_login = derive_login(oidc_claims); + let display_name = derive_display_name(oidc_claims); + let attributes = json!({ + "oidc": oidc_claims, + }); + + match existing_by_subject { + Some(identity) => { + let updated = UpdateIdentityInput { + display_name, + password_hash: None, + attributes: Some(attributes.clone()), + }; + IdentityRepository::update(&state.db, identity.id, updated) + .await + .map_err(Into::into) + } + None => { + let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? { + Some(_) => fallback_subject_login(oidc_claims), + None => desired_login, + }; + + IdentityRepository::create( + &state.db, + CreateIdentityInput { + login, + display_name, + password_hash: None, + attributes, + }, + ) + .await + .map_err(Into::into) + } + } +} + +fn derive_login(oidc_claims: &OidcIdentityClaims) -> String { + oidc_claims + .email + .clone() + .or_else(|| oidc_claims.preferred_username.clone()) + .unwrap_or_else(|| fallback_subject_login(oidc_claims)) +} + +async fn verify_id_token( + raw_id_token: &str, + discovery: &OidcDiscoveryDocument, + oidc: &OidcConfig, + expected_nonce: &str, +) -> Result { + let header = decode_header(raw_id_token) + .map_err(|err| ApiError::Unauthorized(format!("OIDC ID token header decode failed: {err}")))?; + + let algorithm = match header.alg { + Algorithm::RS256 => Algorithm::RS256, + Algorithm::RS384 => Algorithm::RS384, + Algorithm::RS512 => Algorithm::RS512, + other => { + return Err(ApiError::Unauthorized(format!( + "OIDC ID token uses unsupported signing algorithm: {other:?}" + ))) + } + }; + + let jwks = reqwest::get(discovery.metadata.jwks_uri().url().as_str()) + .await + .map_err(|err| ApiError::InternalServerError(format!("Failed to fetch OIDC JWKS: {err}")))? + .json::() + .await + .map_err(|err| ApiError::InternalServerError(format!("Failed to parse OIDC JWKS: {err}")))?; + + let jwk = jwks + .keys + .iter() + .find(|jwk| { + jwk.common.key_id == header.kid + && matches!(jwk.common.public_key_use, Some(jsonwebtoken::jwk::PublicKeyUse::Signature)) + && matches!( + jwk.algorithm, + AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_) + ) + }) + .ok_or_else(|| ApiError::Unauthorized("OIDC signing key not found in JWKS".to_string()))?; + + let decoding_key = DecodingKey::from_jwk(jwk) + .map_err(|err| ApiError::Unauthorized(format!("OIDC JWK decode failed: {err}")))?; + + let issuer = discovery.metadata.issuer().to_string(); + let mut validation = Validation::new(algorithm); + validation.set_issuer(&[issuer.as_str()]); + validation.set_audience(&[oidc.client_id.as_str()]); + validation.set_required_spec_claims(&["exp", "iat", "iss", "sub", "aud"]); + validation.validate_nbf = false; + + let token = decode::(raw_id_token, &decoding_key, &validation) + .map_err(|err| ApiError::Unauthorized(format!("OIDC ID token validation failed: {err}")))?; + + if token.claims.nonce.as_deref() != Some(expected_nonce) { + return Err(ApiError::Unauthorized( + "OIDC nonce validation failed".to_string(), + )); + } + + Ok(token.claims) +} + +fn derive_display_name(oidc_claims: &OidcIdentityClaims) -> Option { + oidc_claims + .name + .clone() + .or_else(|| oidc_claims.preferred_username.clone()) + .or_else(|| oidc_claims.email.clone()) +} + +fn fallback_subject_login(oidc_claims: &OidcIdentityClaims) -> String { + let mut hasher = Sha256::new(); + hasher.update(oidc_claims.issuer.as_bytes()); + hasher.update(b":"); + hasher.update(oidc_claims.sub.as_bytes()); + let digest = hex::encode(hasher.finalize()); + format!("oidc:{}", &digest[..24]) +} + +fn extract_groups_from_claims(claims: &T) -> Vec +where + T: Serialize, +{ + let Ok(json) = serde_json::to_value(claims) else { + return Vec::new(); + }; + match json.get("groups") { + Some(JsonValue::Array(values)) => values + .iter() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .collect(), + Some(JsonValue::String(value)) => vec![value.to_string()], + _ => Vec::new(), + } +} + +fn merge_userinfo_claims(oidc_claims: &mut OidcIdentityClaims, userinfo: &CoreUserInfoClaims) { + if oidc_claims.email.is_none() { + oidc_claims.email = userinfo.email().map(|email| email.as_str().to_string()); + } + if oidc_claims.name.is_none() { + oidc_claims.name = userinfo.name().and_then(first_localized_claim); + } + if oidc_claims.preferred_username.is_none() { + oidc_claims.preferred_username = userinfo + .preferred_username() + .map(|username| username.as_str().to_string()); + } + if oidc_claims.groups.is_empty() { + oidc_claims.groups = extract_groups_from_claims(userinfo.additional_claims()); + } +} + +fn first_localized_claim(claim: &LocalizedClaim) -> Option +where + T: std::ops::Deref, +{ + claim + .iter() + .next() + .map(|(_, value)| value.as_str().to_string()) +} + +fn build_cookie( + state: &SharedState, + name: &'static str, + value: String, + max_age_seconds: i64, + http_only: bool, +) -> Cookie<'static> { + let mut cookie = Cookie::build((name, value)) + .path("/") + .same_site(SameSite::Lax) + .http_only(http_only) + .max_age(CookieDuration::seconds(max_age_seconds)) + .build(); + + if should_use_secure_cookies(state) { + cookie.set_secure(true); + } + + cookie +} + +fn remove_cookie(state: &SharedState, name: &'static str) -> Cookie<'static> { + let mut cookie = Cookie::build((name, String::new())) + .path("/") + .same_site(SameSite::Lax) + .http_only(true) + .max_age(CookieDuration::seconds(0)) + .build(); + cookie.make_removal(); + if should_use_secure_cookies(state) { + cookie.set_secure(true); + } + cookie +} + +fn should_use_secure_cookies(state: &SharedState) -> bool { + state.config.is_production() + || state + .config + .security + .oidc + .as_ref() + .map(|oidc| oidc.redirect_uri.starts_with("https://")) + .unwrap_or(false) +} + +fn sanitize_redirect_target(redirect_to: Option<&str>) -> String { + let fallback = "/".to_string(); + let Some(redirect_to) = redirect_to else { + return fallback; + }; + if redirect_to.starts_with('/') && !redirect_to.starts_with("//") { + redirect_to.to_string() + } else { + fallback + } +} + +pub fn unauthorized_redirect(location: &str) -> Response { + let mut response = Redirect::to(location).into_response(); + *response.status_mut() = StatusCode::FOUND; + response +} + +fn encode_fragment_value(value: &str) -> String { + byte_serialize(value.as_bytes()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_redirect_target_rejects_external_urls() { + assert_eq!(sanitize_redirect_target(Some("https://example.com")), "/"); + assert_eq!(sanitize_redirect_target(Some("//example.com")), "/"); + assert_eq!( + sanitize_redirect_target(Some("/executions/42")), + "/executions/42" + ); + } + + #[test] + fn extract_groups_from_claims_accepts_array_and_string() { + let array_claims = serde_json::json!({ "groups": ["admins", "operators"] }); + let string_claims = serde_json::json!({ "groups": "admins" }); + + assert_eq!( + extract_groups_from_claims(&array_claims), + vec!["admins".to_string(), "operators".to_string()] + ); + assert_eq!( + extract_groups_from_claims(&string_claims), + vec!["admins".to_string()] + ); + } +} diff --git a/crates/api/src/dto/auth.rs b/crates/api/src/dto/auth.rs index 3d3d2d4..e9fdec8 100644 --- a/crates/api/src/dto/auth.rs +++ b/crates/api/src/dto/auth.rs @@ -136,3 +136,43 @@ pub struct CurrentUserResponse { #[schema(example = "Administrator")] pub display_name: Option, } + +/// Public authentication settings for the login page. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AuthSettingsResponse { + /// Whether authentication is enabled for the server. + #[schema(example = true)] + pub authentication_enabled: bool, + + /// Whether local username/password login is configured. + #[schema(example = true)] + pub local_password_enabled: bool, + + /// Whether local username/password login should be shown by default. + #[schema(example = true)] + pub local_password_visible_by_default: bool, + + /// Whether OIDC login is configured and enabled. + #[schema(example = false)] + pub oidc_enabled: bool, + + /// Whether OIDC login should be shown by default. + #[schema(example = false)] + pub oidc_visible_by_default: bool, + + /// Provider name for `?auth=`. + #[schema(example = "sso")] + pub oidc_provider_name: Option, + + /// User-facing provider label for the login button. + #[schema(example = "Example SSO")] + pub oidc_provider_label: Option, + + /// Optional icon URL shown beside the provider label. + #[schema(example = "https://auth.example.com/assets/logo.svg")] + pub oidc_provider_icon_url: Option, + + /// Whether unauthenticated self-service registration is allowed. + #[schema(example = false)] + pub self_registration_enabled: bool, +} diff --git a/crates/api/src/dto/mod.rs b/crates/api/src/dto/mod.rs index cbab859..4f3572d 100644 --- a/crates/api/src/dto/mod.rs +++ b/crates/api/src/dto/mod.rs @@ -30,8 +30,8 @@ pub use artifact::{ CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest, }; pub use auth::{ - ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, - TokenResponse, + AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, + RefreshTokenRequest, RegisterRequest, TokenResponse, }; pub use common::{ ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse, diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 6bf675d..fddd804 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -115,8 +115,9 @@ async fn mq_reconnect_loop(state: Arc, mq_url: String) { #[tokio::main] async fn main() -> Result<()> { - // Install HMAC-only JWT crypto provider (must be before any token operations) - attune_common::auth::install_crypto_provider(); + // Install a JWT crypto provider that supports both Attune's HS tokens + // and external RS256 OIDC identity tokens. + let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default(); // Initialize tracing subscriber tracing_subscriber::fmt() diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index e269066..7bf73e3 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -10,8 +10,8 @@ use crate::dto::{ ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest, }, auth::{ - ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, - RegisterRequest, TokenResponse, + AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, + RefreshTokenRequest, RegisterRequest, TokenResponse, }, common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse}, event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary}, @@ -68,6 +68,7 @@ use crate::dto::{ crate::routes::health::liveness, // Authentication + crate::routes::auth::auth_settings, crate::routes::auth::login, crate::routes::auth::register, crate::routes::auth::refresh_token, @@ -202,6 +203,7 @@ use crate::dto::{ schemas( // Common types ApiResponse, + ApiResponse, ApiResponse, ApiResponse, ApiResponse, diff --git a/crates/api/src/routes/auth.rs b/crates/api/src/routes/auth.rs index 1076791..43b81b3 100644 --- a/crates/api/src/routes/auth.rs +++ b/crates/api/src/routes/auth.rs @@ -1,7 +1,9 @@ //! Authentication routes use axum::{ - extract::State, + extract::{Query, State}, + http::HeaderMap, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, Json, Router, }; @@ -21,11 +23,16 @@ use crate::{ TokenType, }, middleware::RequireAuth, + oidc::{ + apply_cookies_to_headers, build_login_redirect, build_logout_redirect, + cookie_authenticated_user, get_cookie_value, oidc_callback_redirect_response, + OidcCallbackQuery, REFRESH_COOKIE_NAME, + }, verify_password, }, dto::{ - ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, - RegisterRequest, SuccessResponse, TokenResponse, + ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, + LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse, }, middleware::error::ApiError, state::SharedState, @@ -63,7 +70,11 @@ pub struct SensorTokenResponse { /// Create authentication routes pub fn routes() -> Router { Router::new() + .route("/settings", get(auth_settings)) .route("/login", post(login)) + .route("/oidc/login", get(oidc_login)) + .route("/callback", get(oidc_callback)) + .route("/logout", get(logout)) .route("/register", post(register)) .route("/refresh", post(refresh_token)) .route("/me", get(get_current_user)) @@ -72,6 +83,44 @@ pub fn routes() -> Router { .route("/internal/sensor-token", post(create_sensor_token_internal)) } +/// Authentication settings endpoint +/// +/// GET /auth/settings +#[utoipa::path( + get, + path = "/auth/settings", + tag = "auth", + responses( + (status = 200, description = "Authentication settings", body = inline(ApiResponse)) + ) +)] +pub async fn auth_settings( + State(state): State, +) -> Result>, ApiError> { + let oidc = state + .config + .security + .oidc + .as_ref() + .filter(|oidc| oidc.enabled); + + let response = AuthSettingsResponse { + authentication_enabled: state.config.security.enable_auth, + local_password_enabled: state.config.security.enable_auth, + local_password_visible_by_default: state.config.security.enable_auth + && state.config.security.login_page.show_local_login, + oidc_enabled: oidc.is_some(), + oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login, + oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()), + oidc_provider_label: oidc + .map(|oidc| oidc.provider_label.clone().unwrap_or_else(|| oidc.provider_name.clone())), + oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()), + self_registration_enabled: state.config.security.allow_self_registration, + }; + + Ok(Json(ApiResponse::new(response))) +} + /// Login endpoint /// /// POST /auth/login @@ -221,15 +270,22 @@ pub async fn register( )] pub async fn refresh_token( State(state): State, - Json(payload): Json, -) -> Result>, ApiError> { - // Validate request - payload - .validate() - .map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?; + headers: HeaderMap, + payload: Option>, +) -> Result { + let browser_cookie_refresh = payload.is_none(); + let refresh_token = if let Some(Json(payload)) = payload { + payload.validate().map_err(|e| { + ApiError::ValidationError(format!("Invalid refresh token request: {}", e)) + })?; + payload.refresh_token + } else { + get_cookie_value(&headers, REFRESH_COOKIE_NAME) + .ok_or_else(|| ApiError::Unauthorized("Missing refresh token".to_string()))? + }; // Validate refresh token - let claims = validate_token(&payload.refresh_token, &state.jwt_config) + let claims = validate_token(&refresh_token, &state.jwt_config) .map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?; // Ensure it's a refresh token @@ -257,8 +313,18 @@ pub async fn refresh_token( refresh_token, state.jwt_config.access_token_expiration, ); + let response_body = Json(ApiResponse::new(response.clone())); - Ok(Json(ApiResponse::new(response))) + if browser_cookie_refresh { + let mut http_response = response_body.into_response(); + apply_cookies_to_headers( + http_response.headers_mut(), + &crate::auth::oidc::build_auth_cookies(&state, &response, ""), + )?; + return Ok(http_response); + } + + Ok(response_body.into_response()) } /// Get current user endpoint @@ -279,9 +345,15 @@ pub async fn refresh_token( )] pub async fn get_current_user( State(state): State, - RequireAuth(user): RequireAuth, + headers: HeaderMap, + user: Result, ) -> Result>, ApiError> { - let identity_id = user.identity_id()?; + let authenticated_user = match user { + Ok(RequireAuth(user)) => user, + Err(_) => cookie_authenticated_user(&headers, &state)? + .ok_or_else(|| ApiError::Unauthorized("Unauthorized".to_string()))?, + }; + let identity_id = authenticated_user.identity_id()?; // Fetch identity from database let identity = IdentityRepository::find_by_id(&state.db, identity_id) @@ -297,6 +369,67 @@ pub async fn get_current_user( Ok(Json(ApiResponse::new(response))) } +#[derive(Debug, Deserialize)] +pub struct OidcLoginParams { + pub redirect_to: Option, +} + +/// Begin browser OIDC login by redirecting to the provider. +pub async fn oidc_login( + State(state): State, + Query(params): Query, +) -> Result { + let login_redirect = build_login_redirect(&state, params.redirect_to.as_deref()).await?; + let mut response = Redirect::temporary(&login_redirect.authorization_url).into_response(); + apply_cookies_to_headers(response.headers_mut(), &login_redirect.cookies)?; + Ok(response) +} + +/// Handle the OIDC authorization code callback. +pub async fn oidc_callback( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let redirect_to = get_cookie_value(&headers, crate::auth::oidc::OIDC_REDIRECT_COOKIE_NAME); + let authenticated = crate::auth::oidc::handle_callback(&state, &headers, &query).await?; + oidc_callback_redirect_response( + &state, + &authenticated.token_response, + redirect_to, + &authenticated.id_token, + ) +} + +/// Logout the current browser session and optionally redirect through the provider logout flow. +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + let oidc_enabled = state + .config + .security + .oidc + .as_ref() + .is_some_and(|oidc| oidc.enabled); + + let response = if oidc_enabled { + let logout_redirect = build_logout_redirect(&state, &headers).await?; + let mut response = Redirect::temporary(&logout_redirect.redirect_url).into_response(); + apply_cookies_to_headers(response.headers_mut(), &logout_redirect.cookies)?; + response + } else { + let mut response = Redirect::temporary("/login").into_response(); + apply_cookies_to_headers( + response.headers_mut(), + &crate::auth::oidc::clear_auth_cookies(&state), + )?; + response + }; + + Ok(response) +} + /// Change password endpoint /// /// POST /auth/change-password diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 6c5f7ee..8fecb16 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -1779,7 +1779,6 @@ async fn handle_update( #[serde(tag = "op", content = "value", rename_all = "snake_case")] enum PackDescriptionPatch { Set(String), - Clear, } #[derive(Serialize)] diff --git a/crates/cli/src/commands/trigger.rs b/crates/cli/src/commands/trigger.rs index e4a98a3..6713fcd 100644 --- a/crates/cli/src/commands/trigger.rs +++ b/crates/cli/src/commands/trigger.rs @@ -258,7 +258,6 @@ async fn handle_update( #[serde(tag = "op", content = "value", rename_all = "snake_case")] enum TriggerDescriptionPatch { Set(String), - Clear, } #[derive(Serialize)] diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 83c5378..83e9abe 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -299,6 +299,14 @@ pub struct SecurityConfig { /// Allow unauthenticated self-service user registration #[serde(default)] pub allow_self_registration: bool, + + /// Login page visibility defaults for the web UI. + #[serde(default)] + pub login_page: LoginPageConfig, + + /// Optional OpenID Connect configuration for browser login. + #[serde(default)] + pub oidc: Option, } fn default_jwt_access_expiration() -> u64 { @@ -309,6 +317,68 @@ fn default_jwt_refresh_expiration() -> u64 { 604800 // 7 days } +/// Web login page configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginPageConfig { + /// Show the local username/password form by default. + #[serde(default = "default_true")] + pub show_local_login: bool, + + /// Show the OIDC/SSO option by default when configured. + #[serde(default = "default_true")] + pub show_oidc_login: bool, +} + +impl Default for LoginPageConfig { + fn default() -> Self { + Self { + show_local_login: true, + show_oidc_login: true, + } + } +} + +/// OpenID Connect configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OidcConfig { + /// Enable OpenID Connect login flow. + #[serde(default)] + pub enabled: bool, + + /// OpenID Provider discovery document URL. + pub discovery_url: String, + + /// Confidential client ID. + pub client_id: String, + + /// Provider name used in login-page overrides such as `?auth=`. + #[serde(default = "default_oidc_provider_name")] + pub provider_name: String, + + /// User-facing provider label shown on the login page. + pub provider_label: Option, + + /// Optional icon URL shown beside the provider label on the login page. + pub provider_icon_url: Option, + + /// Confidential client secret. + pub client_secret: Option, + + /// Redirect URI registered with the provider. + pub redirect_uri: String, + + /// Optional post-logout redirect URI. + pub post_logout_redirect_uri: Option, + + /// Optional requested scopes in addition to `openid email profile`. + #[serde(default)] + pub scopes: Vec, +} + +fn default_oidc_provider_name() -> String { + "oidc".to_string() +} + /// Worker configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerConfig { @@ -681,6 +751,8 @@ impl Default for SecurityConfig { encryption_key: None, enable_auth: true, allow_self_registration: false, + login_page: LoginPageConfig::default(), + oidc: None, } } } @@ -800,6 +872,37 @@ impl Config { )); } + if let Some(oidc) = &self.security.oidc { + if oidc.enabled { + if oidc.discovery_url.trim().is_empty() { + return Err(crate::Error::validation( + "OIDC discovery URL cannot be empty when OIDC is enabled", + )); + } + if oidc.client_id.trim().is_empty() { + return Err(crate::Error::validation( + "OIDC client ID cannot be empty when OIDC is enabled", + )); + } + if oidc + .client_secret + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + return Err(crate::Error::validation( + "OIDC client secret is required when OIDC is enabled", + )); + } + if oidc.redirect_uri.trim().is_empty() { + return Err(crate::Error::validation( + "OIDC redirect URI cannot be empty when OIDC is enabled", + )); + } + } + } + // Validate encryption key if provided if let Some(ref key) = self.security.encryption_key { if key.len() < 32 { @@ -930,6 +1033,8 @@ mod tests { encryption_key: Some("a".repeat(32)), enable_auth: true, allow_self_registration: false, + login_page: LoginPageConfig::default(), + oidc: None, }, worker: None, sensor: None, diff --git a/crates/common/src/repositories/identity.rs b/crates/common/src/repositories/identity.rs index 5341f4b..d753703 100644 --- a/crates/common/src/repositories/identity.rs +++ b/crates/common/src/repositories/identity.rs @@ -159,6 +159,27 @@ impl IdentityRepository { "SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1" ).bind(login).fetch_optional(executor).await.map_err(Into::into) } + + pub async fn find_by_oidc_subject<'e, E>( + executor: E, + issuer: &str, + subject: &str, + ) -> Result> + where + E: Executor<'e, Database = Postgres> + 'e, + { + sqlx::query_as::<_, Identity>( + "SELECT id, login, display_name, password_hash, attributes, created, updated + FROM identity + WHERE attributes->'oidc'->>'issuer' = $1 + AND attributes->'oidc'->>'sub' = $2", + ) + .bind(issuer) + .bind(subject) + .fetch_optional(executor) + .await + .map_err(Into::into) + } } // Permission Set Repository diff --git a/deny.toml b/deny.toml index 28a897f..ea20db6 100644 --- a/deny.toml +++ b/deny.toml @@ -4,7 +4,12 @@ all-features = true [advisories] version = 2 yanked = "deny" -ignore = [] +ignore = [ + # jsonwebtoken's RSA support is required for verifying external RS256 OIDC ID tokens. + # No patched rsa release is available yet for RUSTSEC-2023-0071, so tolerate it until + # the upstream ecosystem provides a safe replacement or fix. + "RUSTSEC-2023-0071", +] [licenses] version = 2 @@ -30,7 +35,14 @@ multiple-versions = "warn" wildcards = "allow" highlight = "all" deny = [] -skip = [] +skip = [ + "winnow@0.6.26", + "winnow@0.7.15", + "windows_x86_64_msvc@0.42.2", + "windows_x86_64_msvc@0.48.5", + "windows_x86_64_msvc@0.52.6", + "windows_x86_64_msvc@0.53.1", +] skip-tree = [] [sources] diff --git a/docker/Dockerfile.optimized b/docker/Dockerfile.optimized index 93af07c..bbdac66 100644 --- a/docker/Dockerfile.optimized +++ b/docker/Dockerfile.optimized @@ -28,7 +28,7 @@ RUN apt-get update && apt-get install -y \ WORKDIR /build # Increase rustc stack size to prevent SIGSEGV during release builds -ENV RUST_MIN_STACK=16777216 +ENV RUST_MIN_STACK=33554432 # Copy dependency metadata first so `cargo fetch` layer is cached # when only source code changes (Cargo.toml/Cargo.lock stay the same) diff --git a/docker/Dockerfile.sensor.optimized b/docker/Dockerfile.sensor.optimized index 0e52ac1..3817dda 100644 --- a/docker/Dockerfile.sensor.optimized +++ b/docker/Dockerfile.sensor.optimized @@ -31,7 +31,7 @@ RUN apt-get update && apt-get install -y \ WORKDIR /build # Increase rustc stack size to prevent SIGSEGV during release builds -ENV RUST_MIN_STACK=16777216 +ENV RUST_MIN_STACK=33554432 # Copy dependency metadata first so `cargo fetch` layer is cached # when only source code changes (Cargo.toml/Cargo.lock stay the same) diff --git a/docker/Dockerfile.worker.optimized b/docker/Dockerfile.worker.optimized index 58763d3..7bf15e3 100644 --- a/docker/Dockerfile.worker.optimized +++ b/docker/Dockerfile.worker.optimized @@ -36,7 +36,7 @@ RUN apt-get update && apt-get install -y \ WORKDIR /build # Increase rustc stack size to prevent SIGSEGV during release builds -ENV RUST_MIN_STACK=16777216 +ENV RUST_MIN_STACK=33554432 # Copy dependency metadata first so `cargo fetch` layer is cached # when only source code changes (Cargo.toml/Cargo.lock stay the same) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..450b103 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "attune", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "jose": "^6.2.1" + } + }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0aafe70 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "jose": "^6.2.1" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 551a25f..9e28e0e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import MainLayout from "@/components/layout/MainLayout"; // Lazy-loaded page components for code splitting const LoginPage = lazy(() => import("@/pages/auth/LoginPage")); +const OidcCallbackPage = lazy(() => import("@/pages/auth/OidcCallbackPage")); const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage")); const PacksPage = lazy(() => import("@/pages/packs/PacksPage")); const PackCreatePage = lazy(() => import("@/pages/packs/PackCreatePage")); @@ -68,6 +69,7 @@ function App() { {/* Public routes */} } /> + } /> {/* Protected routes */} { // Initialize from localStorage @@ -206,7 +205,6 @@ export default function MainLayout() { const handleLogout = () => { logout(); - navigate("/login"); }; return ( diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 3df1a30..3aac2a5 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -7,7 +7,7 @@ import { ReactNode, } from "react"; import { AuthService, ApiError } from "@/api"; -import type { UserInfo, LoginRequest } from "@/api"; +import type { UserInfo } from "@/api"; import { startTokenRefreshMonitor, stopTokenRefreshMonitor, @@ -17,10 +17,14 @@ interface AuthContextType { user: UserInfo | null; isAuthenticated: boolean; isLoading: boolean; - login: (credentials: LoginRequest) => Promise; + login: (redirectTo?: string) => void; logout: () => void; refreshUser: () => Promise; getToken: () => string | null; + completeLogin: (params: { + accessToken: string; + refreshToken: string; + }) => Promise; } const AuthContext = createContext(undefined); @@ -73,29 +77,11 @@ export function AuthProvider({ children }: AuthProviderProps) { } }; - const login = async (credentials: LoginRequest) => { - try { - const response = await AuthService.login({ - requestBody: credentials, - }); - - const { access_token, refresh_token, user: userInfo } = response.data; - localStorage.setItem("access_token", access_token); - localStorage.setItem("refresh_token", refresh_token); - - // If user info is included in response, use it; otherwise load it - if (userInfo) { - setUser(userInfo); - } else { - await loadUser(); - } - } catch (error) { - console.error("Login failed:", error); - if (error instanceof ApiError) { - console.error(`API Error ${error.status}: ${error.message}`); - } - throw error; - } + const login = (redirectTo?: string) => { + const redirectParam = redirectTo + ? `?redirect_to=${encodeURIComponent(redirectTo)}` + : ""; + window.location.href = `/auth/oidc/login${redirectParam}`; }; const logout = () => { @@ -103,6 +89,7 @@ export function AuthProvider({ children }: AuthProviderProps) { localStorage.removeItem("refresh_token"); stopTokenRefreshMonitor(); setUser(null); + window.location.href = "/auth/logout"; }; const refreshUser = async () => { @@ -113,6 +100,18 @@ export function AuthProvider({ children }: AuthProviderProps) { return localStorage.getItem("access_token"); }; + const completeLogin = async ({ + accessToken, + refreshToken, + }: { + accessToken: string; + refreshToken: string; + }) => { + localStorage.setItem("access_token", accessToken); + localStorage.setItem("refresh_token", refreshToken); + await loadUser(); + }; + const value: AuthContextType = { user, isAuthenticated: !!user, @@ -121,6 +120,7 @@ export function AuthProvider({ children }: AuthProviderProps) { logout, refreshUser, getToken, + completeLogin, }; return {children}; diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index fcd93b9..1e5150c 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -1,6 +1,8 @@ -import React, { useState } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; +import { FormEvent, useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ApiError, AuthService } from "@/api"; import { useAuth } from "@/contexts/AuthContext"; +import apiClient from "@/lib/api-client"; interface LocationState { from?: { @@ -8,65 +10,142 @@ interface LocationState { }; } -interface LoginError { - response?: { - status: number; - data?: { - message?: string; - }; - }; - message?: string; +interface AuthSettingsResponse { + authentication_enabled: boolean; + local_password_enabled: boolean; + local_password_visible_by_default: boolean; + oidc_enabled: boolean; + oidc_visible_by_default: boolean; + oidc_provider_name: string | null; + oidc_provider_label: string | null; + oidc_provider_icon_url: string | null; + self_registration_enabled: boolean; } export default function LoginPage() { - const [login, setLogin] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const { login: authLogin } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const { login: startOidcLogin, completeLogin } = useAuth(); + const [settings, setSettings] = useState(null); + const [settingsError, setSettingsError] = useState(null); + const [overrideError, setOverrideError] = useState(null); + const [loginError, setLoginError] = useState(null); + const [isLoadingSettings, setIsLoadingSettings] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [credentials, setCredentials] = useState({ login: "", password: "" }); - // Check for redirect path from session storage (set by axios interceptor on 401) const redirectPath = sessionStorage.getItem("redirect_after_login"); const from = redirectPath || (location.state as LocationState)?.from?.pathname || "/"; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setIsLoading(true); + useEffect(() => { + const loadAuthSettings = async () => { + try { + const response = await apiClient.get<{ data: AuthSettingsResponse }>( + "/auth/settings", + ); + setSettings(response.data.data); + } catch (error) { + console.error("Failed to load auth settings:", error); + setSettingsError("Unable to load authentication options."); + } finally { + setIsLoadingSettings(false); + } + }; + + void loadAuthSettings(); + }, []); + + const authOverride = new URLSearchParams(location.search) + .get("auth") + ?.trim() + .toLowerCase(); + + const localEnabled = settings?.local_password_enabled ?? false; + const oidcEnabled = settings?.oidc_enabled ?? false; + const authEnabled = settings?.authentication_enabled ?? true; + const providerName = settings?.oidc_provider_name?.toLowerCase() ?? null; + const providerLabel = + settings?.oidc_provider_label ?? settings?.oidc_provider_name ?? "SSO"; + + let showLocal = settings?.local_password_visible_by_default ?? false; + let showOidc = settings?.oidc_visible_by_default ?? false; + + if (authOverride === "direct") { + if (localEnabled) { + showLocal = true; + showOidc = false; + } + } else if (authOverride && providerName && authOverride === providerName) { + if (oidcEnabled) { + showLocal = false; + showOidc = true; + } + } + + useEffect(() => { + if (!authOverride || !settings) { + setOverrideError(null); + return; + } + + if (authOverride === "direct") { + setOverrideError( + localEnabled + ? null + : "Local login was requested, but it is not available on this server.", + ); + return; + } + + if (providerName && authOverride === providerName) { + setOverrideError( + oidcEnabled + ? null + : `${providerLabel} was requested, but it is not available on this server.`, + ); + return; + } + + setOverrideError( + `Unknown authentication override '${authOverride}'. Falling back to the server defaults.`, + ); + }, [authOverride, localEnabled, oidcEnabled, providerLabel, providerName, settings]); + + const handleOidcLogin = () => { + sessionStorage.setItem("redirect_after_login", from); + startOidcLogin(from); + }; + + const handleLocalLogin = async (event: FormEvent) => { + event.preventDefault(); + setLoginError(null); + setIsSubmitting(true); try { - await authLogin({ login, password }); - - // Clear the redirect path from session storage + const response = await AuthService.login({ + requestBody: credentials, + }); + await completeLogin({ + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + }); sessionStorage.removeItem("redirect_after_login"); - navigate(from, { replace: true }); - } catch (err: unknown) { - const loginErr = err as LoginError; - console.error("Login error:", loginErr); - if (loginErr.response) { - console.error("Response status:", loginErr.response.status); - console.error("Response data:", loginErr.response.data); + } catch (error) { + if (error instanceof ApiError) { + setLoginError(error.message); + } else { + setLoginError("Failed to sign in."); } - const errorMessage = - loginErr.response?.data?.message || - loginErr.message || - "Login failed. Please check your credentials."; - setError(errorMessage); - // Don't navigate on error - stay on login page - setIsLoading(false); - return; } finally { - setIsLoading(false); + setIsSubmitting(false); } }; return (
-
+

Attune @@ -75,68 +154,135 @@ export default function LoginPage() { Sign in to your account

-
- {error && ( -
-
-
-

{error}

+
+ {isLoadingSettings ? ( +
+
+ Loading authentication options... +
+ ) : ( + <> + {settingsError ? ( +
+ {settingsError}
-
-
- )} -
-
- - setLogin(e.target.value)} - disabled={isLoading} - /> -
-
- - setPassword(e.target.value)} - disabled={isLoading} - /> -
-
+ ) : null} -
- -
- + {overrideError ? ( +
+ {overrideError} +
+ ) : null} + + {!authEnabled ? ( +
+ Authentication is disabled in the current server + configuration. +
+ ) : null} + + {authEnabled && showLocal ? ( +
+
+ + + setCredentials((current) => ({ + ...current, + login: event.target.value, + })) + } + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + required + /> +
+
+ + + setCredentials((current) => ({ + ...current, + password: event.target.value, + })) + } + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + required + /> +
+ {loginError ? ( +
+ {loginError} +
+ ) : null} + +
+ ) : null} + + {authEnabled && showLocal && showOidc ? ( +
+
+ or +
+
+ ) : null} + + {authEnabled && showOidc ? ( + <> +

+ Continue with your configured single sign-on provider. +

+ + + ) : null} + + {!settingsError && authEnabled && !showLocal && !showOidc ? ( +
+ No login method is shown by default for this server. Use + `?auth=direct` + {providerName ? ` or ?auth=${providerName}` : ""} to choose + a specific method. +
+ ) : null} + + )} +
); diff --git a/web/src/pages/auth/OidcCallbackPage.tsx b/web/src/pages/auth/OidcCallbackPage.tsx new file mode 100644 index 0000000..f336ad0 --- /dev/null +++ b/web/src/pages/auth/OidcCallbackPage.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/contexts/AuthContext"; + +function parseHashParams(hash: string): URLSearchParams { + const fragment = hash.startsWith("#") ? hash.slice(1) : hash; + return new URLSearchParams(fragment); +} + +export default function OidcCallbackPage() { + const navigate = useNavigate(); + const { completeLogin } = useAuth(); + const [error, setError] = useState(null); + + useEffect(() => { + const finalizeLogin = async () => { + const params = parseHashParams(window.location.hash); + const accessToken = params.get("access_token"); + const refreshToken = params.get("refresh_token"); + const redirectTo = params.get("redirect_to") || "/"; + + if (!accessToken || !refreshToken) { + setError("Missing login tokens in OIDC callback response."); + return; + } + + try { + await completeLogin({ accessToken, refreshToken }); + sessionStorage.removeItem("redirect_after_login"); + navigate(redirectTo, { replace: true }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to complete login."; + setError(message); + } + }; + + void finalizeLogin(); + }, [completeLogin, navigate]); + + return ( +
+
+

+ Completing sign-in +

+

+ Attune is finalizing your authenticated session. +

+ {error ? ( +
+ {error} +
+ ) : ( +
+
+ Redirecting... +
+ )} +
+
+ ); +}