mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 10:14:35 +00:00
feat(web): add OIDC SSO login support (#1943)
This commit is contained in:
Generated
+250
-82
@@ -514,29 +514,6 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
|
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aws-lc-rs"
|
|
||||||
version = "1.13.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba"
|
|
||||||
dependencies = [
|
|
||||||
"aws-lc-sys",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aws-lc-sys"
|
|
||||||
version = "0.30.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff"
|
|
||||||
dependencies = [
|
|
||||||
"bindgen 0.69.5",
|
|
||||||
"cc",
|
|
||||||
"cmake",
|
|
||||||
"dunce",
|
|
||||||
"fs_extra",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.7.7"
|
version = "0.7.7"
|
||||||
@@ -746,6 +723,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base62"
|
name = "base62"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -790,29 +773,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bindgen"
|
|
||||||
version = "0.69.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.8.0",
|
|
||||||
"cexpr",
|
|
||||||
"clang-sys",
|
|
||||||
"itertools 0.12.1",
|
|
||||||
"lazy_static",
|
|
||||||
"lazycell",
|
|
||||||
"log",
|
|
||||||
"prettyplease",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"regex",
|
|
||||||
"rustc-hash 1.1.0",
|
|
||||||
"shlex",
|
|
||||||
"syn 2.0.87",
|
|
||||||
"which 4.4.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.72.1"
|
version = "0.72.1"
|
||||||
@@ -826,7 +786,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash 2.1.0",
|
"rustc-hash",
|
||||||
"shlex",
|
"shlex",
|
||||||
"syn 2.0.87",
|
"syn 2.0.87",
|
||||||
]
|
]
|
||||||
@@ -1390,15 +1350,6 @@ dependencies = [
|
|||||||
"error-code",
|
"error-code",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cmake"
|
|
||||||
version = "0.1.54"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codepage"
|
name = "codepage"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1699,6 +1650,18 @@ version = "0.8.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
|
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-bigint"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -1765,6 +1728,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -2368,7 +2332,6 @@ dependencies = [
|
|||||||
"gethostname 1.0.2",
|
"gethostname 1.0.2",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls",
|
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2456,8 +2419,10 @@ dependencies = [
|
|||||||
"maxminddb",
|
"maxminddb",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"openidconnect",
|
||||||
"password-auth",
|
"password-auth",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"rust-i18n",
|
"rust-i18n",
|
||||||
"rusttype",
|
"rusttype",
|
||||||
@@ -2466,6 +2431,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"subtle",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"thiserror 1.0.63",
|
"thiserror 1.0.63",
|
||||||
"thunk-rs",
|
"thunk-rs",
|
||||||
@@ -2478,6 +2444,44 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.16.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"digest",
|
||||||
|
"elliptic-curve",
|
||||||
|
"rfc6979",
|
||||||
|
"signature",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
@@ -2487,6 +2491,27 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "elliptic-curve"
|
||||||
|
version = "0.13.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"crypto-bigint",
|
||||||
|
"digest",
|
||||||
|
"ff",
|
||||||
|
"generic-array",
|
||||||
|
"group",
|
||||||
|
"hkdf",
|
||||||
|
"pem-rfc7468",
|
||||||
|
"pkcs8",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"sec1",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.5"
|
version = "3.0.5"
|
||||||
@@ -2755,6 +2780,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -2886,12 +2921,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
|
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fs_extra"
|
|
||||||
version = "1.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "funty"
|
name = "funty"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -3156,6 +3185,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3376,6 +3406,17 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "group"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||||
|
dependencies = [
|
||||||
|
"ff",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gtk"
|
name = "gtk"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -3801,6 +3842,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4374,7 +4416,7 @@ source = "git+https://github.com/EasyTier/kcp-sys?rev=94964794caaed5d388463137da
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"auto_impl",
|
"auto_impl",
|
||||||
"bindgen 0.72.1",
|
"bindgen",
|
||||||
"bitflags 2.8.0",
|
"bitflags 2.8.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -4421,12 +4463,6 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lazycell"
|
|
||||||
version = "1.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libappindicator"
|
name = "libappindicator"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -5292,6 +5328,26 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oauth2"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"chrono",
|
||||||
|
"getrandom 0.2.15",
|
||||||
|
"http",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 1.0.63",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -5574,6 +5630,37 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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.63",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.66"
|
version = "0.10.66"
|
||||||
@@ -5634,6 +5721,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-float"
|
||||||
|
version = "2.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "3.9.2"
|
version = "3.9.2"
|
||||||
@@ -5723,6 +5819,30 @@ dependencies = [
|
|||||||
"ttf-parser",
|
"ttf-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p256"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa",
|
||||||
|
"elliptic-curve",
|
||||||
|
"primeorder",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p384"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa",
|
||||||
|
"elliptic-curve",
|
||||||
|
"primeorder",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -6315,6 +6435,15 @@ dependencies = [
|
|||||||
"syn 2.0.87",
|
"syn 2.0.87",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primeorder"
|
||||||
|
version = "0.13.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||||
|
dependencies = [
|
||||||
|
"elliptic-curve",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -6552,7 +6681,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash 2.1.0",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"thiserror 2.0.11",
|
"thiserror 2.0.11",
|
||||||
@@ -6585,7 +6714,7 @@ dependencies = [
|
|||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash 2.1.0",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
@@ -6921,7 +7050,10 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
@@ -6929,6 +7061,7 @@ dependencies = [
|
|||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -6937,6 +7070,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6946,6 +7080,16 @@ version = "0.7.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc7c8f7f733062b66dc1c63f9db168ac0b97a9210e247fa90fdc9ad08f51b302"
|
checksum = "fc7c8f7f733062b66dc1c63f9db168ac0b97a9210e247fa90fdc9ad08f51b302"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfc6979"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||||
|
dependencies = [
|
||||||
|
"hmac",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -7175,12 +7319,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -7228,8 +7366,6 @@ version = "0.23.27"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
|
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"log",
|
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -7301,7 +7437,6 @@ version = "0.103.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
|
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -7508,7 +7643,7 @@ dependencies = [
|
|||||||
"bigdecimal",
|
"bigdecimal",
|
||||||
"chrono",
|
"chrono",
|
||||||
"inherent",
|
"inherent",
|
||||||
"ordered-float",
|
"ordered-float 3.9.2",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"sea-query-derive",
|
"sea-query-derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -7575,6 +7710,20 @@ version = "4.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sec1"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"der",
|
||||||
|
"generic-array",
|
||||||
|
"pkcs8",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -7659,6 +7808,16 @@ dependencies = [
|
|||||||
"typeid",
|
"typeid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde-value"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
||||||
|
dependencies = [
|
||||||
|
"ordered-float 2.10.1",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -7725,6 +7884,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_plain"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ uuid = { version = "1.5.0", features = [
|
|||||||
] }
|
] }
|
||||||
|
|
||||||
chrono = { version = "0.4.37", features = ["serde"] }
|
chrono = { version = "0.4.37", features = ["serde"] }
|
||||||
|
openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps", "reqwest"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
subtle = "2.6"
|
||||||
|
|
||||||
mimalloc = { version = "*" }
|
mimalloc = { version = "*" }
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ web:
|
|||||||
captcha: 验证码
|
captcha: 验证码
|
||||||
back_to_login: 返回登录
|
back_to_login: 返回登录
|
||||||
login: 登录
|
login: 登录
|
||||||
|
sso_login: "SSO 登录"
|
||||||
|
|
||||||
register:
|
register:
|
||||||
title: 注册
|
title: 注册
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ web:
|
|||||||
captcha: Captcha
|
captcha: Captcha
|
||||||
back_to_login: Back to Login
|
back_to_login: Back to Login
|
||||||
login: Login
|
login: Login
|
||||||
|
sso_login: "SSO Login"
|
||||||
|
|
||||||
register:
|
register:
|
||||||
title: Register
|
title: Register
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
|
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
@@ -68,8 +68,43 @@ const apiHostSearch = async (event: { query: string }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const oidcEnabled = ref(false);
|
||||||
|
const lastCheckedHost = ref('');
|
||||||
|
const oidcCheckTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const checkOidcConfig = () => {
|
||||||
|
if (oidcCheckTimer.value) clearTimeout(oidcCheckTimer.value);
|
||||||
|
oidcCheckTimer.value = setTimeout(async () => {
|
||||||
|
const host = apiHost.value;
|
||||||
|
if (host === lastCheckedHost.value) return;
|
||||||
|
|
||||||
|
const enabled = (await new ApiClient(host).getOidcConfig()).enabled;
|
||||||
|
// If host changes while request is in-flight, do not overwrite UI state.
|
||||||
|
if (apiHost.value !== host) return;
|
||||||
|
|
||||||
|
lastCheckedHost.value = host;
|
||||||
|
oidcEnabled.value = enabled;
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(apiHost, () => {
|
||||||
|
checkOidcConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSsoLogin = () => {
|
||||||
|
saveApiHost(apiHost.value);
|
||||||
|
localStorage.setItem('apiHost', btoa(apiHost.value));
|
||||||
|
window.location.href = api.value.oidcLoginUrl();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkOidcConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (oidcCheckTimer.value) {
|
||||||
|
clearTimeout(oidcCheckTimer.value);
|
||||||
|
oidcCheckTimer.value = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -104,6 +139,10 @@ onMounted(() => {
|
|||||||
<Button :label="t('web.login.register')" type="button" class="w-full"
|
<Button :label="t('web.login.register')" type="button" class="w-full"
|
||||||
@click="saveApiHost(apiHost); $router.replace({ name: 'register' })" severity="secondary" />
|
@click="saveApiHost(apiHost); $router.replace({ name: 'register' })" severity="secondary" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="oidcEnabled" class="flex items-center justify-between">
|
||||||
|
<Button :label="t('web.login.sso_login')" type="button" class="w-full" severity="info"
|
||||||
|
@click="onSsoLogin" />
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export interface ValidateConfigResponse {
|
|||||||
toml_config: string;
|
toml_config: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OidcConfigResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 定义接口返回的数据结构
|
// 定义接口返回的数据结构
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -174,6 +178,19 @@ export class ApiClient {
|
|||||||
return this.client.defaults.baseURL + '/auth/captcha';
|
return this.client.defaults.baseURL + '/auth/captcha';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getOidcConfig(): Promise<OidcConfigResponse> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.get<any, OidcConfigResponse>('/auth/oidc/config');
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public oidcLoginUrl() {
|
||||||
|
return this.client.defaults.baseURL + '/auth/oidc/login';
|
||||||
|
}
|
||||||
|
|
||||||
public get_remote_client(machine_id: string): Api.RemoteClient {
|
public get_remote_client(machine_id: string): Api.RemoteClient {
|
||||||
return new WebRemoteClient(machine_id, this.client);
|
return new WebRemoteClient(machine_id, this.client);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,3 +43,30 @@ cli:
|
|||||||
disable_registration:
|
disable_registration:
|
||||||
en: "Disable user registration"
|
en: "Disable user registration"
|
||||||
zh-CN: "禁用用户注册"
|
zh-CN: "禁用用户注册"
|
||||||
|
oidc_issuer_url:
|
||||||
|
en: "The OIDC issuer URL for single sign-on authentication"
|
||||||
|
zh-CN: "OIDC 签发者 URL,用于单点登录认证"
|
||||||
|
oidc_client_id:
|
||||||
|
en: "The OIDC client ID"
|
||||||
|
zh-CN: "OIDC 客户端 ID"
|
||||||
|
oidc_client_secret:
|
||||||
|
en: "The OIDC client secret (can also be set via OIDC_CLIENT_SECRET env var)"
|
||||||
|
zh-CN: "OIDC 客户端密钥(也可通过 OIDC_CLIENT_SECRET 环境变量设置)"
|
||||||
|
oidc_username_claim:
|
||||||
|
en: "The OIDC claim to use as the local username, default: preferred_username"
|
||||||
|
zh-CN: "用作本地用户名的 OIDC claim 字段,默认: preferred_username"
|
||||||
|
oidc_scopes:
|
||||||
|
en: "OIDC scopes to request during login. Supports comma-separated values or repeated --oidc-scopes flags, default: openid,profile"
|
||||||
|
zh-CN: "登录时请求的 OIDC scopes。支持逗号分隔或多次指定 --oidc-scopes,默认: openid,profile"
|
||||||
|
oidc_redirect_url:
|
||||||
|
en: "The OIDC redirect URL (callback URL), must match exactly what is registered with your Identity Provider. Required when using OIDC. Example: http://your-domain.com:11211/api/v1/auth/oidc/callback"
|
||||||
|
zh-CN: "OIDC 重定向 URL(回调 URL),必须与身份提供商注册的地址完全一致。使用 OIDC 时必须提供。示例: http://your-domain.com:11211/api/v1/auth/oidc/callback"
|
||||||
|
allow_auto_create_user:
|
||||||
|
en: "Allow auto-creating local user when easytier-core connects with an unknown username"
|
||||||
|
zh-CN: "当 easytier-core 使用未知用户名连接时,允许自动创建本地用户"
|
||||||
|
oidc_disable_pkce:
|
||||||
|
en: "Disable PKCE (Proof Key for Code Exchange) for OIDC authentication"
|
||||||
|
zh-CN: "禁用 OIDC 认证的 PKCE(授权码交换证明密钥)"
|
||||||
|
oidc_frontend_base_url:
|
||||||
|
en: "Frontend base URL to redirect to after successful OIDC callback. Required when frontend and API are deployed separately (non-embed build, --no-web mode, or different web_server_port)"
|
||||||
|
zh-CN: "OIDC 回调成功后跳转的前端入口地址。当前端与 API 分离部署时必须提供(非 embed 构建、--no-web 模式、或 web_server_port 与 api_server_port 不同)"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use easytier::{
|
|||||||
use maxminddb::geoip2;
|
use maxminddb::geoip2;
|
||||||
use session::{Location, Session};
|
use session::{Location, Session};
|
||||||
use storage::{Storage, StorageToken};
|
use storage::{Storage, StorageToken};
|
||||||
|
|
||||||
|
use crate::FeatureFlags;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
use crate::db::{entity::user_running_network_configs, Db, UserIdInDb};
|
use crate::db::{entity::user_running_network_configs, Db, UserIdInDb};
|
||||||
@@ -55,11 +57,13 @@ pub struct ClientManager {
|
|||||||
client_sessions: Arc<DashMap<url::Url, Arc<Session>>>,
|
client_sessions: Arc<DashMap<url::Url, Arc<Session>>>,
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
|
|
||||||
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
|
||||||
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientManager {
|
impl ClientManager {
|
||||||
pub fn new(db: Db, geoip_db: Option<String>) -> Self {
|
pub fn new(db: Db, geoip_db: Option<String>, feature_flags: Arc<FeatureFlags>) -> Self {
|
||||||
let client_sessions = Arc::new(DashMap::new());
|
let client_sessions = Arc::new(DashMap::new());
|
||||||
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
|
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
@@ -76,6 +80,8 @@ impl ClientManager {
|
|||||||
|
|
||||||
client_sessions,
|
client_sessions,
|
||||||
storage: Storage::new(db),
|
storage: Storage::new(db),
|
||||||
|
feature_flags,
|
||||||
|
|
||||||
geoip_db: Arc::new(load_geoip_db(geoip_db)),
|
geoip_db: Arc::new(load_geoip_db(geoip_db)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +96,7 @@ impl ClientManager {
|
|||||||
let storage = self.storage.weak_ref();
|
let storage = self.storage.weak_ref();
|
||||||
let listeners_cnt = self.listeners_cnt.clone();
|
let listeners_cnt = self.listeners_cnt.clone();
|
||||||
let geoip_db = self.geoip_db.clone();
|
let geoip_db = self.geoip_db.clone();
|
||||||
|
let feature_flags = self.feature_flags.clone();
|
||||||
self.tasks.spawn(async move {
|
self.tasks.spawn(async move {
|
||||||
while let Ok(tunnel) = listener.accept().await {
|
while let Ok(tunnel) = listener.accept().await {
|
||||||
let info = tunnel.info().unwrap();
|
let info = tunnel.info().unwrap();
|
||||||
@@ -100,7 +107,12 @@ impl ClientManager {
|
|||||||
client_url,
|
client_url,
|
||||||
location
|
location
|
||||||
);
|
);
|
||||||
let mut session = Session::new(storage.clone(), client_url.clone(), location);
|
let mut session = Session::new(
|
||||||
|
storage.clone(),
|
||||||
|
client_url.clone(),
|
||||||
|
location,
|
||||||
|
feature_flags.clone(),
|
||||||
|
);
|
||||||
session.serve(tunnel).await;
|
session.serve(tunnel).await;
|
||||||
sessions.insert(client_url, Arc::new(session));
|
sessions.insert(client_url, Arc::new(session));
|
||||||
}
|
}
|
||||||
@@ -291,12 +303,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use sqlx::Executor;
|
use sqlx::Executor;
|
||||||
|
|
||||||
use crate::{client_manager::ClientManager, db::Db};
|
use crate::{client_manager::ClientManager, db::Db, FeatureFlags};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_client() {
|
async fn test_client() {
|
||||||
let listener = UdpTunnelListener::new("udp://0.0.0.0:54333".parse().unwrap());
|
let listener = UdpTunnelListener::new("udp://0.0.0.0:54333".parse().unwrap());
|
||||||
let mut mgr = ClientManager::new(Db::memory_db().await, None);
|
let mut mgr = ClientManager::new(
|
||||||
|
Db::memory_db().await,
|
||||||
|
None,
|
||||||
|
Arc::new(FeatureFlags::default()),
|
||||||
|
);
|
||||||
mgr.add_listener(Box::new(listener)).await.unwrap();
|
mgr.add_listener(Box::new(listener)).await.unwrap();
|
||||||
|
|
||||||
mgr.db()
|
mgr.db()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use easytier::{
|
|||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
|
||||||
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
||||||
|
use crate::FeatureFlags;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct Location {
|
pub struct Location {
|
||||||
@@ -29,6 +30,7 @@ pub struct Location {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionData {
|
pub struct SessionData {
|
||||||
storage: WeakRefStorage,
|
storage: WeakRefStorage,
|
||||||
|
feature_flags: Arc<FeatureFlags>,
|
||||||
client_url: url::Url,
|
client_url: url::Url,
|
||||||
|
|
||||||
storage_token: Option<StorageToken>,
|
storage_token: Option<StorageToken>,
|
||||||
@@ -38,11 +40,17 @@ pub struct SessionData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionData {
|
impl SessionData {
|
||||||
fn new(storage: WeakRefStorage, client_url: url::Url, location: Option<Location>) -> Self {
|
fn new(
|
||||||
|
storage: WeakRefStorage,
|
||||||
|
client_url: url::Url,
|
||||||
|
location: Option<Location>,
|
||||||
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
) -> Self {
|
||||||
let (tx, _rx1) = broadcast::channel(2);
|
let (tx, _rx1) = broadcast::channel(2);
|
||||||
|
|
||||||
SessionData {
|
SessionData {
|
||||||
storage,
|
storage,
|
||||||
|
feature_flags,
|
||||||
client_url,
|
client_url,
|
||||||
storage_token: None,
|
storage_token: None,
|
||||||
notifier: tx,
|
notifier: tx,
|
||||||
@@ -98,7 +106,7 @@ impl SessionRpcService {
|
|||||||
req.machine_id
|
req.machine_id
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let user_id = storage
|
let user_id = match storage
|
||||||
.db()
|
.db()
|
||||||
.get_user_id_by_token(req.user_token.clone())
|
.get_user_id_by_token(req.user_token.clone())
|
||||||
.await
|
.await
|
||||||
@@ -107,11 +115,18 @@ impl SessionRpcService {
|
|||||||
"Failed to get user id by token from db: {:?}",
|
"Failed to get user id by token from db: {:?}",
|
||||||
req.user_token
|
req.user_token
|
||||||
)
|
)
|
||||||
})?
|
})? {
|
||||||
.ok_or(anyhow::anyhow!(
|
Some(id) => id,
|
||||||
"User not found by token: {:?}",
|
None if data.feature_flags.allow_auto_create_user => storage
|
||||||
req.user_token
|
.auto_create_user(&req.user_token)
|
||||||
))?;
|
.await
|
||||||
|
.with_context(|| format!("Failed to auto-create user: {:?}", req.user_token))?,
|
||||||
|
None => {
|
||||||
|
return Err(
|
||||||
|
anyhow::anyhow!("User not found by token: {:?}", req.user_token).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if data.req.replace(req.clone()).is_none() {
|
if data.req.replace(req.clone()).is_none() {
|
||||||
assert!(data.storage_token.is_none());
|
assert!(data.storage_token.is_none());
|
||||||
@@ -173,8 +188,13 @@ impl Debug for Session {
|
|||||||
type SessionRpcClient = Box<dyn WebClientService<Controller = BaseController> + Send>;
|
type SessionRpcClient = Box<dyn WebClientService<Controller = BaseController> + Send>;
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn new(storage: WeakRefStorage, client_url: url::Url, location: Option<Location>) -> Self {
|
pub fn new(
|
||||||
let session_data = SessionData::new(storage, client_url, location);
|
storage: WeakRefStorage,
|
||||||
|
client_url: url::Url,
|
||||||
|
location: Option<Location>,
|
||||||
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
) -> Self {
|
||||||
|
let session_data = SessionData::new(storage, client_url, location, feature_flags);
|
||||||
let data = Arc::new(RwLock::new(session_data));
|
let data = Arc::new(RwLock::new(session_data));
|
||||||
|
|
||||||
let rpc_mgr =
|
let rpc_mgr =
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ struct ClientInfo {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct StorageInner {
|
pub struct StorageInner {
|
||||||
// some map for indexing
|
|
||||||
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
|
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
|
||||||
pub db: Db,
|
pub db: Db,
|
||||||
}
|
}
|
||||||
@@ -123,4 +122,10 @@ impl Storage {
|
|||||||
pub fn db(&self) -> &Db {
|
pub fn db(&self) -> &Db {
|
||||||
&self.0.db
|
&self.0.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn auto_create_user(&self, username: &str) -> anyhow::Result<UserIdInDb> {
|
||||||
|
let new_user = self.db().auto_create_user(username).await?;
|
||||||
|
tracing::info!("Auto-created user '{}' with id {}", username, new_user.id);
|
||||||
|
Ok(new_user.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use easytier::{
|
|||||||
use entity::user_running_network_configs;
|
use entity::user_running_network_configs;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
prelude::Expr, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
|
prelude::Expr, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
|
||||||
QueryFilter as _, SqlxSqliteConnector, TransactionTrait as _,
|
QueryFilter as _, Set, SqlxSqliteConnector, TransactionTrait as _,
|
||||||
};
|
};
|
||||||
use sea_orm_migration::MigratorTrait as _;
|
use sea_orm_migration::MigratorTrait as _;
|
||||||
use sqlx::{migrate::MigrateDatabase as _, types::chrono, Sqlite, SqlitePool};
|
use sqlx::{migrate::MigrateDatabase as _, types::chrono, Sqlite, SqlitePool};
|
||||||
@@ -82,6 +82,57 @@ impl Db {
|
|||||||
Ok(user.map(|u| u.id))
|
Ok(user.map(|u| u.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `password_hash` must be pre-hashed by the caller.
|
||||||
|
/// Creates user + joins "users" group in one transaction. Returns the created user model.
|
||||||
|
pub async fn create_user_and_join_users_group(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password_hash: String,
|
||||||
|
) -> Result<entity::users::Model, DbErr> {
|
||||||
|
use entity::{groups, users, users_groups};
|
||||||
|
|
||||||
|
let txn = self.orm_db().begin().await?;
|
||||||
|
|
||||||
|
let user_active = users::ActiveModel {
|
||||||
|
username: Set(username.to_string()),
|
||||||
|
password: Set(password_hash),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let insert_result = users::Entity::insert(user_active).exec(&txn).await?;
|
||||||
|
|
||||||
|
let new_user = users::Entity::find_by_id(insert_result.last_insert_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbErr::Custom("Failed to find newly created user".to_string()))?;
|
||||||
|
|
||||||
|
let users_group = groups::Entity::find()
|
||||||
|
.filter(groups::Column::Name.eq("users"))
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DbErr::Custom("Users group not found".to_string()))?;
|
||||||
|
|
||||||
|
let ug_active = users_groups::ActiveModel {
|
||||||
|
user_id: Set(new_user.id),
|
||||||
|
group_id: Set(users_group.id),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
users_groups::Entity::insert(ug_active).exec(&txn).await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
|
Ok(new_user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auto_create_user(&self, username: &str) -> Result<entity::users::Model, DbErr> {
|
||||||
|
let random_password = uuid::Uuid::new_v4().to_string();
|
||||||
|
let hashed_password =
|
||||||
|
tokio::task::spawn_blocking(move || password_auth::generate_hash(&random_password))
|
||||||
|
.await
|
||||||
|
.map_err(|e| DbErr::Custom(format!("Failed to hash password: {}", e)))?;
|
||||||
|
self.create_user_and_join_users_group(username, hashed_password)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: currently we don't have a token system, so we just use the user name as token
|
// TODO: currently we don't have a token system, so we just use the user name as token
|
||||||
pub async fn get_user_id_by_token<T: ToString>(
|
pub async fn get_user_id_by_token<T: ToString>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -126,12 +126,22 @@ struct Cli {
|
|||||||
)]
|
)]
|
||||||
api_host: Option<url::Url>,
|
api_host: Option<url::Url>,
|
||||||
|
|
||||||
#[arg(
|
#[command(flatten)]
|
||||||
long,
|
feature_flags: FeatureFlags,
|
||||||
default_value = "false",
|
|
||||||
help = t!("cli.disable_registration").to_string(),
|
#[command(flatten)]
|
||||||
)]
|
oidc: restful::oidc::OidcOptions,
|
||||||
disable_registration: bool,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, clap::Args)]
|
||||||
|
pub struct FeatureFlags {
|
||||||
|
/// Whether user registration via the web UI is disabled.
|
||||||
|
#[arg(long, default_value = "false", help = t!("cli.disable_registration").to_string())]
|
||||||
|
pub disable_registration: bool,
|
||||||
|
|
||||||
|
/// Whether to auto-create users when they connect via heartbeat with an unknown token.
|
||||||
|
#[arg(long, default_value = "false", help = t!("cli.allow_auto_create_user").to_string())]
|
||||||
|
pub allow_auto_create_user: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoggingConfigLoader for &Cli {
|
impl LoggingConfigLoader for &Cli {
|
||||||
@@ -197,9 +207,37 @@ async fn main() {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
init_logger(&cli, false).unwrap();
|
init_logger(&cli, false).unwrap();
|
||||||
|
|
||||||
|
// Validate OIDC configuration: check split-deploy specific requirements
|
||||||
|
// Basic OIDC parameter validation is handled in OidcConfig::from_params
|
||||||
|
if cli.oidc.any_param_provided() {
|
||||||
|
let is_split_deploy = {
|
||||||
|
#[cfg(feature = "embed")]
|
||||||
|
{
|
||||||
|
let embed_split_by_port = cli.web_server_port.is_some()
|
||||||
|
&& cli.web_server_port != Some(cli.api_server_port);
|
||||||
|
cli.no_web || embed_split_by_port
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "embed"))]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_split_deploy && cli.oidc.oidc_frontend_base_url.is_none() {
|
||||||
|
eprintln!("Error: --oidc-frontend-base-url is required in split-deploy mode");
|
||||||
|
eprintln!(
|
||||||
|
"When frontend and API are deployed separately, you must specify the frontend URL"
|
||||||
|
);
|
||||||
|
eprintln!("Example: --oidc-frontend-base-url http://your-frontend-domain.com");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// let db = db::Db::new(":memory:").await.unwrap();
|
// let db = db::Db::new(":memory:").await.unwrap();
|
||||||
let db = db::Db::new(cli.db).await.unwrap();
|
let db = db::Db::new(cli.db).await.unwrap();
|
||||||
let mut mgr = client_manager::ClientManager::new(db.clone(), cli.geoip_db);
|
let feature_flags = Arc::new(cli.feature_flags);
|
||||||
|
let mut mgr =
|
||||||
|
client_manager::ClientManager::new(db.clone(), cli.geoip_db, feature_flags.clone());
|
||||||
let (v6_listener, v4_listener) =
|
let (v6_listener, v4_listener) =
|
||||||
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
|
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
|
||||||
.await
|
.await
|
||||||
@@ -233,12 +271,26 @@ async fn main() {
|
|||||||
#[cfg(not(feature = "embed"))]
|
#[cfg(not(feature = "embed"))]
|
||||||
let web_router_restful = None;
|
let web_router_restful = None;
|
||||||
|
|
||||||
|
let oidc_config = if cli.oidc.oidc_issuer_url.is_some() {
|
||||||
|
match restful::oidc::OidcConfig::from_params(cli.oidc).await {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to initialize OIDC: {:?}", e);
|
||||||
|
eprintln!("Please check your OIDC configuration (issuer URL, client ID, etc.)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restful::oidc::OidcConfig::disabled()
|
||||||
|
};
|
||||||
|
|
||||||
let _restful_server_tasks = restful::RestfulServer::new(
|
let _restful_server_tasks = restful::RestfulServer::new(
|
||||||
std::net::SocketAddr::new(cli.api_server_addr, cli.api_server_port),
|
std::net::SocketAddr::new(cli.api_server_addr, cli.api_server_port),
|
||||||
mgr.clone(),
|
mgr.clone(),
|
||||||
db,
|
db,
|
||||||
web_router_restful,
|
web_router_restful,
|
||||||
cli.disable_registration,
|
feature_flags,
|
||||||
|
oidc_config,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -9,18 +9,15 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::restful::users::Backend;
|
use crate::restful::users::Backend;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::FeatureFlags;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
users::{AuthSession, Credentials},
|
users::{AuthSession, Credentials},
|
||||||
AppStateInner,
|
AppStateInner,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Feature flags for the web server
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct FeatureFlags {
|
|
||||||
/// Whether user registration is disabled
|
|
||||||
pub disable_registration: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct LoginResult {
|
pub struct LoginResult {
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
@@ -117,7 +114,7 @@ mod post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
Extension(feature_flags): Extension<FeatureFlags>,
|
Extension(feature_flags): Extension<Arc<FeatureFlags>>,
|
||||||
auth_session: AuthSession,
|
auth_session: AuthSession,
|
||||||
captcha_session: tower_sessions::Session,
|
captcha_session: tower_sessions::Session,
|
||||||
Json(req): Json<RegisterNewUser>,
|
Json(req): Json<RegisterNewUser>,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
pub(crate) mod captcha;
|
pub(crate) mod captcha;
|
||||||
mod network;
|
mod network;
|
||||||
|
pub(crate) mod oidc;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
@@ -19,7 +20,7 @@ use network::NetworkApi;
|
|||||||
use sea_orm::DbErr;
|
use sea_orm::DbErr;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower_sessions::cookie::time::Duration;
|
use tower_sessions::cookie::time::Duration;
|
||||||
use tower_sessions::cookie::Key;
|
use tower_sessions::cookie::{Key, SameSite};
|
||||||
use tower_sessions::Expiry;
|
use tower_sessions::Expiry;
|
||||||
use tower_sessions_sqlx_store::SqliteStore;
|
use tower_sessions_sqlx_store::SqliteStore;
|
||||||
use users::{AuthSession, Backend};
|
use users::{AuthSession, Backend};
|
||||||
@@ -27,6 +28,7 @@ use users::{AuthSession, Backend};
|
|||||||
use crate::client_manager::storage::StorageToken;
|
use crate::client_manager::storage::StorageToken;
|
||||||
use crate::client_manager::ClientManager;
|
use crate::client_manager::ClientManager;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
use crate::FeatureFlags;
|
||||||
|
|
||||||
/// Embed assets for web dashboard, build frontend first
|
/// Embed assets for web dashboard, build frontend first
|
||||||
#[cfg(feature = "embed")]
|
#[cfg(feature = "embed")]
|
||||||
@@ -37,8 +39,9 @@ struct Assets;
|
|||||||
pub struct RestfulServer {
|
pub struct RestfulServer {
|
||||||
bind_addr: SocketAddr,
|
bind_addr: SocketAddr,
|
||||||
client_mgr: Arc<ClientManager>,
|
client_mgr: Arc<ClientManager>,
|
||||||
registration_disabled: bool,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
db: Db,
|
db: Db,
|
||||||
|
oidc_config: oidc::OidcConfig,
|
||||||
|
|
||||||
// serve_task: Option<ScopedTask<()>>,
|
// serve_task: Option<ScopedTask<()>>,
|
||||||
// delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>,
|
// delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>,
|
||||||
@@ -105,7 +108,8 @@ impl RestfulServer {
|
|||||||
client_mgr: Arc<ClientManager>,
|
client_mgr: Arc<ClientManager>,
|
||||||
db: Db,
|
db: Db,
|
||||||
web_router: Option<Router>,
|
web_router: Option<Router>,
|
||||||
registration_disabled: bool,
|
feature_flags: Arc<FeatureFlags>,
|
||||||
|
oidc_config: oidc::OidcConfig,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
assert!(client_mgr.is_running());
|
assert!(client_mgr.is_running());
|
||||||
|
|
||||||
@@ -114,8 +118,9 @@ impl RestfulServer {
|
|||||||
Ok(RestfulServer {
|
Ok(RestfulServer {
|
||||||
bind_addr,
|
bind_addr,
|
||||||
client_mgr,
|
client_mgr,
|
||||||
registration_disabled,
|
feature_flags,
|
||||||
db,
|
db,
|
||||||
|
oidc_config,
|
||||||
// serve_task: None,
|
// serve_task: None,
|
||||||
// delete_task: None,
|
// delete_task: None,
|
||||||
// network_api,
|
// network_api,
|
||||||
@@ -222,6 +227,7 @@ impl RestfulServer {
|
|||||||
|
|
||||||
let session_layer = SessionManagerLayer::new(session_store)
|
let session_layer = SessionManagerLayer::new(session_store)
|
||||||
.with_secure(false)
|
.with_secure(false)
|
||||||
|
.with_same_site(SameSite::Lax)
|
||||||
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
|
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
|
||||||
.with_signed(key);
|
.with_signed(key);
|
||||||
|
|
||||||
@@ -243,15 +249,15 @@ impl RestfulServer {
|
|||||||
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
|
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
|
||||||
.merge(NetworkApi::build_route())
|
.merge(NetworkApi::build_route())
|
||||||
.route_layer(login_required!(Backend))
|
.route_layer(login_required!(Backend))
|
||||||
.merge(auth::router().layer(Extension(auth::FeatureFlags {
|
.merge(auth::router().layer(Extension(self.feature_flags.clone())))
|
||||||
disable_registration: self.registration_disabled,
|
.merge(oidc::router())
|
||||||
})))
|
|
||||||
.with_state(self.client_mgr.clone())
|
.with_state(self.client_mgr.clone())
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/generate-config",
|
"/api/v1/generate-config",
|
||||||
post(Self::handle_generate_config),
|
post(Self::handle_generate_config),
|
||||||
)
|
)
|
||||||
.route("/api/v1/parse-config", post(Self::handle_parse_config))
|
.route("/api/v1/parse-config", post(Self::handle_parse_config))
|
||||||
|
.layer(Extension(self.oidc_config.clone()))
|
||||||
.layer(MessagesManagerLayer)
|
.layer(MessagesManagerLayer)
|
||||||
.layer(auth_layer)
|
.layer(auth_layer)
|
||||||
.layer(tower_http::cors::CorsLayer::very_permissive())
|
.layer(tower_http::cors::CorsLayer::very_permissive())
|
||||||
|
|||||||
@@ -0,0 +1,734 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use openidconnect::core::{
|
||||||
|
CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey,
|
||||||
|
CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata,
|
||||||
|
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType,
|
||||||
|
};
|
||||||
|
use openidconnect::{
|
||||||
|
Client, ClientId, ClientSecret, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet,
|
||||||
|
EndpointSet, IdTokenFields, IssuerUrl, RedirectUrl, StandardErrorResponse,
|
||||||
|
StandardTokenResponse,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::AppStateInner;
|
||||||
|
|
||||||
|
const DEFAULT_OIDC_SCOPES: [&str; 2] = ["openid", "profile"];
|
||||||
|
|
||||||
|
fn normalize_oidc_scopes(scopes: &[String]) -> Vec<String> {
|
||||||
|
let mut normalized: Vec<String> = scopes
|
||||||
|
.iter()
|
||||||
|
.map(|scope| scope.trim().to_string())
|
||||||
|
.filter(|scope| !scope.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if normalized.is_empty() {
|
||||||
|
normalized = DEFAULT_OIDC_SCOPES
|
||||||
|
.iter()
|
||||||
|
.map(|scope| scope.to_string())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !normalized.iter().any(|scope| scope == "openid") {
|
||||||
|
normalized.insert(0, "openid".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct JsonAdditionalClaims {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub claims: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl openidconnect::AdditionalClaims for JsonAdditionalClaims {}
|
||||||
|
|
||||||
|
pub type AppIdTokenFields = IdTokenFields<
|
||||||
|
JsonAdditionalClaims,
|
||||||
|
EmptyExtraTokenFields,
|
||||||
|
CoreGenderClaim,
|
||||||
|
CoreJweContentEncryptionAlgorithm,
|
||||||
|
CoreJwsSigningAlgorithm,
|
||||||
|
>;
|
||||||
|
|
||||||
|
pub type AppTokenResponse = StandardTokenResponse<AppIdTokenFields, CoreTokenType>;
|
||||||
|
|
||||||
|
pub type AppClient<
|
||||||
|
HasAuthUrl = EndpointNotSet,
|
||||||
|
HasDeviceAuthUrl = EndpointNotSet,
|
||||||
|
HasIntrospectionUrl = EndpointNotSet,
|
||||||
|
HasRevocationUrl = EndpointNotSet,
|
||||||
|
HasTokenUrl = EndpointNotSet,
|
||||||
|
HasUserInfoUrl = EndpointNotSet,
|
||||||
|
> = Client<
|
||||||
|
JsonAdditionalClaims,
|
||||||
|
CoreAuthDisplay,
|
||||||
|
CoreGenderClaim,
|
||||||
|
CoreJweContentEncryptionAlgorithm,
|
||||||
|
CoreJsonWebKey,
|
||||||
|
CoreAuthPrompt,
|
||||||
|
StandardErrorResponse<CoreErrorResponseType>,
|
||||||
|
AppTokenResponse,
|
||||||
|
CoreTokenIntrospectionResponse,
|
||||||
|
CoreRevocableToken,
|
||||||
|
CoreRevocationErrorResponse,
|
||||||
|
HasAuthUrl,
|
||||||
|
HasDeviceAuthUrl,
|
||||||
|
HasIntrospectionUrl,
|
||||||
|
HasRevocationUrl,
|
||||||
|
HasTokenUrl,
|
||||||
|
HasUserInfoUrl,
|
||||||
|
>;
|
||||||
|
|
||||||
|
pub type ConfiguredAppClient = AppClient<
|
||||||
|
EndpointSet,
|
||||||
|
EndpointNotSet,
|
||||||
|
EndpointNotSet,
|
||||||
|
EndpointNotSet,
|
||||||
|
EndpointMaybeSet,
|
||||||
|
EndpointMaybeSet,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// Convert a dot-path (e.g. `realm_access.roles.0`) to a JSON Pointer (e.g. `/realm_access/roles/0`).
|
||||||
|
/// Each segment is escaped per RFC 6901: `~` → `~0`, `/` → `~1`.
|
||||||
|
fn dot_path_to_json_pointer(dot_path: &str) -> String {
|
||||||
|
let mut pointer = String::new();
|
||||||
|
for segment in dot_path.split('.') {
|
||||||
|
pointer.push('/');
|
||||||
|
for ch in segment.chars() {
|
||||||
|
match ch {
|
||||||
|
'~' => pointer.push_str("~0"),
|
||||||
|
'/' => pointer.push_str("~1"),
|
||||||
|
_ => pointer.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timing-safe string comparison via constant-time equality check.
|
||||||
|
/// Prevents timing side-channel attacks on CSRF token verification.
|
||||||
|
fn timing_safe_eq(a: &str, b: &str) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.as_bytes().ct_eq(b.as_bytes()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, clap::Args)]
|
||||||
|
pub struct OidcOptions {
|
||||||
|
#[arg(long, help = t!("cli.oidc_issuer_url").to_string())]
|
||||||
|
pub oidc_issuer_url: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, help = t!("cli.oidc_client_id").to_string())]
|
||||||
|
pub oidc_client_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, env = "OIDC_CLIENT_SECRET", help = t!("cli.oidc_client_secret").to_string())]
|
||||||
|
pub oidc_client_secret: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "preferred_username", help = t!("cli.oidc_username_claim").to_string())]
|
||||||
|
pub oidc_username_claim: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_delimiter = ',',
|
||||||
|
default_values = DEFAULT_OIDC_SCOPES,
|
||||||
|
help = t!("cli.oidc_scopes").to_string()
|
||||||
|
)]
|
||||||
|
pub oidc_scopes: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(long, help = t!("cli.oidc_redirect_url").to_string())]
|
||||||
|
pub oidc_redirect_url: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "false", help = t!("cli.oidc_disable_pkce").to_string())]
|
||||||
|
pub oidc_disable_pkce: bool,
|
||||||
|
|
||||||
|
#[arg(long, help = t!("cli.oidc_frontend_base_url").to_string())]
|
||||||
|
pub oidc_frontend_base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcOptions {
|
||||||
|
pub fn any_param_provided(&self) -> bool {
|
||||||
|
self.oidc_issuer_url.is_some()
|
||||||
|
|| self.oidc_client_id.is_some()
|
||||||
|
|| self.oidc_client_secret.is_some()
|
||||||
|
|| self.oidc_redirect_url.is_some()
|
||||||
|
|| self.oidc_frontend_base_url.is_some()
|
||||||
|
|| self.oidc_username_claim != "preferred_username"
|
||||||
|
|| self.oidc_scopes != DEFAULT_OIDC_SCOPES
|
||||||
|
|| self.oidc_disable_pkce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OidcConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub provider_metadata: Option<Arc<CoreProviderMetadata>>,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub redirect_url: Option<RedirectUrl>,
|
||||||
|
pub username_claim: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub pkce_enabled: bool,
|
||||||
|
pub frontend_base_url: Option<String>,
|
||||||
|
pub http_client: Option<reqwest::Client>,
|
||||||
|
cached_client: Option<Arc<ConfiguredAppClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcConfig {
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
provider_metadata: None,
|
||||||
|
client_id: String::new(),
|
||||||
|
client_secret: None,
|
||||||
|
redirect_url: None,
|
||||||
|
username_claim: "preferred_username".to_string(),
|
||||||
|
scopes: DEFAULT_OIDC_SCOPES
|
||||||
|
.iter()
|
||||||
|
.map(|scope| scope.to_string())
|
||||||
|
.collect(),
|
||||||
|
pkce_enabled: false,
|
||||||
|
frontend_base_url: None,
|
||||||
|
http_client: None,
|
||||||
|
cached_client: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn from_params(opts: OidcOptions) -> anyhow::Result<Self> {
|
||||||
|
let OidcOptions {
|
||||||
|
oidc_issuer_url,
|
||||||
|
oidc_client_id,
|
||||||
|
oidc_client_secret,
|
||||||
|
oidc_username_claim,
|
||||||
|
oidc_scopes,
|
||||||
|
oidc_redirect_url,
|
||||||
|
oidc_disable_pkce,
|
||||||
|
oidc_frontend_base_url,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
if oidc_issuer_url.is_none() || oidc_client_id.is_none() || oidc_redirect_url.is_none() {
|
||||||
|
return Err(anyhow::anyhow!("--oidc-issuer-url, --oidc-client-id and --oidc-redirect-url are required when using OIDC authentication"));
|
||||||
|
}
|
||||||
|
if oidc_username_claim.trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("--oidc-username-claim cannot be empty"));
|
||||||
|
}
|
||||||
|
let http_client = reqwest::ClientBuilder::new()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let issuer_url = oidc_issuer_url.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("--oidc-issuer-url is required when using OIDC authentication")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let provider_metadata =
|
||||||
|
CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, &http_client).await?;
|
||||||
|
|
||||||
|
let client_id = oidc_client_id.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("--oidc-client-id is required when using OIDC authentication")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let redirect_url = oidc_redirect_url
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("--oidc-redirect-url is required when using OIDC authentication. The redirect URL must match exactly what is registered with your Identity Provider. Example: --oidc-redirect-url http://your-domain.com:11211/api/v1/auth/oidc/callback"))?;
|
||||||
|
|
||||||
|
let provider_metadata = Arc::new(provider_metadata);
|
||||||
|
let redirect_url = RedirectUrl::new(redirect_url)?;
|
||||||
|
let client_secret = oidc_client_secret;
|
||||||
|
|
||||||
|
let cached_client = {
|
||||||
|
let c = AppClient::from_provider_metadata(
|
||||||
|
provider_metadata.as_ref().clone(),
|
||||||
|
ClientId::new(client_id.clone()),
|
||||||
|
client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(redirect_url.clone());
|
||||||
|
Arc::new(c)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
enabled: true,
|
||||||
|
provider_metadata: Some(provider_metadata),
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_url: Some(redirect_url),
|
||||||
|
username_claim: oidc_username_claim,
|
||||||
|
scopes: normalize_oidc_scopes(&oidc_scopes),
|
||||||
|
pkce_enabled: !oidc_disable_pkce,
|
||||||
|
frontend_base_url: oidc_frontend_base_url,
|
||||||
|
http_client: Some(http_client),
|
||||||
|
cached_client: Some(cached_client),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client(&self) -> Option<&ConfiguredAppClient> {
|
||||||
|
self.cached_client.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppStateInner> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/v1/auth/oidc/config", get(self::route::oidc_config))
|
||||||
|
.route("/api/v1/auth/oidc/login", get(self::route::oidc_login))
|
||||||
|
.route(
|
||||||
|
"/api/v1/auth/oidc/callback",
|
||||||
|
get(self::route::oidc_callback),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod route {
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use openidconnect::core::CoreAuthenticationFlow;
|
||||||
|
use openidconnect::{
|
||||||
|
AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse,
|
||||||
|
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::restful::other_error;
|
||||||
|
use crate::restful::users::AuthSession;
|
||||||
|
|
||||||
|
use super::OidcConfig;
|
||||||
|
|
||||||
|
pub async fn oidc_config(Extension(oidc): Extension<OidcConfig>) -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({ "enabled": oidc.enabled }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_login(
|
||||||
|
Extension(oidc): Extension<OidcConfig>,
|
||||||
|
session: tower_sessions::Session,
|
||||||
|
) -> Response {
|
||||||
|
if !oidc.enabled {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("OIDC is not enabled")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = match oidc.client() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("OIDC client not initialized")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scopes = oidc.scopes.clone();
|
||||||
|
let pkce_enabled = oidc.pkce_enabled;
|
||||||
|
|
||||||
|
let (pkce_challenge, pkce_verifier) = if pkce_enabled {
|
||||||
|
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
(Some(challenge), Some(verifier))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut auth_request = client.authorize_url(
|
||||||
|
CoreAuthenticationFlow::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
);
|
||||||
|
|
||||||
|
for scope in &scopes {
|
||||||
|
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(challenge) = pkce_challenge {
|
||||||
|
auth_request = auth_request.set_pkce_challenge(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (auth_url, csrf_token, nonce) = auth_request.url();
|
||||||
|
|
||||||
|
if let Err(e) = session
|
||||||
|
.insert("oidc_csrf_token", csrf_token.secret().clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Failed to store csrf_token in session: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Session error")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = session.insert("oidc_nonce", nonce.secret().clone()).await {
|
||||||
|
tracing::error!("Failed to store nonce in session: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Session error")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if let Some(verifier) = pkce_verifier {
|
||||||
|
if let Err(e) = session
|
||||||
|
.insert("oidc_pkce_verifier", verifier.secret().clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Failed to store pkce_verifier in session: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Session error")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = session.insert("oidc_pkce_used", pkce_enabled).await {
|
||||||
|
tracing::error!("Failed to store pkce_used in session: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Session error")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::temporary(auth_url.as_str()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CallbackParams {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_oidc_session(session: &tower_sessions::Session) {
|
||||||
|
let _ = session.remove::<String>("oidc_csrf_token").await;
|
||||||
|
let _ = session.remove::<String>("oidc_nonce").await;
|
||||||
|
let _ = session.remove::<String>("oidc_pkce_verifier").await;
|
||||||
|
let _ = session.remove::<bool>("oidc_pkce_used").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_callback(
|
||||||
|
Extension(oidc): Extension<OidcConfig>,
|
||||||
|
Query(params): Query<CallbackParams>,
|
||||||
|
session: tower_sessions::Session,
|
||||||
|
mut auth_session: AuthSession,
|
||||||
|
) -> Response {
|
||||||
|
if !oidc.enabled {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("OIDC is not enabled")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref error) = params.error {
|
||||||
|
tracing::error!(
|
||||||
|
"OIDC provider returned error: {}, description: {:?}",
|
||||||
|
error,
|
||||||
|
params.error_description
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error(
|
||||||
|
"Authentication failed at the identity provider",
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = match params.code {
|
||||||
|
Some(ref c) => c.clone(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("Missing authorization code")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let callback_state = match params.state {
|
||||||
|
Some(ref s) => s.clone(),
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("Missing state parameter in callback")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stored_csrf: String = match session.get("oidc_csrf_token").await {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("Missing or invalid CSRF token in session")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !super::timing_safe_eq(&stored_csrf, &callback_state) {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("CSRF state mismatch")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored_nonce: String = match session.get("oidc_nonce").await {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("Missing nonce in session")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stored_pkce_verifier: Option<String> =
|
||||||
|
session.get("oidc_pkce_verifier").await.ok().flatten();
|
||||||
|
let pkce_was_used: Option<bool> = session.get("oidc_pkce_used").await.ok().flatten();
|
||||||
|
|
||||||
|
cleanup_oidc_session(&session).await;
|
||||||
|
|
||||||
|
let client = match oidc.client() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("OIDC client not initialized")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let http_client = match oidc.http_client.as_ref() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
tracing::error!("HTTP client not initialized in OIDC config");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("OIDC internal error")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut token_request = match client.exchange_code(AuthorizationCode::new(code)) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to create token request: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Failed to create token exchange request")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(stored_pkce_verifier) = stored_pkce_verifier {
|
||||||
|
token_request =
|
||||||
|
token_request.set_pkce_verifier(PkceCodeVerifier::new(stored_pkce_verifier));
|
||||||
|
} else if pkce_was_used == Some(true) {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error(
|
||||||
|
"PKCE was enabled but verifier is missing from session (session may have expired)",
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_response = match token_request.request_async(http_client).await {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to exchange code for token: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Token exchange failed")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_token = match token_response.id_token() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("No ID token in response")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let claims = match id_token.claims(&client.id_token_verifier(), &Nonce::new(stored_nonce)) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to verify ID token: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(other_error("ID token verification failed")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(expected_at_hash) = claims.access_token_hash() {
|
||||||
|
let id_token_verifier = client.id_token_verifier();
|
||||||
|
let (Ok(signing_alg), Ok(signing_key)) = (
|
||||||
|
id_token.signing_alg(),
|
||||||
|
id_token.signing_key(&id_token_verifier),
|
||||||
|
) else {
|
||||||
|
tracing::error!("Failed to get signing algorithm or key for at_hash verification");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Failed to determine token signing algorithm")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let actual_at_hash = match AccessTokenHash::from_token(
|
||||||
|
token_response.access_token(),
|
||||||
|
signing_alg,
|
||||||
|
signing_key,
|
||||||
|
) {
|
||||||
|
Ok(hash) => hash,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to compute access token hash: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Failed to verify access token hash")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if actual_at_hash != *expected_at_hash {
|
||||||
|
tracing::error!("Access token hash mismatch");
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(other_error("Access token hash mismatch")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims_json = match serde_json::to_value(claims) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to serialize claims: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Failed to process ID token claims")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointer = super::dot_path_to_json_pointer(&oidc.username_claim);
|
||||||
|
let username: Option<String> = claims_json
|
||||||
|
.pointer(&pointer)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let username = match username {
|
||||||
|
Some(u) if !u.is_empty() => u,
|
||||||
|
_ => {
|
||||||
|
tracing::error!(
|
||||||
|
"Could not extract username from claim '{}' in token",
|
||||||
|
oidc.username_claim
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(other_error("Could not extract username from token claims")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = match auth_session
|
||||||
|
.backend
|
||||||
|
.find_or_create_oidc_user(&username)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to find or create OIDC user '{}': {:?}", username, e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Failed to provision user account")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = auth_session.login(&user).await {
|
||||||
|
tracing::error!("Failed to login user via OIDC: {:?}", e);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(other_error("Failed to establish session")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = session.cycle_id().await {
|
||||||
|
tracing::error!("Failed to cycle session ID after OIDC login: {:?}", e);
|
||||||
|
}
|
||||||
|
if let Some(frontend_url) = &oidc.frontend_base_url {
|
||||||
|
Redirect::temporary(frontend_url).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::temporary("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_path_to_json_pointer() {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let cases = vec![
|
||||||
|
(
|
||||||
|
"realm_access.roles.0",
|
||||||
|
"/realm_access/roles/0",
|
||||||
|
json!({ "realm_access": { "roles": ["admin", "user"] } }),
|
||||||
|
"admin",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"preferred_username",
|
||||||
|
"/preferred_username",
|
||||||
|
json!({ "preferred_username": "bob" }),
|
||||||
|
"bob",
|
||||||
|
),
|
||||||
|
("a~b.c", "/a~0b/c", json!({ "a~b": { "c": "v" } }), "v"),
|
||||||
|
("a/b.c", "/a~1b/c", json!({ "a/b": { "c": "w" } }), "w"),
|
||||||
|
("~/.x", "/~0~1/x", json!({ "~/": { "x": "z" } }), "z"),
|
||||||
|
("a..b", "/a//b", json!({ "a": { "": { "b": "x" } } }), "x"),
|
||||||
|
("", "/", json!({ "": "root" }), "root"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (path, expected_ptr, json_val, expected_val) in cases {
|
||||||
|
let ptr = dot_path_to_json_pointer(path);
|
||||||
|
assert_eq!(ptr, expected_ptr, "Pointer mismatch for path: {}", path);
|
||||||
|
assert_eq!(
|
||||||
|
json_val.pointer(&ptr).and_then(|v| v.as_str()),
|
||||||
|
Some(expected_val),
|
||||||
|
"Value extraction failed for path: {}, pointer: {}",
|
||||||
|
path,
|
||||||
|
ptr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ use async_trait::async_trait;
|
|||||||
use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId};
|
use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId};
|
||||||
use password_auth::verify_password;
|
use password_auth::verify_password;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait as _, ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, JoinType,
|
ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, JoinType, QueryFilter,
|
||||||
QueryFilter, QuerySelect as _, RelationTrait, Set, TransactionTrait,
|
QuerySelect as _, RelationTrait, Set,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
@@ -14,7 +14,7 @@ use crate::db::{self, entity};
|
|||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
db_user: entity::users::Model,
|
pub(crate) db_user: entity::users::Model,
|
||||||
pub tokens: Vec<String>,
|
pub tokens: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,40 +74,47 @@ impl Backend {
|
|||||||
Self { db }
|
Self { db }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn db(&self) -> &db::Db {
|
||||||
|
&self.db
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register_new_user(&self, new_user: &RegisterNewUser) -> anyhow::Result<()> {
|
pub async fn register_new_user(&self, new_user: &RegisterNewUser) -> anyhow::Result<()> {
|
||||||
let hashed_password = password_auth::generate_hash(new_user.credentials.password.as_str());
|
let hashed_password = password_auth::generate_hash(new_user.credentials.password.as_str());
|
||||||
let txn = self.db.orm_db().begin().await?;
|
self.db
|
||||||
|
.create_user_and_join_users_group(&new_user.credentials.username, hashed_password)
|
||||||
entity::users::ActiveModel {
|
.await?;
|
||||||
username: Set(new_user.credentials.username.clone()),
|
|
||||||
password: Set(hashed_password.clone()),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.save(&txn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
entity::users_groups::ActiveModel {
|
|
||||||
user_id: Set(entity::users::Entity::find()
|
|
||||||
.filter(entity::users::Column::Username.eq(new_user.credentials.username.as_str()))
|
|
||||||
.one(&txn)
|
|
||||||
.await?
|
|
||||||
.unwrap()
|
|
||||||
.id),
|
|
||||||
group_id: Set(entity::groups::Entity::find()
|
|
||||||
.filter(entity::groups::Column::Name.eq("users"))
|
|
||||||
.one(&txn)
|
|
||||||
.await?
|
|
||||||
.unwrap()
|
|
||||||
.id),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.save(&txn)
|
|
||||||
.await?;
|
|
||||||
txn.commit().await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find a user by username, or auto-create one for OIDC-authenticated users.
|
||||||
|
///
|
||||||
|
/// Unlike the heartbeat auto-creation path (controlled by `allow_auto_create_user`),
|
||||||
|
/// OIDC users are always provisioned automatically because their identity has already
|
||||||
|
/// been verified by a trusted external Identity Provider (IdP).
|
||||||
|
pub async fn find_or_create_oidc_user(&self, username: &str) -> anyhow::Result<User> {
|
||||||
|
use entity::users;
|
||||||
|
|
||||||
|
// Try to find an existing user first.
|
||||||
|
if let Some(db_user) = users::Entity::find()
|
||||||
|
.filter(users::Column::Username.eq(username))
|
||||||
|
.one(self.db.orm_db())
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(User {
|
||||||
|
tokens: vec![db_user.username.clone()],
|
||||||
|
db_user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User not found – auto-provision a local account backed by the IdP identity.
|
||||||
|
let db_user = self.db.auto_create_user(username).await?;
|
||||||
|
tracing::info!("Auto-provisioned OIDC user '{username}'");
|
||||||
|
Ok(User {
|
||||||
|
tokens: vec![db_user.username.clone()],
|
||||||
|
db_user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn change_password(
|
pub async fn change_password(
|
||||||
&self,
|
&self,
|
||||||
id: <User as AuthUser>::Id,
|
id: <User as AuthUser>::Id,
|
||||||
|
|||||||
Reference in New Issue
Block a user