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
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:
515
Cargo.lock
generated
515
Cargo.lock
generated
@@ -466,19 +466,23 @@ dependencies = [
|
||||
"async-trait",
|
||||
"attune-common",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"chrono",
|
||||
"clap",
|
||||
"config",
|
||||
"cookie",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"jsonschema",
|
||||
"jsonwebtoken",
|
||||
"mockall",
|
||||
"openidconnect",
|
||||
"rand 0.10.0",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest-eventsource",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml_ng",
|
||||
@@ -495,6 +499,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
"uuid",
|
||||
@@ -547,7 +552,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"config",
|
||||
"futures",
|
||||
@@ -559,7 +564,7 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.13.2",
|
||||
"ring",
|
||||
"schemars",
|
||||
"schemars 1.2.1",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -722,7 +727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -771,6 +776,29 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde_core",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backon"
|
||||
version = "1.6.0"
|
||||
@@ -780,6 +808,18 @@ dependencies = [
|
||||
"fastrand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -1087,7 +1127,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1194,6 +1234,17 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie-factory"
|
||||
version = "0.3.3"
|
||||
@@ -1232,7 +1283,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures",
|
||||
@@ -1305,7 +1356,7 @@ dependencies = [
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"itertools",
|
||||
"itertools 0.13.0",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
@@ -1325,7 +1376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
"itertools 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1428,6 +1479,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1448,14 +1511,51 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1472,13 +1572,37 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
@@ -1566,6 +1690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1594,7 +1719,7 @@ version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -1723,6 +1848,44 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1732,6 +1895,27 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
@@ -1855,6 +2039,22 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
@@ -2132,6 +2332,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2185,6 +2386,17 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -2197,7 +2409,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2215,6 +2427,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -2436,6 +2654,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2444,7 +2663,7 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
@@ -2455,7 +2674,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -2601,6 +2820,17 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -2657,6 +2887,15 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -2760,11 +2999,16 @@ version = "10.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac",
|
||||
"js-sys",
|
||||
"p256",
|
||||
"p384",
|
||||
"pem",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3211,6 +3455,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauth2"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -3248,6 +3512,37 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openidconnect"
|
||||
version = "4.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ed25519-dalek",
|
||||
"hmac",
|
||||
"http",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"oauth2",
|
||||
"p256",
|
||||
"p384",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_plain",
|
||||
"serde_with",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
@@ -3298,6 +3593,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
@@ -3320,7 +3624,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"cms",
|
||||
"der",
|
||||
@@ -3337,6 +3641,30 @@ dependencies = [
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
@@ -3409,7 +3737,7 @@ version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -3685,6 +4013,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
@@ -3729,7 +4066,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3767,7 +4104,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -4024,7 +4361,7 @@ version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -4032,16 +4369,21 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4051,6 +4393,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4059,7 +4402,7 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
@@ -4120,6 +4463,16 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4218,6 +4571,15 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
@@ -4370,6 +4732,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
@@ -4413,6 +4787,20 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4468,6 +4856,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||
dependencies = [
|
||||
"ordered-float",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -4523,6 +4921,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_plain"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
@@ -4544,13 +4951,44 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.13.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml_ng"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@@ -4729,7 +5167,7 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
@@ -4742,7 +5180,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"hashlink",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
@@ -4806,7 +5244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -4850,7 +5288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
@@ -5581,6 +6019,7 @@ dependencies = [
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5613,7 +6052,7 @@ version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa-gen",
|
||||
@@ -5639,7 +6078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"mime_guess",
|
||||
"regex",
|
||||
"rust-embed",
|
||||
@@ -5694,7 +6133,7 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"once_cell",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
@@ -5870,7 +6309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
@@ -5909,7 +6348,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"semver",
|
||||
]
|
||||
|
||||
@@ -5998,7 +6437,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6400,7 +6839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http",
|
||||
@@ -6444,7 +6883,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
@@ -6475,7 +6914,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -6494,7 +6933,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -6677,7 +7116,7 @@ dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
@@ -47,6 +47,15 @@ security:
|
||||
encryption_key: test-encryption-key-32-chars-okay
|
||||
enable_auth: true
|
||||
allow_self_registration: true
|
||||
oidc:
|
||||
enabled: false
|
||||
discovery_url: https://auth.rdrx.app/.well-known/openid-configuration
|
||||
client_id: 31d194737840d32bd3afe6474826976bae346d77247a158c4dc43887278eb605
|
||||
client_secret: null
|
||||
redirect_uri: http://localhost:3000/auth/callback
|
||||
post_logout_redirect_uri: http://localhost:3000/login
|
||||
scopes:
|
||||
- groups
|
||||
|
||||
# Packs directory (where pack action files are located)
|
||||
packs_base_dir: ./packs
|
||||
|
||||
@@ -86,6 +86,27 @@ security:
|
||||
# Enable authentication
|
||||
enable_auth: true
|
||||
|
||||
# Login page defaults for the web UI. Users can still override with:
|
||||
# /login?auth=direct
|
||||
# /login?auth=<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)
|
||||
# Uncomment and configure if running worker processes
|
||||
# worker:
|
||||
|
||||
@@ -27,6 +27,8 @@ futures = { workspace = true }
|
||||
|
||||
# Web framework
|
||||
axum = { workspace = true, features = ["multipart"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
cookie = "0.18"
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
|
||||
@@ -67,6 +69,8 @@ jsonschema = { workspace = true }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { workspace = true }
|
||||
openidconnect = "4.0"
|
||||
url = { workspace = true }
|
||||
|
||||
# Archive/compression
|
||||
tar = { workspace = true }
|
||||
@@ -88,6 +92,7 @@ hex = "0.4"
|
||||
# OpenAPI/Swagger
|
||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0", features = ["axum"] }
|
||||
jsonwebtoken = { workspace = true, features = ["rust_crypto"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header::AUTHORIZATION, StatusCode},
|
||||
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
@@ -14,6 +14,8 @@ use attune_common::auth::jwt::{
|
||||
extract_token_from_header, validate_token, Claims, JwtConfig, TokenType,
|
||||
};
|
||||
|
||||
use super::oidc::{cookie_authenticated_user, ACCESS_COOKIE_NAME};
|
||||
|
||||
/// Authentication middleware state
|
||||
#[derive(Clone)]
|
||||
pub struct AuthMiddleware {
|
||||
@@ -50,21 +52,7 @@ pub async fn require_auth(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AuthError> {
|
||||
// Extract Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(AuthError::MissingToken)?;
|
||||
|
||||
// Extract token from Bearer scheme
|
||||
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
|
||||
|
||||
// Validate token
|
||||
let claims = validate_token(token, &auth.jwt_config).map_err(|e| match e {
|
||||
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
|
||||
_ => AuthError::InvalidToken,
|
||||
})?;
|
||||
let claims = extract_claims(request.headers(), &auth.jwt_config)?;
|
||||
|
||||
// Add claims to request extensions
|
||||
request
|
||||
@@ -90,22 +78,13 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
|
||||
return Ok(RequireAuth(user.clone()));
|
||||
}
|
||||
|
||||
// Otherwise, extract and validate token directly from header
|
||||
// Extract Authorization header
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(AuthError::MissingToken)?;
|
||||
|
||||
// Extract token from Bearer scheme
|
||||
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
|
||||
|
||||
// Validate token using jwt_config from app state
|
||||
let claims = validate_token(token, &state.jwt_config).map_err(|e| match e {
|
||||
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
|
||||
_ => AuthError::InvalidToken,
|
||||
})?;
|
||||
let claims = if let Some(user) =
|
||||
cookie_authenticated_user(&parts.headers, state).map_err(map_cookie_auth_error)?
|
||||
{
|
||||
user.claims
|
||||
} else {
|
||||
extract_claims(&parts.headers, &state.jwt_config)?
|
||||
};
|
||||
|
||||
// Allow access, sensor, and execution-scoped tokens
|
||||
if claims.token_type != TokenType::Access
|
||||
@@ -119,6 +98,33 @@ impl axum::extract::FromRequestParts<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
|
||||
#[derive(Debug)]
|
||||
pub enum AuthError {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod oidc;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::{generate_token, validate_token, Claims};
|
||||
|
||||
767
crates/api/src/auth/oidc.rs
Normal file
767
crates/api/src/auth/oidc.rs
Normal 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -136,3 +136,43 @@ pub struct CurrentUserResponse {
|
||||
#[schema(example = "Administrator")]
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ pub use artifact::{
|
||||
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
|
||||
};
|
||||
pub use auth::{
|
||||
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest,
|
||||
TokenResponse,
|
||||
AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
|
||||
RefreshTokenRequest, RegisterRequest, TokenResponse,
|
||||
};
|
||||
pub use common::{
|
||||
ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse,
|
||||
|
||||
@@ -115,8 +115,9 @@ async fn mq_reconnect_loop(state: Arc<AppState>, mq_url: String) {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Install HMAC-only JWT crypto provider (must be before any token operations)
|
||||
attune_common::auth::install_crypto_provider();
|
||||
// Install a JWT crypto provider that supports both Attune's HS tokens
|
||||
// and external RS256 OIDC identity tokens.
|
||||
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
|
||||
|
||||
// Initialize tracing subscriber
|
||||
tracing_subscriber::fmt()
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::dto::{
|
||||
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest,
|
||||
},
|
||||
auth::{
|
||||
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
|
||||
RegisterRequest, TokenResponse,
|
||||
AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
|
||||
RefreshTokenRequest, RegisterRequest, TokenResponse,
|
||||
},
|
||||
common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse},
|
||||
event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary},
|
||||
@@ -68,6 +68,7 @@ use crate::dto::{
|
||||
crate::routes::health::liveness,
|
||||
|
||||
// Authentication
|
||||
crate::routes::auth::auth_settings,
|
||||
crate::routes::auth::login,
|
||||
crate::routes::auth::register,
|
||||
crate::routes::auth::refresh_token,
|
||||
@@ -202,6 +203,7 @@ use crate::dto::{
|
||||
schemas(
|
||||
// Common types
|
||||
ApiResponse<TokenResponse>,
|
||||
ApiResponse<AuthSettingsResponse>,
|
||||
ApiResponse<CurrentUserResponse>,
|
||||
ApiResponse<PackResponse>,
|
||||
ApiResponse<PackInstallResponse>,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Authentication routes
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
extract::{Query, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -21,11 +23,16 @@ use crate::{
|
||||
TokenType,
|
||||
},
|
||||
middleware::RequireAuth,
|
||||
oidc::{
|
||||
apply_cookies_to_headers, build_login_redirect, build_logout_redirect,
|
||||
cookie_authenticated_user, get_cookie_value, oidc_callback_redirect_response,
|
||||
OidcCallbackQuery, REFRESH_COOKIE_NAME,
|
||||
},
|
||||
verify_password,
|
||||
},
|
||||
dto::{
|
||||
ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
|
||||
RegisterRequest, SuccessResponse, TokenResponse,
|
||||
ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse,
|
||||
LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse,
|
||||
},
|
||||
middleware::error::ApiError,
|
||||
state::SharedState,
|
||||
@@ -63,7 +70,11 @@ pub struct SensorTokenResponse {
|
||||
/// Create authentication routes
|
||||
pub fn routes() -> Router<SharedState> {
|
||||
Router::new()
|
||||
.route("/settings", get(auth_settings))
|
||||
.route("/login", post(login))
|
||||
.route("/oidc/login", get(oidc_login))
|
||||
.route("/callback", get(oidc_callback))
|
||||
.route("/logout", get(logout))
|
||||
.route("/register", post(register))
|
||||
.route("/refresh", post(refresh_token))
|
||||
.route("/me", get(get_current_user))
|
||||
@@ -72,6 +83,44 @@ pub fn routes() -> Router<SharedState> {
|
||||
.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
|
||||
///
|
||||
/// POST /auth/login
|
||||
@@ -221,15 +270,22 @@ pub async fn register(
|
||||
)]
|
||||
pub async fn refresh_token(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<RefreshTokenRequest>,
|
||||
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?;
|
||||
headers: HeaderMap,
|
||||
payload: Option<Json<RefreshTokenRequest>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let browser_cookie_refresh = payload.is_none();
|
||||
let refresh_token = if let Some(Json(payload)) = payload {
|
||||
payload.validate().map_err(|e| {
|
||||
ApiError::ValidationError(format!("Invalid refresh token request: {}", e))
|
||||
})?;
|
||||
payload.refresh_token
|
||||
} else {
|
||||
get_cookie_value(&headers, REFRESH_COOKIE_NAME)
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing refresh token".to_string()))?
|
||||
};
|
||||
|
||||
// Validate refresh token
|
||||
let claims = validate_token(&payload.refresh_token, &state.jwt_config)
|
||||
let claims = validate_token(&refresh_token, &state.jwt_config)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?;
|
||||
|
||||
// Ensure it's a refresh token
|
||||
@@ -257,8 +313,18 @@ pub async fn refresh_token(
|
||||
refresh_token,
|
||||
state.jwt_config.access_token_expiration,
|
||||
);
|
||||
let response_body = Json(ApiResponse::new(response.clone()));
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
if browser_cookie_refresh {
|
||||
let mut http_response = response_body.into_response();
|
||||
apply_cookies_to_headers(
|
||||
http_response.headers_mut(),
|
||||
&crate::auth::oidc::build_auth_cookies(&state, &response, ""),
|
||||
)?;
|
||||
return Ok(http_response);
|
||||
}
|
||||
|
||||
Ok(response_body.into_response())
|
||||
}
|
||||
|
||||
/// Get current user endpoint
|
||||
@@ -279,9 +345,15 @@ pub async fn refresh_token(
|
||||
)]
|
||||
pub async fn get_current_user(
|
||||
State(state): State<SharedState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
headers: HeaderMap,
|
||||
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
|
||||
) -> 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
|
||||
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
@@ -297,6 +369,67 @@ pub async fn get_current_user(
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OidcLoginParams {
|
||||
pub redirect_to: Option<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
|
||||
///
|
||||
/// POST /auth/change-password
|
||||
|
||||
@@ -1779,7 +1779,6 @@ async fn handle_update(
|
||||
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
|
||||
enum PackDescriptionPatch {
|
||||
Set(String),
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -258,7 +258,6 @@ async fn handle_update(
|
||||
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
|
||||
enum TriggerDescriptionPatch {
|
||||
Set(String),
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -299,6 +299,14 @@ pub struct SecurityConfig {
|
||||
/// Allow unauthenticated self-service user registration
|
||||
#[serde(default)]
|
||||
pub allow_self_registration: bool,
|
||||
|
||||
/// Login page visibility defaults for the web UI.
|
||||
#[serde(default)]
|
||||
pub login_page: LoginPageConfig,
|
||||
|
||||
/// Optional OpenID Connect configuration for browser login.
|
||||
#[serde(default)]
|
||||
pub oidc: Option<OidcConfig>,
|
||||
}
|
||||
|
||||
fn default_jwt_access_expiration() -> u64 {
|
||||
@@ -309,6 +317,68 @@ fn default_jwt_refresh_expiration() -> u64 {
|
||||
604800 // 7 days
|
||||
}
|
||||
|
||||
/// Web login page configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginPageConfig {
|
||||
/// Show the local username/password form by default.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_local_login: bool,
|
||||
|
||||
/// Show the OIDC/SSO option by default when configured.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_oidc_login: bool,
|
||||
}
|
||||
|
||||
impl Default for LoginPageConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_local_login: true,
|
||||
show_oidc_login: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenID Connect configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcConfig {
|
||||
/// Enable OpenID Connect login flow.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
/// OpenID Provider discovery document URL.
|
||||
pub discovery_url: String,
|
||||
|
||||
/// Confidential client ID.
|
||||
pub client_id: String,
|
||||
|
||||
/// Provider name used in login-page overrides such as `?auth=<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
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkerConfig {
|
||||
@@ -681,6 +751,8 @@ impl Default for SecurityConfig {
|
||||
encryption_key: None,
|
||||
enable_auth: true,
|
||||
allow_self_registration: false,
|
||||
login_page: LoginPageConfig::default(),
|
||||
oidc: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,6 +872,37 @@ impl Config {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(oidc) = &self.security.oidc {
|
||||
if oidc.enabled {
|
||||
if oidc.discovery_url.trim().is_empty() {
|
||||
return Err(crate::Error::validation(
|
||||
"OIDC discovery URL cannot be empty when OIDC is enabled",
|
||||
));
|
||||
}
|
||||
if oidc.client_id.trim().is_empty() {
|
||||
return Err(crate::Error::validation(
|
||||
"OIDC client ID cannot be empty when OIDC is enabled",
|
||||
));
|
||||
}
|
||||
if oidc
|
||||
.client_secret
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(crate::Error::validation(
|
||||
"OIDC client secret is required when OIDC is enabled",
|
||||
));
|
||||
}
|
||||
if oidc.redirect_uri.trim().is_empty() {
|
||||
return Err(crate::Error::validation(
|
||||
"OIDC redirect URI cannot be empty when OIDC is enabled",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate encryption key if provided
|
||||
if let Some(ref key) = self.security.encryption_key {
|
||||
if key.len() < 32 {
|
||||
@@ -930,6 +1033,8 @@ mod tests {
|
||||
encryption_key: Some("a".repeat(32)),
|
||||
enable_auth: true,
|
||||
allow_self_registration: false,
|
||||
login_page: LoginPageConfig::default(),
|
||||
oidc: None,
|
||||
},
|
||||
worker: None,
|
||||
sensor: None,
|
||||
|
||||
@@ -159,6 +159,27 @@ impl IdentityRepository {
|
||||
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1"
|
||||
).bind(login).fetch_optional(executor).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_by_oidc_subject<'e, E>(
|
||||
executor: E,
|
||||
issuer: &str,
|
||||
subject: &str,
|
||||
) -> Result<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
|
||||
|
||||
16
deny.toml
16
deny.toml
@@ -4,7 +4,12 @@ all-features = true
|
||||
[advisories]
|
||||
version = 2
|
||||
yanked = "deny"
|
||||
ignore = []
|
||||
ignore = [
|
||||
# jsonwebtoken's RSA support is required for verifying external RS256 OIDC ID tokens.
|
||||
# No patched rsa release is available yet for RUSTSEC-2023-0071, so tolerate it until
|
||||
# the upstream ecosystem provides a safe replacement or fix.
|
||||
"RUSTSEC-2023-0071",
|
||||
]
|
||||
|
||||
[licenses]
|
||||
version = 2
|
||||
@@ -30,7 +35,14 @@ multiple-versions = "warn"
|
||||
wildcards = "allow"
|
||||
highlight = "all"
|
||||
deny = []
|
||||
skip = []
|
||||
skip = [
|
||||
"winnow@0.6.26",
|
||||
"winnow@0.7.15",
|
||||
"windows_x86_64_msvc@0.42.2",
|
||||
"windows_x86_64_msvc@0.48.5",
|
||||
"windows_x86_64_msvc@0.52.6",
|
||||
"windows_x86_64_msvc@0.53.1",
|
||||
]
|
||||
skip-tree = []
|
||||
|
||||
[sources]
|
||||
|
||||
@@ -28,7 +28,7 @@ RUN apt-get update && apt-get install -y \
|
||||
WORKDIR /build
|
||||
|
||||
# Increase rustc stack size to prevent SIGSEGV during release builds
|
||||
ENV RUST_MIN_STACK=16777216
|
||||
ENV RUST_MIN_STACK=33554432
|
||||
|
||||
# Copy dependency metadata first so `cargo fetch` layer is cached
|
||||
# when only source code changes (Cargo.toml/Cargo.lock stay the same)
|
||||
|
||||
@@ -31,7 +31,7 @@ RUN apt-get update && apt-get install -y \
|
||||
WORKDIR /build
|
||||
|
||||
# Increase rustc stack size to prevent SIGSEGV during release builds
|
||||
ENV RUST_MIN_STACK=16777216
|
||||
ENV RUST_MIN_STACK=33554432
|
||||
|
||||
# Copy dependency metadata first so `cargo fetch` layer is cached
|
||||
# when only source code changes (Cargo.toml/Cargo.lock stay the same)
|
||||
|
||||
@@ -36,7 +36,7 @@ RUN apt-get update && apt-get install -y \
|
||||
WORKDIR /build
|
||||
|
||||
# Increase rustc stack size to prevent SIGSEGV during release builds
|
||||
ENV RUST_MIN_STACK=16777216
|
||||
ENV RUST_MIN_STACK=33554432
|
||||
|
||||
# Copy dependency metadata first so `cargo fetch` layer is cached
|
||||
# when only source code changes (Cargo.toml/Cargo.lock stay the same)
|
||||
|
||||
21
package-lock.json
generated
Normal file
21
package-lock.json
generated
Normal 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
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"jose": "^6.2.1"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import MainLayout from "@/components/layout/MainLayout";
|
||||
|
||||
// Lazy-loaded page components for code splitting
|
||||
const LoginPage = lazy(() => import("@/pages/auth/LoginPage"));
|
||||
const OidcCallbackPage = lazy(() => import("@/pages/auth/OidcCallbackPage"));
|
||||
const DashboardPage = lazy(() => import("@/pages/dashboard/DashboardPage"));
|
||||
const PacksPage = lazy(() => import("@/pages/packs/PacksPage"));
|
||||
const PackCreatePage = lazy(() => import("@/pages/packs/PackCreatePage"));
|
||||
@@ -68,6 +69,7 @@ function App() {
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/callback" element={<OidcCallbackPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
Package,
|
||||
@@ -180,7 +180,6 @@ function NavLink({
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
// Initialize from localStorage
|
||||
@@ -206,7 +205,6 @@ export default function MainLayout() {
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { AuthService, ApiError } from "@/api";
|
||||
import type { UserInfo, LoginRequest } from "@/api";
|
||||
import type { UserInfo } from "@/api";
|
||||
import {
|
||||
startTokenRefreshMonitor,
|
||||
stopTokenRefreshMonitor,
|
||||
@@ -17,10 +17,14 @@ interface AuthContextType {
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
login: (redirectTo?: string) => void;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
getToken: () => string | null;
|
||||
completeLogin: (params: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -73,29 +77,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
try {
|
||||
const response = await AuthService.login({
|
||||
requestBody: credentials,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token, user: userInfo } = response.data;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
|
||||
// If user info is included in response, use it; otherwise load it
|
||||
if (userInfo) {
|
||||
setUser(userInfo);
|
||||
} else {
|
||||
await loadUser();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
if (error instanceof ApiError) {
|
||||
console.error(`API Error ${error.status}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const login = (redirectTo?: string) => {
|
||||
const redirectParam = redirectTo
|
||||
? `?redirect_to=${encodeURIComponent(redirectTo)}`
|
||||
: "";
|
||||
window.location.href = `/auth/oidc/login${redirectParam}`;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
@@ -103,6 +89,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
localStorage.removeItem("refresh_token");
|
||||
stopTokenRefreshMonitor();
|
||||
setUser(null);
|
||||
window.location.href = "/auth/logout";
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
@@ -113,6 +100,18 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
return localStorage.getItem("access_token");
|
||||
};
|
||||
|
||||
const completeLogin = async ({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
localStorage.setItem("access_token", accessToken);
|
||||
localStorage.setItem("refresh_token", refreshToken);
|
||||
await loadUser();
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
@@ -121,6 +120,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
logout,
|
||||
refreshUser,
|
||||
getToken,
|
||||
completeLogin,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ApiError, AuthService } from "@/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import apiClient from "@/lib/api-client";
|
||||
|
||||
interface LocationState {
|
||||
from?: {
|
||||
@@ -8,65 +10,142 @@ interface LocationState {
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginError {
|
||||
response?: {
|
||||
status: number;
|
||||
data?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
interface AuthSettingsResponse {
|
||||
authentication_enabled: boolean;
|
||||
local_password_enabled: boolean;
|
||||
local_password_visible_by_default: boolean;
|
||||
oidc_enabled: boolean;
|
||||
oidc_visible_by_default: boolean;
|
||||
oidc_provider_name: string | null;
|
||||
oidc_provider_label: string | null;
|
||||
oidc_provider_icon_url: string | null;
|
||||
self_registration_enabled: boolean;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login: authLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login: startOidcLogin, completeLogin } = useAuth();
|
||||
const [settings, setSettings] = useState<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 from =
|
||||
redirectPath || (location.state as LocationState)?.from?.pathname || "/";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
useEffect(() => {
|
||||
const loadAuthSettings = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<{ data: AuthSettingsResponse }>(
|
||||
"/auth/settings",
|
||||
);
|
||||
setSettings(response.data.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load auth settings:", error);
|
||||
setSettingsError("Unable to load authentication options.");
|
||||
} finally {
|
||||
setIsLoadingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAuthSettings();
|
||||
}, []);
|
||||
|
||||
const authOverride = new URLSearchParams(location.search)
|
||||
.get("auth")
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const localEnabled = settings?.local_password_enabled ?? false;
|
||||
const oidcEnabled = settings?.oidc_enabled ?? false;
|
||||
const authEnabled = settings?.authentication_enabled ?? true;
|
||||
const providerName = settings?.oidc_provider_name?.toLowerCase() ?? null;
|
||||
const providerLabel =
|
||||
settings?.oidc_provider_label ?? settings?.oidc_provider_name ?? "SSO";
|
||||
|
||||
let showLocal = settings?.local_password_visible_by_default ?? false;
|
||||
let showOidc = settings?.oidc_visible_by_default ?? false;
|
||||
|
||||
if (authOverride === "direct") {
|
||||
if (localEnabled) {
|
||||
showLocal = true;
|
||||
showOidc = false;
|
||||
}
|
||||
} else if (authOverride && providerName && authOverride === providerName) {
|
||||
if (oidcEnabled) {
|
||||
showLocal = false;
|
||||
showOidc = true;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!authOverride || !settings) {
|
||||
setOverrideError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authOverride === "direct") {
|
||||
setOverrideError(
|
||||
localEnabled
|
||||
? null
|
||||
: "Local login was requested, but it is not available on this server.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerName && authOverride === providerName) {
|
||||
setOverrideError(
|
||||
oidcEnabled
|
||||
? null
|
||||
: `${providerLabel} was requested, but it is not available on this server.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOverrideError(
|
||||
`Unknown authentication override '${authOverride}'. Falling back to the server defaults.`,
|
||||
);
|
||||
}, [authOverride, localEnabled, oidcEnabled, providerLabel, providerName, settings]);
|
||||
|
||||
const handleOidcLogin = () => {
|
||||
sessionStorage.setItem("redirect_after_login", from);
|
||||
startOidcLogin(from);
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLoginError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await authLogin({ login, password });
|
||||
|
||||
// Clear the redirect path from session storage
|
||||
const response = await AuthService.login({
|
||||
requestBody: credentials,
|
||||
});
|
||||
await completeLogin({
|
||||
accessToken: response.data.access_token,
|
||||
refreshToken: response.data.refresh_token,
|
||||
});
|
||||
sessionStorage.removeItem("redirect_after_login");
|
||||
|
||||
navigate(from, { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const loginErr = err as LoginError;
|
||||
console.error("Login error:", loginErr);
|
||||
if (loginErr.response) {
|
||||
console.error("Response status:", loginErr.response.status);
|
||||
console.error("Response data:", loginErr.response.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setLoginError(error.message);
|
||||
} else {
|
||||
setLoginError("Failed to sign in.");
|
||||
}
|
||||
const errorMessage =
|
||||
loginErr.response?.data?.message ||
|
||||
loginErr.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
setError(errorMessage);
|
||||
// Don't navigate on error - stay on login page
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h1 className="text-center text-4xl font-bold text-gray-900">
|
||||
Attune
|
||||
@@ -75,68 +154,135 @@ export default function LoginPage() {
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={handleSubmit}
|
||||
action="#"
|
||||
method="post"
|
||||
>
|
||||
{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 className="mt-8 rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
|
||||
{isLoadingSettings ? (
|
||||
<div className="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" />
|
||||
Loading authentication options...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{settingsError ? (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
|
||||
{settingsError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="login" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
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>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
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>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
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"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{overrideError ? (
|
||||
<div className="mb-4 rounded-lg bg-amber-50 p-4 text-sm text-amber-800">
|
||||
{overrideError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!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>
|
||||
<label
|
||||
htmlFor="login"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Login
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
type="text"
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="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
|
||||
/>
|
||||
</div>
|
||||
{loginError ? (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">
|
||||
{loginError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
63
web/src/pages/auth/OidcCallbackPage.tsx
Normal file
63
web/src/pages/auth/OidcCallbackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user