added oidc adapter
Some checks failed
CI / Rustfmt (push) Failing after 56s
CI / Clippy (push) Successful in 2m4s
CI / Web Blocking Checks (push) Successful in 50s
CI / Cargo Audit & Deny (push) Successful in 2m2s
CI / Security Blocking Checks (push) Successful in 10s
CI / Security Advisory Checks (push) Successful in 41s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish init-packs (push) Failing after 13s
Publish Images And Chart / Publish init-user (push) Failing after 11s
CI / Web Advisory Checks (push) Successful in 1m38s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish sensor (push) Failing after 31s
Publish Images And Chart / Publish api (push) Failing after 10s
Publish Images And Chart / Publish notifier (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Tests (push) Successful in 1h34m2s

This commit is contained in:
2026-03-18 16:35:21 -05:00
parent 1d59ff5de4
commit 57fa3bf7cf
27 changed files with 2019 additions and 224 deletions

515
Cargo.lock generated
View File

@@ -466,19 +466,23 @@ dependencies = [
"async-trait", "async-trait",
"attune-common", "attune-common",
"axum", "axum",
"axum-extra",
"chrono", "chrono",
"clap", "clap",
"config", "config",
"cookie",
"flate2", "flate2",
"futures", "futures",
"hex", "hex",
"hmac", "hmac",
"jsonschema", "jsonschema",
"jsonwebtoken",
"mockall", "mockall",
"openidconnect",
"rand 0.10.0", "rand 0.10.0",
"reqwest 0.13.2", "reqwest 0.13.2",
"reqwest-eventsource", "reqwest-eventsource",
"schemars", "schemars 1.2.1",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng", "serde_yaml_ng",
@@ -495,6 +499,7 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
"utoipa", "utoipa",
"utoipa-swagger-ui", "utoipa-swagger-ui",
"uuid", "uuid",
@@ -547,7 +552,7 @@ dependencies = [
"argon2", "argon2",
"async-recursion", "async-recursion",
"async-trait", "async-trait",
"base64", "base64 0.22.1",
"chrono", "chrono",
"config", "config",
"futures", "futures",
@@ -559,7 +564,7 @@ dependencies = [
"regex", "regex",
"reqwest 0.13.2", "reqwest 0.13.2",
"ring", "ring",
"schemars", "schemars 1.2.1",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
@@ -722,7 +727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"base64", "base64 0.22.1",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@@ -771,6 +776,29 @@ dependencies = [
"tracing", "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]] [[package]]
name = "backon" name = "backon"
version = "1.6.0" version = "1.6.0"
@@ -780,6 +808,18 @@ dependencies = [
"fastrand", "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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -1087,7 +1127,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1194,6 +1234,17 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "cookie-factory" name = "cookie-factory"
version = "0.3.3" version = "0.3.3"
@@ -1232,7 +1283,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64", "base64 0.22.1",
"chrono", "chrono",
"clap", "clap",
"futures", "futures",
@@ -1305,7 +1356,7 @@ dependencies = [
"ciborium", "ciborium",
"clap", "clap",
"criterion-plot", "criterion-plot",
"itertools", "itertools 0.13.0",
"num-traits", "num-traits",
"oorandom", "oorandom",
"page_size", "page_size",
@@ -1325,7 +1376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [ dependencies = [
"cast", "cast",
"itertools", "itertools 0.13.0",
] ]
[[package]] [[package]]
@@ -1428,6 +1479,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -1448,14 +1511,51 @@ dependencies = [
"cipher", "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]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.20.11",
"darling_macro", "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]] [[package]]
@@ -1472,13 +1572,37 @@ dependencies = [
"syn", "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]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.20.11" version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ 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", "quote",
"syn", "syn",
] ]
@@ -1566,6 +1690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde_core",
] ]
[[package]] [[package]]
@@ -1594,7 +1719,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [ dependencies = [
"darling", "darling 0.20.11",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
@@ -1723,6 +1848,44 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 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]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -1732,6 +1895,27 @@ dependencies = [
"serde", "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]] [[package]]
name = "email_address" name = "email_address"
version = "0.2.9" version = "0.2.9"
@@ -1855,6 +2039,22 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.27" version = "0.2.27"
@@ -2132,6 +2332,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@@ -2185,6 +2386,17 @@ dependencies = [
"polyval", "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]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -2197,7 +2409,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"http", "http",
"indexmap", "indexmap 2.13.0",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -2215,6 +2427,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -2436,6 +2654,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -2444,7 +2663,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@@ -2455,7 +2674,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.6.2",
"system-configuration", "system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
@@ -2601,6 +2820,17 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -2657,6 +2887,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -2760,11 +2999,16 @@ version = "10.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac", "hmac",
"js-sys", "js-sys",
"p256",
"p384",
"pem", "pem",
"rand 0.8.5",
"rsa",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -3211,6 +3455,26 @@ dependencies = [
"libc", "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]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.8.1" version = "0.8.1"
@@ -3248,6 +3512,37 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 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]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.75" version = "0.10.75"
@@ -3298,6 +3593,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "ordered-multimap" name = "ordered-multimap"
version = "0.7.3" version = "0.7.3"
@@ -3320,7 +3624,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2" checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"cbc", "cbc",
"cms", "cms",
"der", "der",
@@ -3337,6 +3641,30 @@ dependencies = [
"x509-parser", "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]] [[package]]
name = "page_size" name = "page_size"
version = "0.6.0" version = "0.6.0"
@@ -3409,7 +3737,7 @@ version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"serde_core", "serde_core",
] ]
@@ -3685,6 +4013,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro-error-attr2" name = "proc-macro-error-attr2"
version = "2.0.0" version = "2.0.0"
@@ -3729,7 +4066,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2 0.5.10", "socket2 0.6.2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -3767,7 +4104,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.6.2",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@@ -4024,7 +4361,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
@@ -4032,16 +4369,21 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -4051,6 +4393,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2", "wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -4059,7 +4402,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-channel", "futures-channel",
@@ -4120,6 +4463,16 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" 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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -4218,6 +4571,15 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 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]] [[package]]
name = "rusticata-macros" name = "rusticata-macros"
version = "4.1.0" version = "4.1.0"
@@ -4370,6 +4732,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "schemars" name = "schemars"
version = "1.2.1" version = "1.2.1"
@@ -4413,6 +4787,20 @@ dependencies = [
"sha2", "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]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -4468,6 +4856,16 @@ dependencies = [
"typeid", "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]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -4523,6 +4921,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.0.4" version = "1.0.4"
@@ -4544,13 +4951,44 @@ dependencies = [
"serde", "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]] [[package]]
name = "serde_yaml_ng" name = "serde_yaml_ng"
version = "0.10.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.13.0",
"itoa", "itoa",
"ryu", "ryu",
"serde", "serde",
@@ -4729,7 +5167,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
@@ -4742,7 +5180,7 @@ dependencies = [
"futures-util", "futures-util",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"hashlink", "hashlink",
"indexmap", "indexmap 2.13.0",
"log", "log",
"memchr", "memchr",
"once_cell", "once_cell",
@@ -4806,7 +5244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
@@ -4850,7 +5288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono", "chrono",
@@ -5581,6 +6019,7 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]] [[package]]
@@ -5613,7 +6052,7 @@ version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.13.0",
"serde", "serde",
"serde_json", "serde_json",
"utoipa-gen", "utoipa-gen",
@@ -5639,7 +6078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
dependencies = [ dependencies = [
"axum", "axum",
"base64", "base64 0.22.1",
"mime_guess", "mime_guess",
"regex", "regex",
"rust-embed", "rust-embed",
@@ -5694,7 +6133,7 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
dependencies = [ dependencies = [
"darling", "darling 0.20.11",
"once_cell", "once_cell",
"proc-macro-error2", "proc-macro-error2",
"proc-macro2", "proc-macro2",
@@ -5870,7 +6309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap 2.13.0",
"wasm-encoder", "wasm-encoder",
"wasmparser", "wasmparser",
] ]
@@ -5909,7 +6348,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap 2.13.0",
"semver", "semver",
] ]
@@ -5998,7 +6437,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6400,7 +6839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [ dependencies = [
"assert-json-diff", "assert-json-diff",
"base64", "base64 0.22.1",
"deadpool", "deadpool",
"futures", "futures",
"http", "http",
@@ -6444,7 +6883,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"indexmap", "indexmap 2.13.0",
"prettyplease", "prettyplease",
"syn", "syn",
"wasm-metadata", "wasm-metadata",
@@ -6475,7 +6914,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags", "bitflags",
"indexmap", "indexmap 2.13.0",
"log", "log",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -6494,7 +6933,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"id-arena", "id-arena",
"indexmap", "indexmap 2.13.0",
"log", "log",
"semver", "semver",
"serde", "serde",
@@ -6677,7 +7116,7 @@ dependencies = [
"arbitrary", "arbitrary",
"crc32fast", "crc32fast",
"flate2", "flate2",
"indexmap", "indexmap 2.13.0",
"memchr", "memchr",
"zopfli", "zopfli",
] ]

View File

@@ -47,6 +47,15 @@ security:
encryption_key: test-encryption-key-32-chars-okay encryption_key: test-encryption-key-32-chars-okay
enable_auth: true enable_auth: true
allow_self_registration: 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 directory (where pack action files are located)
packs_base_dir: ./packs packs_base_dir: ./packs

View File

@@ -86,6 +86,27 @@ security:
# Enable authentication # Enable authentication
enable_auth: true enable_auth: true
# Login page defaults for the web UI. Users can still override with:
# /login?auth=direct
# /login?auth=<provider_name>
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) # Worker configuration (optional, for worker services)
# Uncomment and configure if running worker processes # Uncomment and configure if running worker processes
# worker: # worker:

View File

@@ -27,6 +27,8 @@ futures = { workspace = true }
# Web framework # Web framework
axum = { workspace = true, features = ["multipart"] } axum = { workspace = true, features = ["multipart"] }
axum-extra = { version = "0.10", features = ["cookie"] }
cookie = "0.18"
tower = { workspace = true } tower = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
@@ -67,6 +69,8 @@ jsonschema = { workspace = true }
# HTTP client # HTTP client
reqwest = { workspace = true } reqwest = { workspace = true }
openidconnect = "4.0"
url = { workspace = true }
# Archive/compression # Archive/compression
tar = { workspace = true } tar = { workspace = true }
@@ -88,6 +92,7 @@ hex = "0.4"
# OpenAPI/Swagger # OpenAPI/Swagger
utoipa = { workspace = true, features = ["axum_extras"] } utoipa = { workspace = true, features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0", features = ["axum"] } utoipa-swagger-ui = { version = "9.0", features = ["axum"] }
jsonwebtoken = { workspace = true, features = ["rust_crypto"] }
[dev-dependencies] [dev-dependencies]
mockall = { workspace = true } mockall = { workspace = true }

View File

@@ -2,7 +2,7 @@
use axum::{ use axum::{
extract::{Request, State}, extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode}, http::{header::AUTHORIZATION, HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
@@ -14,6 +14,8 @@ use attune_common::auth::jwt::{
extract_token_from_header, validate_token, Claims, JwtConfig, TokenType, extract_token_from_header, validate_token, Claims, JwtConfig, TokenType,
}; };
use super::oidc::{cookie_authenticated_user, ACCESS_COOKIE_NAME};
/// Authentication middleware state /// Authentication middleware state
#[derive(Clone)] #[derive(Clone)]
pub struct AuthMiddleware { pub struct AuthMiddleware {
@@ -50,21 +52,7 @@ pub async fn require_auth(
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, AuthError> { ) -> Result<Response, AuthError> {
// Extract Authorization header let claims = extract_claims(request.headers(), &auth.jwt_config)?;
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,
})?;
// Add claims to request extensions // Add claims to request extensions
request request
@@ -90,22 +78,13 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
return Ok(RequireAuth(user.clone())); return Ok(RequireAuth(user.clone()));
} }
// Otherwise, extract and validate token directly from header let claims = if let Some(user) =
// Extract Authorization header cookie_authenticated_user(&parts.headers, state).map_err(map_cookie_auth_error)?
let auth_header = parts {
.headers user.claims
.get(AUTHORIZATION) } else {
.and_then(|h| h.to_str().ok()) extract_claims(&parts.headers, &state.jwt_config)?
.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,
})?;
// Allow access, sensor, and execution-scoped tokens // Allow access, sensor, and execution-scoped tokens
if claims.token_type != TokenType::Access if claims.token_type != TokenType::Access
@@ -119,6 +98,33 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
} }
} }
fn extract_claims(headers: &HeaderMap, jwt_config: &JwtConfig) -> Result<Claims, AuthError> {
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 /// Authentication errors
#[derive(Debug)] #[derive(Debug)]
pub enum AuthError { pub enum AuthError {

View File

@@ -2,6 +2,7 @@
pub mod jwt; pub mod jwt;
pub mod middleware; pub mod middleware;
pub mod oidc;
pub mod password; pub mod password;
pub use jwt::{generate_token, validate_token, Claims}; pub use jwt::{generate_token, validate_token, Claims};

767
crates/api/src/auth/oidc.rs Normal file
View File

@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcIdentityClaims {
pub issuer: String,
pub sub: String,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub name: Option<String>,
pub preferred_username: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct VerifiedIdTokenClaims {
iss: String,
sub: String,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
email_verified: Option<bool>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
preferred_username: Option<String>,
#[serde(default)]
groups: Vec<String>,
}
#[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<Cookie<'static>>,
}
#[derive(Debug, Clone)]
pub struct OidcLogoutRedirect {
pub redirect_url: String,
pub cookies: Vec<Cookie<'static>>,
}
#[derive(Debug, Deserialize)]
pub struct OidcCallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
pub async fn build_login_redirect(
state: &SharedState,
redirect_to: Option<&str>,
) -> Result<OidcLoginRedirect, ApiError> {
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<OidcAuthenticatedIdentity, ApiError> {
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<OidcLogoutRedirect, ApiError> {
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<Cookie<'static>> {
[
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<Cookie<'static>> {
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<String>,
id_token: &str,
) -> Result<Response, ApiError> {
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<Option<crate::auth::middleware::AuthenticatedUser>, 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<String> {
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<OidcConfig, ApiError> {
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<OidcDiscoveryDocument, ApiError> {
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::<OidcDiscoveryDocument>()
.await
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to parse OIDC discovery document: {err}"))
})
}
async fn upsert_identity(
state: &SharedState,
oidc_claims: &OidcIdentityClaims,
) -> Result<attune_common::models::identity::Identity, ApiError> {
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<VerifiedIdTokenClaims, ApiError> {
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::<JwkSet>()
.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::<VerifiedIdTokenClaims>(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<String> {
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<T>(claims: &T) -> Vec<String>
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<T>(claim: &LocalizedClaim<T>) -> Option<String>
where
T: std::ops::Deref<Target = String>,
{
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()]
);
}
}

View File

@@ -136,3 +136,43 @@ pub struct CurrentUserResponse {
#[schema(example = "Administrator")] #[schema(example = "Administrator")]
pub display_name: Option<String>, pub display_name: Option<String>,
} }
/// 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=<provider>`.
#[schema(example = "sso")]
pub oidc_provider_name: Option<String>,
/// User-facing provider label for the login button.
#[schema(example = "Example SSO")]
pub oidc_provider_label: Option<String>,
/// Optional icon URL shown beside the provider label.
#[schema(example = "https://auth.example.com/assets/logo.svg")]
pub oidc_provider_icon_url: Option<String>,
/// Whether unauthenticated self-service registration is allowed.
#[schema(example = false)]
pub self_registration_enabled: bool,
}

View File

@@ -30,8 +30,8 @@ pub use artifact::{
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest, CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
}; };
pub use auth::{ pub use auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
TokenResponse, RefreshTokenRequest, RegisterRequest, TokenResponse,
}; };
pub use common::{ pub use common::{
ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse, ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse,

View File

@@ -115,8 +115,9 @@ async fn mq_reconnect_loop(state: Arc<AppState>, mq_url: String) {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Install HMAC-only JWT crypto provider (must be before any token operations) // Install a JWT crypto provider that supports both Attune's HS tokens
attune_common::auth::install_crypto_provider(); // and external RS256 OIDC identity tokens.
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
// Initialize tracing subscriber // Initialize tracing subscriber
tracing_subscriber::fmt() tracing_subscriber::fmt()

View File

@@ -10,8 +10,8 @@ use crate::dto::{
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest, ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest,
}, },
auth::{ auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
RegisterRequest, TokenResponse, RefreshTokenRequest, RegisterRequest, TokenResponse,
}, },
common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse}, common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse},
event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary}, event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary},
@@ -68,6 +68,7 @@ use crate::dto::{
crate::routes::health::liveness, crate::routes::health::liveness,
// Authentication // Authentication
crate::routes::auth::auth_settings,
crate::routes::auth::login, crate::routes::auth::login,
crate::routes::auth::register, crate::routes::auth::register,
crate::routes::auth::refresh_token, crate::routes::auth::refresh_token,
@@ -202,6 +203,7 @@ use crate::dto::{
schemas( schemas(
// Common types // Common types
ApiResponse<TokenResponse>, ApiResponse<TokenResponse>,
ApiResponse<AuthSettingsResponse>,
ApiResponse<CurrentUserResponse>, ApiResponse<CurrentUserResponse>,
ApiResponse<PackResponse>, ApiResponse<PackResponse>,
ApiResponse<PackInstallResponse>, ApiResponse<PackInstallResponse>,

View File

@@ -1,7 +1,9 @@
//! Authentication routes //! Authentication routes
use axum::{ use axum::{
extract::State, extract::{Query, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
@@ -21,11 +23,16 @@ use crate::{
TokenType, TokenType,
}, },
middleware::RequireAuth, 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, verify_password,
}, },
dto::{ dto::{
ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse,
RegisterRequest, SuccessResponse, TokenResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse,
}, },
middleware::error::ApiError, middleware::error::ApiError,
state::SharedState, state::SharedState,
@@ -63,7 +70,11 @@ pub struct SensorTokenResponse {
/// Create authentication routes /// Create authentication routes
pub fn routes() -> Router<SharedState> { pub fn routes() -> Router<SharedState> {
Router::new() Router::new()
.route("/settings", get(auth_settings))
.route("/login", post(login)) .route("/login", post(login))
.route("/oidc/login", get(oidc_login))
.route("/callback", get(oidc_callback))
.route("/logout", get(logout))
.route("/register", post(register)) .route("/register", post(register))
.route("/refresh", post(refresh_token)) .route("/refresh", post(refresh_token))
.route("/me", get(get_current_user)) .route("/me", get(get_current_user))
@@ -72,6 +83,44 @@ pub fn routes() -> Router<SharedState> {
.route("/internal/sensor-token", post(create_sensor_token_internal)) .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<AuthSettingsResponse>))
)
)]
pub async fn auth_settings(
State(state): State<SharedState>,
) -> Result<Json<ApiResponse<AuthSettingsResponse>>, 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 /// Login endpoint
/// ///
/// POST /auth/login /// POST /auth/login
@@ -221,15 +270,22 @@ pub async fn register(
)] )]
pub async fn refresh_token( pub async fn refresh_token(
State(state): State<SharedState>, State(state): State<SharedState>,
Json(payload): Json<RefreshTokenRequest>, headers: HeaderMap,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> { payload: Option<Json<RefreshTokenRequest>>,
// Validate request ) -> Result<Response, ApiError> {
payload let browser_cookie_refresh = payload.is_none();
.validate() let refresh_token = if let Some(Json(payload)) = payload {
.map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?; 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 // 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()))?; .map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?;
// Ensure it's a refresh token // Ensure it's a refresh token
@@ -257,8 +313,18 @@ pub async fn refresh_token(
refresh_token, refresh_token,
state.jwt_config.access_token_expiration, 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 /// Get current user endpoint
@@ -279,9 +345,15 @@ pub async fn refresh_token(
)] )]
pub async fn get_current_user( pub async fn get_current_user(
State(state): State<SharedState>, State(state): State<SharedState>,
RequireAuth(user): RequireAuth, headers: HeaderMap,
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
) -> Result<Json<ApiResponse<CurrentUserResponse>>, ApiError> { ) -> Result<Json<ApiResponse<CurrentUserResponse>>, 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 // Fetch identity from database
let identity = IdentityRepository::find_by_id(&state.db, identity_id) 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))) Ok(Json(ApiResponse::new(response)))
} }
#[derive(Debug, Deserialize)]
pub struct OidcLoginParams {
pub redirect_to: Option<String>,
}
/// Begin browser OIDC login by redirecting to the provider.
pub async fn oidc_login(
State(state): State<SharedState>,
Query(params): Query<OidcLoginParams>,
) -> Result<Response, ApiError> {
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<SharedState>,
headers: HeaderMap,
Query(query): Query<OidcCallbackQuery>,
) -> Result<Response, ApiError> {
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<SharedState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
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 /// Change password endpoint
/// ///
/// POST /auth/change-password /// POST /auth/change-password

View File

@@ -1779,7 +1779,6 @@ async fn handle_update(
#[serde(tag = "op", content = "value", rename_all = "snake_case")] #[serde(tag = "op", content = "value", rename_all = "snake_case")]
enum PackDescriptionPatch { enum PackDescriptionPatch {
Set(String), Set(String),
Clear,
} }
#[derive(Serialize)] #[derive(Serialize)]

View File

@@ -258,7 +258,6 @@ async fn handle_update(
#[serde(tag = "op", content = "value", rename_all = "snake_case")] #[serde(tag = "op", content = "value", rename_all = "snake_case")]
enum TriggerDescriptionPatch { enum TriggerDescriptionPatch {
Set(String), Set(String),
Clear,
} }
#[derive(Serialize)] #[derive(Serialize)]

View File

@@ -299,6 +299,14 @@ pub struct SecurityConfig {
/// Allow unauthenticated self-service user registration /// Allow unauthenticated self-service user registration
#[serde(default)] #[serde(default)]
pub allow_self_registration: bool, 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<OidcConfig>,
} }
fn default_jwt_access_expiration() -> u64 { fn default_jwt_access_expiration() -> u64 {
@@ -309,6 +317,68 @@ fn default_jwt_refresh_expiration() -> u64 {
604800 // 7 days 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=<provider_name>`.
#[serde(default = "default_oidc_provider_name")]
pub provider_name: String,
/// User-facing provider label shown on the login page.
pub provider_label: Option<String>,
/// Optional icon URL shown beside the provider label on the login page.
pub provider_icon_url: Option<String>,
/// Confidential client secret.
pub client_secret: Option<String>,
/// Redirect URI registered with the provider.
pub redirect_uri: String,
/// Optional post-logout redirect URI.
pub post_logout_redirect_uri: Option<String>,
/// Optional requested scopes in addition to `openid email profile`.
#[serde(default)]
pub scopes: Vec<String>,
}
fn default_oidc_provider_name() -> String {
"oidc".to_string()
}
/// Worker configuration /// Worker configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig { pub struct WorkerConfig {
@@ -681,6 +751,8 @@ impl Default for SecurityConfig {
encryption_key: None, encryption_key: None,
enable_auth: true, enable_auth: true,
allow_self_registration: false, 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 // Validate encryption key if provided
if let Some(ref key) = self.security.encryption_key { if let Some(ref key) = self.security.encryption_key {
if key.len() < 32 { if key.len() < 32 {
@@ -930,6 +1033,8 @@ mod tests {
encryption_key: Some("a".repeat(32)), encryption_key: Some("a".repeat(32)),
enable_auth: true, enable_auth: true,
allow_self_registration: false, allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
}, },
worker: None, worker: None,
sensor: None, sensor: None,

View File

@@ -159,6 +159,27 @@ impl IdentityRepository {
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1" "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) ).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<Option<Identity>>
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 // Permission Set Repository

View File

@@ -4,7 +4,12 @@ all-features = true
[advisories] [advisories]
version = 2 version = 2
yanked = "deny" 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] [licenses]
version = 2 version = 2
@@ -30,7 +35,14 @@ multiple-versions = "warn"
wildcards = "allow" wildcards = "allow"
highlight = "all" highlight = "all"
deny = [] 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 = [] skip-tree = []
[sources] [sources]

View File

@@ -28,7 +28,7 @@ RUN apt-get update && apt-get install -y \
WORKDIR /build WORKDIR /build
# Increase rustc stack size to prevent SIGSEGV during release builds # 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 # Copy dependency metadata first so `cargo fetch` layer is cached
# when only source code changes (Cargo.toml/Cargo.lock stay the same) # when only source code changes (Cargo.toml/Cargo.lock stay the same)

View File

@@ -31,7 +31,7 @@ RUN apt-get update && apt-get install -y \
WORKDIR /build WORKDIR /build
# Increase rustc stack size to prevent SIGSEGV during release builds # 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 # Copy dependency metadata first so `cargo fetch` layer is cached
# when only source code changes (Cargo.toml/Cargo.lock stay the same) # when only source code changes (Cargo.toml/Cargo.lock stay the same)

View File

@@ -36,7 +36,7 @@ RUN apt-get update && apt-get install -y \
WORKDIR /build WORKDIR /build
# Increase rustc stack size to prevent SIGSEGV during release builds # 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 # Copy dependency metadata first so `cargo fetch` layer is cached
# when only source code changes (Cargo.toml/Cargo.lock stay the same) # when only source code changes (Cargo.toml/Cargo.lock stay the same)

21
package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"jose": "^6.2.1"
}
}

View File

@@ -9,6 +9,7 @@ import MainLayout from "@/components/layout/MainLayout";
// Lazy-loaded page components for code splitting // Lazy-loaded page components for code splitting
const LoginPage = lazy(() => import("@/pages/auth/LoginPage")); const LoginPage = lazy(() => import("@/pages/auth/LoginPage"));
const OidcCallbackPage = lazy(() => import("@/pages/auth/OidcCallbackPage"));
const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage")); const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage"));
const PacksPage = lazy(() => import("@/pages/packs/PacksPage")); const PacksPage = lazy(() => import("@/pages/packs/PacksPage"));
const PackCreatePage = lazy(() => import("@/pages/packs/PackCreatePage")); const PackCreatePage = lazy(() => import("@/pages/packs/PackCreatePage"));
@@ -68,6 +69,7 @@ function App() {
<Routes> <Routes>
{/* Public routes */} {/* Public routes */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/login/callback" element={<OidcCallbackPage />} />
{/* Protected routes */} {/* Protected routes */}
<Route <Route

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Link, Outlet, useNavigate, useLocation } from "react-router-dom"; import { Link, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import {
Package, Package,
@@ -180,7 +180,6 @@ function NavLink({
export default function MainLayout() { export default function MainLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isCollapsed, setIsCollapsed] = useState(() => { const [isCollapsed, setIsCollapsed] = useState(() => {
// Initialize from localStorage // Initialize from localStorage
@@ -206,7 +205,6 @@ export default function MainLayout() {
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate("/login");
}; };
return ( return (

View File

@@ -7,7 +7,7 @@ import {
ReactNode, ReactNode,
} from "react"; } from "react";
import { AuthService, ApiError } from "@/api"; import { AuthService, ApiError } from "@/api";
import type { UserInfo, LoginRequest } from "@/api"; import type { UserInfo } from "@/api";
import { import {
startTokenRefreshMonitor, startTokenRefreshMonitor,
stopTokenRefreshMonitor, stopTokenRefreshMonitor,
@@ -17,10 +17,14 @@ interface AuthContextType {
user: UserInfo | null; user: UserInfo | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>; login: (redirectTo?: string) => void;
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
getToken: () => string | null; getToken: () => string | null;
completeLogin: (params: {
accessToken: string;
refreshToken: string;
}) => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -73,29 +77,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
} }
}; };
const login = async (credentials: LoginRequest) => { const login = (redirectTo?: string) => {
try { const redirectParam = redirectTo
const response = await AuthService.login({ ? `?redirect_to=${encodeURIComponent(redirectTo)}`
requestBody: credentials, : "";
}); window.location.href = `/auth/oidc/login${redirectParam}`;
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 logout = () => { const logout = () => {
@@ -103,6 +89,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
localStorage.removeItem("refresh_token"); localStorage.removeItem("refresh_token");
stopTokenRefreshMonitor(); stopTokenRefreshMonitor();
setUser(null); setUser(null);
window.location.href = "/auth/logout";
}; };
const refreshUser = async () => { const refreshUser = async () => {
@@ -113,6 +100,18 @@ export function AuthProvider({ children }: AuthProviderProps) {
return localStorage.getItem("access_token"); 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 = { const value: AuthContextType = {
user, user,
isAuthenticated: !!user, isAuthenticated: !!user,
@@ -121,6 +120,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
logout, logout,
refreshUser, refreshUser,
getToken, getToken,
completeLogin,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -1,6 +1,8 @@
import React, { useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ApiError, AuthService } from "@/api";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import apiClient from "@/lib/api-client";
interface LocationState { interface LocationState {
from?: { from?: {
@@ -8,65 +10,142 @@ interface LocationState {
}; };
} }
interface LoginError { interface AuthSettingsResponse {
response?: { authentication_enabled: boolean;
status: number; local_password_enabled: boolean;
data?: { local_password_visible_by_default: boolean;
message?: string; oidc_enabled: boolean;
}; oidc_visible_by_default: boolean;
}; oidc_provider_name: string | null;
message?: string; oidc_provider_label: string | null;
oidc_provider_icon_url: string | null;
self_registration_enabled: boolean;
} }
export default function LoginPage() { 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { login: startOidcLogin, completeLogin } = useAuth();
const [settings, setSettings] = useState<AuthSettingsResponse | null>(null);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [overrideError, setOverrideError] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(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 redirectPath = sessionStorage.getItem("redirect_after_login");
const from = const from =
redirectPath || (location.state as LocationState)?.from?.pathname || "/"; redirectPath || (location.state as LocationState)?.from?.pathname || "/";
const handleSubmit = async (e: React.FormEvent) => { useEffect(() => {
e.preventDefault(); const loadAuthSettings = async () => {
setError(""); try {
setIsLoading(true); 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<HTMLFormElement>) => {
event.preventDefault();
setLoginError(null);
setIsSubmitting(true);
try { try {
await authLogin({ login, password }); const response = await AuthService.login({
requestBody: credentials,
// Clear the redirect path from session storage });
await completeLogin({
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
});
sessionStorage.removeItem("redirect_after_login"); sessionStorage.removeItem("redirect_after_login");
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch (err: unknown) { } catch (error) {
const loginErr = err as LoginError; if (error instanceof ApiError) {
console.error("Login error:", loginErr); setLoginError(error.message);
if (loginErr.response) { } else {
console.error("Response status:", loginErr.response.status); setLoginError("Failed to sign in.");
console.error("Response data:", loginErr.response.data);
} }
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 { } finally {
setIsLoading(false); setIsSubmitting(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full">
<div> <div>
<h1 className="text-center text-4xl font-bold text-gray-900"> <h1 className="text-center text-4xl font-bold text-gray-900">
Attune Attune
@@ -75,68 +154,135 @@ export default function LoginPage() {
Sign in to your account Sign in to your account
</h2> </h2>
</div> </div>
<form <div className="mt-8 rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
className="mt-8 space-y-6" {isLoadingSettings ? (
onSubmit={handleSubmit} <div className="flex items-center gap-3 text-sm text-gray-600">
action="#" <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900" />
method="post" Loading authentication options...
>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">{error}</h3>
</div> </div>
) : (
<>
{settingsError ? (
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
{settingsError}
</div> </div>
) : null}
{overrideError ? (
<div className="mb-4 rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
{overrideError}
</div> </div>
)} ) : null}
<div className="rounded-md shadow-sm -space-y-px">
{!authEnabled ? (
<div className="rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
Authentication is disabled in the current server
configuration.
</div>
) : null}
{authEnabled && showLocal ? (
<form className="space-y-4" onSubmit={handleLocalLogin}>
<div> <div>
<label htmlFor="login" className="sr-only"> <label
Username htmlFor="login"
className="block text-sm font-medium text-gray-700"
>
Login
</label> </label>
<input <input
id="login" id="login"
name="login"
type="text" type="text"
autoComplete="username" autoComplete="username"
value={credentials.login}
onChange={(event) =>
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 required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={login}
onChange={(e) => setLogin(e.target.value)}
disabled={isLoading}
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="sr-only"> <label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password Password
</label> </label>
<input <input
id="password" id="password"
name="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
value={credentials.password}
onChange={(event) =>
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 required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/> />
</div> </div>
{loginError ? (
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
{loginError}
</div> </div>
) : null}
<div>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isLoading ? "Signing in..." : "Sign in"} {isSubmitting ? "Signing in..." : "Sign in"}
</button> </button>
</div>
</form> </form>
) : null}
{authEnabled && showLocal && showOidc ? (
<div className="my-6 flex items-center gap-3 text-xs uppercase tracking-[0.24em] text-gray-400">
<div className="h-px flex-1 bg-gray-200" />
or
<div className="h-px flex-1 bg-gray-200" />
</div>
) : null}
{authEnabled && showOidc ? (
<>
<p className="mb-4 text-sm text-gray-600">
Continue with your configured single sign-on provider.
</p>
<button
type="button"
onClick={handleOidcLogin}
className="group relative flex w-full items-center justify-center gap-3 rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{settings?.oidc_provider_icon_url ? (
<img
src={settings.oidc_provider_icon_url}
alt=""
className="h-5 w-5 rounded-sm bg-white/10 object-contain"
/>
) : null}
<span>Continue with {providerLabel}</span>
</button>
</>
) : null}
{!settingsError && authEnabled && !showLocal && !showOidc ? (
<div className="rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
No login method is shown by default for this server. Use
`?auth=direct`
{providerName ? ` or ?auth=${providerName}` : ""} to choose
a specific method.
</div>
) : null}
</>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
<h1 className="text-2xl font-semibold text-gray-900">
Completing sign-in
</h1>
<p className="mt-3 text-sm text-gray-600">
Attune is finalizing your authenticated session.
</p>
{error ? (
<div className="mt-6 rounded-lg bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
) : (
<div className="mt-6 flex items-center gap-3 text-sm text-gray-600">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900" />
Redirecting...
</div>
)}
</div>
</div>
);
}