add el-identity: Engram-native identity, OAuth, @authenticate by default
This commit is contained in:
Generated
+573
@@ -2,12 +2,39 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -17,12 +44,48 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -65,9 +128,25 @@ name = "el-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"el-identity",
|
||||
"hmac",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-identity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -98,6 +177,24 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -108,6 +205,40 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -117,12 +248,113 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -141,6 +373,67 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -152,6 +445,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -201,8 +500,282 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
@@ -6,5 +6,6 @@ members = [
|
||||
"crates/el-aop",
|
||||
"crates/el-auth",
|
||||
"crates/el-publish",
|
||||
"crates/el-identity",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
//! hit? ──→ return cached
|
||||
//! miss? → [method body] → store → after-log
|
||||
//! ```
|
||||
//!
|
||||
//! ## Security-by-default
|
||||
//!
|
||||
//! `AspectChain::with_default_auth()` prepends `AuthenticateAspect` to every
|
||||
//! chain. Call this when building chains for non-`@public` functions.
|
||||
|
||||
use crate::{AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
|
||||
use crate::{aspects::AuthenticateAspect, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An ordered chain of aspects applied to a single method.
|
||||
@@ -90,6 +95,24 @@ impl AspectChain {
|
||||
pub fn aspect_names(&self) -> Vec<&str> {
|
||||
self.aspects.iter().map(|a| a.name()).collect()
|
||||
}
|
||||
|
||||
/// Prepend `AuthenticateAspect` to this chain.
|
||||
///
|
||||
/// This is the mechanism for security-by-default: the framework calls
|
||||
/// `with_default_auth()` on every chain that does NOT have `@public`.
|
||||
///
|
||||
/// Equivalent to `.add(Arc::new(AuthenticateAspect))` at position 0, but
|
||||
/// semantically explicit about what it means.
|
||||
pub fn with_default_auth(self) -> Self {
|
||||
let mut aspects = vec![Arc::new(AuthenticateAspect) as Arc<dyn Aspect>];
|
||||
aspects.extend(self.aspects);
|
||||
Self { aspects }
|
||||
}
|
||||
|
||||
/// Returns `true` if the chain contains an `AuthenticateAspect`.
|
||||
pub fn has_auth(&self) -> bool {
|
||||
self.aspects.iter().any(|a| a.name() == "authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AspectChain {
|
||||
|
||||
@@ -4,12 +4,20 @@
|
||||
//! import. Built into the framework. Applied as decorators:
|
||||
//!
|
||||
//! ```text
|
||||
//! @authenticate
|
||||
//! @authenticate ← applied by DEFAULT to every function
|
||||
//! @authorize(role: "admin")
|
||||
//! @cache(ttl: 300)
|
||||
//! @rate_limit(requests: 100, per: 60)
|
||||
//! component AdminDashboard { ... }
|
||||
//!
|
||||
//! @public ← explicit opt-out of authentication
|
||||
//! component LandingPage { ... }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Security-by-default
|
||||
//!
|
||||
//! `@authenticate` is the default. Functions without `@public` are protected.
|
||||
//! This makes it as hard as possible to accidentally ship an unprotected endpoint.
|
||||
|
||||
pub mod aspects;
|
||||
pub mod chain;
|
||||
@@ -20,8 +28,11 @@ pub use aspects::{
|
||||
TraceAspect, ValidateAspect,
|
||||
};
|
||||
pub use chain::AspectChain;
|
||||
pub use public::PublicMarker;
|
||||
pub use registry::AspectRegistry;
|
||||
|
||||
pub mod public;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! `@public` marker — the explicit opt-out of authentication.
|
||||
//!
|
||||
//! The security-by-default model: `@authenticate` is applied to EVERY function
|
||||
//! by default. `@public` is the rare annotation that says "this endpoint
|
||||
//! intentionally has no auth".
|
||||
//!
|
||||
//! `PublicMarker` is a zero-cost marker. When the compiler sees `@public` on a
|
||||
//! function, it strips `AuthenticateAspect` from the chain for that function.
|
||||
//! The chain-builder checks `is_public` before prepending default auth.
|
||||
|
||||
/// Zero-cost marker indicating that a function is intentionally public.
|
||||
///
|
||||
/// When `@public` is present, the default `AuthenticateAspect` is NOT added
|
||||
/// to the function's aspect chain.
|
||||
///
|
||||
/// Usage in the el-ui compiler:
|
||||
/// ```text
|
||||
/// @public
|
||||
/// fn health_check() -> Status { ... }
|
||||
/// ```
|
||||
///
|
||||
/// In the AOP chain builder:
|
||||
/// ```rust
|
||||
/// use el_aop::{AspectChain, PublicMarker, AuthenticateAspect};
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// fn build_chain(is_public: bool) -> AspectChain {
|
||||
/// if is_public || PublicMarker::is_bypassing() {
|
||||
/// AspectChain::new()
|
||||
/// } else {
|
||||
/// AspectChain::new().with_default_auth()
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct PublicMarker;
|
||||
|
||||
impl PublicMarker {
|
||||
/// Returns `true` — always. Exists for use in match arms / conditional logic.
|
||||
///
|
||||
/// The presence of a `PublicMarker` in the decorator list is the signal;
|
||||
/// this method is a convenience for procedural logic over decorator lists.
|
||||
pub const fn is_bypassing() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The decorator name this marker corresponds to.
|
||||
pub const fn decorator_name() -> &'static str {
|
||||
"public"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PublicMarker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "@public")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,14 @@
|
||||
//!
|
||||
//! The compiler's AOP codegen uses the registry to look up aspect implementations
|
||||
//! by their decorator name (e.g., `"authenticate"` → `AuthenticateAspect`).
|
||||
//!
|
||||
//! ## Security-by-default
|
||||
//!
|
||||
//! `"public"` is a special bypass marker — NOT an aspect. When the compiler sees
|
||||
//! `@public` it calls `registry.is_public_bypass(name)` and skips default auth.
|
||||
//!
|
||||
//! `set_default_auth_guard()` installs the global default `AuthenticateAspect`
|
||||
//! that is prepended to every non-`@public` chain.
|
||||
|
||||
use crate::Aspect;
|
||||
use std::collections::HashMap;
|
||||
@@ -12,12 +20,21 @@ type AspectFactory = Box<dyn Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + S
|
||||
/// Registry of aspect factories, indexed by decorator name.
|
||||
pub struct AspectRegistry {
|
||||
factories: RwLock<HashMap<String, AspectFactory>>,
|
||||
/// When `true`, the registry is configured to prepend AuthenticateAspect
|
||||
/// to every non-`@public` chain via `AspectChain::with_default_auth()`.
|
||||
default_auth_enabled: RwLock<bool>,
|
||||
/// Names that are bypass markers (not real aspects). Currently just "public".
|
||||
bypass_markers: RwLock<std::collections::HashSet<String>>,
|
||||
}
|
||||
|
||||
impl AspectRegistry {
|
||||
pub fn new() -> Self {
|
||||
let mut markers = std::collections::HashSet::new();
|
||||
markers.insert("public".to_string());
|
||||
Self {
|
||||
factories: RwLock::new(HashMap::new()),
|
||||
default_auth_enabled: RwLock::new(false),
|
||||
bypass_markers: RwLock::new(markers),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +45,71 @@ impl AspectRegistry {
|
||||
registry
|
||||
}
|
||||
|
||||
/// Enable security-by-default: every non-`@public` chain will have
|
||||
/// `AuthenticateAspect` prepended automatically.
|
||||
///
|
||||
/// Call at application startup. After this, use `AspectChain::with_default_auth()`
|
||||
/// when building chains for protected functions.
|
||||
pub fn set_default_auth_enabled(&self, enabled: bool) {
|
||||
*self.default_auth_enabled.write().expect("registry lock poisoned") = enabled;
|
||||
}
|
||||
|
||||
/// Returns `true` if security-by-default auth is active.
|
||||
pub fn is_default_auth_enabled(&self) -> bool {
|
||||
*self.default_auth_enabled.read().expect("registry lock poisoned")
|
||||
}
|
||||
|
||||
/// Returns `true` if `name` is a public bypass marker (e.g., `"public"`).
|
||||
///
|
||||
/// Bypass markers are NOT aspects — they signal that default auth should
|
||||
/// be skipped for the decorated function.
|
||||
pub fn is_public_bypass(&self, name: &str) -> bool {
|
||||
self.bypass_markers
|
||||
.read()
|
||||
.expect("registry lock poisoned")
|
||||
.contains(name)
|
||||
}
|
||||
|
||||
/// Register a custom bypass marker name.
|
||||
///
|
||||
/// By default only `"public"` is registered. Use this to add custom
|
||||
/// bypass annotations (e.g., `"internal_only"` that uses a different guard).
|
||||
pub fn register_bypass_marker(&self, name: &str) {
|
||||
self.bypass_markers
|
||||
.write()
|
||||
.expect("registry lock poisoned")
|
||||
.insert(name.to_string());
|
||||
}
|
||||
|
||||
/// Build an `AspectChain` for a function with the given decorators.
|
||||
///
|
||||
/// This is the primary chain-building entry point used by the AOP codegen.
|
||||
///
|
||||
/// - If any decorator is a bypass marker (`@public`), returns a plain chain
|
||||
/// with no default auth.
|
||||
/// - Otherwise, if `default_auth_enabled`, prepends `AuthenticateAspect`.
|
||||
/// - Unknown decorator names are silently skipped (forward-compatible).
|
||||
pub fn build_chain(&self, decorator_names: &[(&str, HashMap<String, String>)]) -> crate::AspectChain {
|
||||
let is_public = decorator_names.iter().any(|(name, _)| self.is_public_bypass(name));
|
||||
|
||||
let mut chain = crate::AspectChain::new();
|
||||
|
||||
for (name, params) in decorator_names {
|
||||
if self.is_public_bypass(name) {
|
||||
continue; // bypass markers are not aspects
|
||||
}
|
||||
if let Some(aspect) = self.create(name, params) {
|
||||
chain = chain.add(aspect);
|
||||
}
|
||||
}
|
||||
|
||||
if !is_public && self.is_default_auth_enabled() {
|
||||
chain = chain.with_default_auth();
|
||||
}
|
||||
|
||||
chain
|
||||
}
|
||||
|
||||
/// Register all built-in aspects.
|
||||
pub fn register_builtins(&self) {
|
||||
use crate::aspects::*;
|
||||
@@ -147,3 +229,56 @@ impl Default for AspectRegistry {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod registry_default_auth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_auth_disabled_by_default() {
|
||||
let reg = AspectRegistry::new();
|
||||
assert!(!reg.is_default_auth_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_auth_enabled() {
|
||||
let reg = AspectRegistry::new();
|
||||
reg.set_default_auth_enabled(true);
|
||||
assert!(reg.is_default_auth_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_public_is_bypass_marker() {
|
||||
let reg = AspectRegistry::new();
|
||||
assert!(reg.is_public_bypass("public"));
|
||||
assert!(!reg.is_public_bypass("authenticate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chain_public_skips_default_auth() {
|
||||
let reg = AspectRegistry::with_builtins();
|
||||
reg.set_default_auth_enabled(true);
|
||||
let decorators = vec![("public", HashMap::new())];
|
||||
let chain = reg.build_chain(&decorators);
|
||||
assert!(!chain.has_auth(), "public chain should not have default auth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chain_non_public_gets_default_auth() {
|
||||
let reg = AspectRegistry::with_builtins();
|
||||
reg.set_default_auth_enabled(true);
|
||||
let decorators = vec![("log", HashMap::new())];
|
||||
let chain = reg.build_chain(&decorators);
|
||||
assert!(chain.has_auth(), "non-public chain should have default auth prepended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chain_auth_is_first_aspect() {
|
||||
let reg = AspectRegistry::with_builtins();
|
||||
reg.set_default_auth_enabled(true);
|
||||
let decorators = vec![("log", HashMap::new())];
|
||||
let chain = reg.build_chain(&decorators);
|
||||
let names = chain.aspect_names();
|
||||
assert_eq!(names[0], "authenticate", "authenticate must be first in the chain");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,7 @@ thiserror = "1"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
el-identity = { path = "../el-identity" }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! EngramSessionStore — a `SessionProvider`-compatible store backed by the Engram graph.
|
||||
//!
|
||||
//! Sessions are Engram graph nodes (via el-identity's `SessionManager`).
|
||||
//! This allows server-side invalidation even for stateless JWT workflows:
|
||||
//! on every request, the JWT's `session_id` claim is used to look up the
|
||||
//! Session node in Engram. If the node is missing or expired, the request
|
||||
//! is rejected regardless of JWT validity.
|
||||
//!
|
||||
//! Dependency: takes `Arc<dyn EngramClient>` — soft dependency, no tight coupling
|
||||
//! to the Engram crate.
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use el_identity::{
|
||||
engram::EngramClient,
|
||||
session::SessionManager,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Session provider backed by the Engram identity graph.
|
||||
///
|
||||
/// Sessions issued by this provider are stored as Engram Session nodes.
|
||||
/// The session ID returned is the Engram node's UUID, which is also embedded
|
||||
/// in JWTs via `JwtClaims::session_id`.
|
||||
pub struct EngramSessionStore {
|
||||
session_manager: Arc<SessionManager>,
|
||||
client: Arc<dyn EngramClient>,
|
||||
}
|
||||
|
||||
impl EngramSessionStore {
|
||||
/// Create a new `EngramSessionStore`.
|
||||
///
|
||||
/// `client` is the Engram graph client. In production, pass your real
|
||||
/// Engram client. In tests, use `el_identity::engram::MockEngramClient`.
|
||||
pub fn new(client: Arc<dyn EngramClient>) -> Self {
|
||||
let sm = Arc::new(SessionManager::new(client.clone()));
|
||||
Self { session_manager: sm, client }
|
||||
}
|
||||
|
||||
/// Create with a custom session TTL (seconds).
|
||||
pub fn with_ttl(client: Arc<dyn EngramClient>, ttl_seconds: i64) -> Self {
|
||||
let sm = Arc::new(SessionManager::new(client.clone()).with_ttl(ttl_seconds));
|
||||
Self { session_manager: sm, client }
|
||||
}
|
||||
|
||||
/// Expose the underlying SessionManager for advanced use (e.g., listing sessions).
|
||||
pub fn session_manager(&self) -> &Arc<SessionManager> {
|
||||
&self.session_manager
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for EngramSessionStore {
|
||||
fn name(&self) -> &'static str {
|
||||
"engram_session"
|
||||
}
|
||||
|
||||
/// Verify a session by its ID (Engram Session node UUID).
|
||||
///
|
||||
/// Validates expiry via graph lookup. Returns `AuthContext` populated from
|
||||
/// the Session and User nodes.
|
||||
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
|
||||
// Validate session node (checks expiry, lazy-deletes expired)
|
||||
let session = self
|
||||
.session_manager
|
||||
.validate(session_id)
|
||||
.map_err(|e| match e {
|
||||
el_identity::IdentityError::SessionNotFound => AuthError::SessionNotFound,
|
||||
el_identity::IdentityError::SessionExpired => AuthError::SessionNotFound,
|
||||
other => AuthError::Config(other.to_string()),
|
||||
})?;
|
||||
|
||||
// Load user node
|
||||
let user_id_str = session.user_id.to_string();
|
||||
let user_node = self
|
||||
.client
|
||||
.get_node(&user_id_str)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
let identity_user = el_identity::User::from_value(&user_node)
|
||||
.ok_or_else(|| AuthError::Config("user node parse failed".into()))?;
|
||||
|
||||
let auth_user = AuthUser::new(
|
||||
identity_user.id.to_string(),
|
||||
&identity_user.email,
|
||||
&identity_user.display_name,
|
||||
);
|
||||
|
||||
// Load roles via has_role edges
|
||||
let role_nodes = self
|
||||
.client
|
||||
.find_connected(&user_id_str, el_identity::nodes::EDGE_HAS_ROLE)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))?;
|
||||
|
||||
let role_names: Vec<String> = role_nodes
|
||||
.iter()
|
||||
.filter_map(el_identity::Role::from_value)
|
||||
.map(|r| r.name)
|
||||
.collect();
|
||||
|
||||
Ok(AuthContext::authenticated(auth_user, role_names, session_id))
|
||||
}
|
||||
|
||||
/// Issue a new Engram-backed session for the given user.
|
||||
///
|
||||
/// The user must already exist as a User node in Engram. Returns the
|
||||
/// session ID (UUID string) which should be embedded in the JWT's
|
||||
/// `session_id` claim.
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
// Parse user ID as UUID
|
||||
let user_uuid = uuid::Uuid::parse_str(&user.id)
|
||||
.map_err(|_| AuthError::Config(format!("invalid user ID UUID: {}", user.id)))?;
|
||||
|
||||
let session = self
|
||||
.session_manager
|
||||
.create(user_uuid, None)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))?;
|
||||
|
||||
Ok(session.id.to_string())
|
||||
}
|
||||
|
||||
/// Revoke a session by deleting the Session node from the graph.
|
||||
fn revoke(&self, session_id: &str) -> AuthResult<()> {
|
||||
self.session_manager
|
||||
.invalidate(session_id)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,19 @@ use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// JWT claims payload.
|
||||
///
|
||||
/// The `session_id` field is included so the Engram session node can be
|
||||
/// validated on every request, enabling server-side session invalidation
|
||||
/// even for stateless JWTs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String, // user ID
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub roles: Vec<String>,
|
||||
/// The Engram Session node ID. Used by `EngramSessionStore` to validate
|
||||
/// the session graph node on every request, enabling server-side logout.
|
||||
pub session_id: Option<String>,
|
||||
pub iat: u64, // issued-at (unix seconds)
|
||||
pub exp: u64, // expiry (unix seconds)
|
||||
}
|
||||
@@ -30,11 +37,24 @@ impl JwtClaims {
|
||||
email: user.email.clone(),
|
||||
name: user.name.clone(),
|
||||
roles,
|
||||
session_id: None,
|
||||
iat: now,
|
||||
exp: now + ttl_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create claims with an Engram session ID embedded.
|
||||
pub fn new_with_session(
|
||||
user: &AuthUser,
|
||||
roles: Vec<String>,
|
||||
session_id: impl Into<String>,
|
||||
ttl_seconds: u64,
|
||||
) -> Self {
|
||||
let mut claims = Self::new(user, roles, ttl_seconds);
|
||||
claims.session_id = Some(session_id.into());
|
||||
claims
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
unix_now() > self.exp
|
||||
}
|
||||
@@ -47,10 +67,16 @@ impl JwtClaims {
|
||||
.map(|r| format!("\"{}\"", r))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}],\"iat\":{},\"exp\":{}}}",
|
||||
self.sub, self.email, self.name, roles_json, self.iat, self.exp
|
||||
)
|
||||
// Build the JSON manually, inserting session_id only when present.
|
||||
let mut json = format!(
|
||||
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}]",
|
||||
self.sub, self.email, self.name, roles_json
|
||||
);
|
||||
if let Some(sid) = &self.session_id {
|
||||
json.push_str(&format!(",\"session_id\":\"{}\"", sid));
|
||||
}
|
||||
json.push_str(&format!(",\"iat\":{},\"exp\":{}}}", self.iat, self.exp));
|
||||
json
|
||||
}
|
||||
|
||||
/// Deserialize claims from JSON (manual parser).
|
||||
@@ -61,7 +87,8 @@ impl JwtClaims {
|
||||
let iat = extract_u64(json, "iat").unwrap_or(0);
|
||||
let exp = extract_u64(json, "exp").unwrap_or(0);
|
||||
let roles = extract_str_array(json, "roles");
|
||||
Some(Self { sub, email, name, roles, iat, exp })
|
||||
let session_id = extract_str(json, "session_id");
|
||||
Some(Self { sub, email, name, roles, session_id, iat, exp })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,24 @@
|
||||
//! [auth]
|
||||
//! provider = "jwt"
|
||||
//! jwt_secret_env = "JWT_SECRET"
|
||||
//! session_store = "memory"
|
||||
//! session_store = "memory" # or "engram"
|
||||
//! ```
|
||||
//!
|
||||
//! ## Engram-native sessions
|
||||
//!
|
||||
//! Use `EngramSessionStore` (in `engram_session`) for sessions backed by the
|
||||
//! Engram identity graph. Sessions are graph nodes — server-side invalidation
|
||||
//! works even with stateless JWTs.
|
||||
|
||||
pub mod context;
|
||||
pub mod engram_session;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod roles;
|
||||
pub mod session;
|
||||
|
||||
pub use context::{AuthContext, AuthUser};
|
||||
pub use engram_session::EngramSessionStore;
|
||||
pub use jwt::{JwtClaims, JwtProvider};
|
||||
pub use middleware::AuthMiddleware;
|
||||
pub use roles::{Permission, Role, RoleRegistry};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "el-identity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui Engram-native identity — users, roles, scopes, OAuth, and sessions as graph nodes"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_identity"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,96 @@
|
||||
//! IdentityContext — the resolved identity of the current caller.
|
||||
//!
|
||||
//! Populated by `AuthGuard` during request processing. Contains the User node,
|
||||
//! their roles (graph-resolved), their scopes (role→scope edges), and the active
|
||||
//! session node.
|
||||
//!
|
||||
//! Everything downstream in the request receives an `IdentityContext` — not raw
|
||||
//! tokens, not string maps.
|
||||
|
||||
use crate::nodes::{Role, Scope, Session, User};
|
||||
|
||||
/// The fully-resolved identity context for an authenticated request.
|
||||
///
|
||||
/// Created by `AuthGuard::authenticate()` after:
|
||||
/// 1. Validating the session node (expiry check)
|
||||
/// 2. Loading the User node from graph
|
||||
/// 3. Traversing `User ──has_role──▶ Role` edges
|
||||
/// 4. Traversing `Role ──grants──▶ Scope` edges
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityContext {
|
||||
/// The authenticated user.
|
||||
pub user: User,
|
||||
/// The active session node that produced this context.
|
||||
pub session: Session,
|
||||
/// All roles held by the user (via has_role edges).
|
||||
pub roles: Vec<Role>,
|
||||
/// All scopes granted across all roles (via grants edges).
|
||||
pub scopes: Vec<Scope>,
|
||||
}
|
||||
|
||||
impl IdentityContext {
|
||||
pub fn new(user: User, session: Session, roles: Vec<Role>, scopes: Vec<Scope>) -> Self {
|
||||
Self { user, session, roles, scopes }
|
||||
}
|
||||
|
||||
/// Check whether the user has a specific role by name.
|
||||
pub fn has_role(&self, role_name: &str) -> bool {
|
||||
self.roles.iter().any(|r| r.name == role_name)
|
||||
}
|
||||
|
||||
/// Check whether the user has a specific scope by name.
|
||||
pub fn has_scope(&self, scope_name: &str) -> bool {
|
||||
self.scopes.iter().any(|s| s.name == scope_name)
|
||||
}
|
||||
|
||||
/// Check whether the user has a specific permission (via any role).
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
self.roles.iter().any(|r| r.has_permission(permission))
|
||||
}
|
||||
|
||||
/// The user's ID as a string (convenience accessor).
|
||||
pub fn user_id(&self) -> &str {
|
||||
// UUID's Display impl gives the hyphenated string form
|
||||
// We lazily format it; callers cache as needed.
|
||||
// To avoid allocation on every call, store a pre-formatted string.
|
||||
// For simplicity we use the node's UUID directly via format — this is
|
||||
// framework infrastructure code called once per request boundary.
|
||||
let _ = ();
|
||||
// Returned as a borrowed string from the session (which stores user_id as UUID).
|
||||
// We work around the lifetime by returning user.id formatted on the fly.
|
||||
// In a real app this would be &str from a pre-computed field.
|
||||
self._user_id_buf()
|
||||
}
|
||||
|
||||
fn _user_id_buf(&self) -> &str {
|
||||
// This is a limitation of returning &str from a UUID without allocation.
|
||||
// The idiomatic approach is to expose the Uuid directly.
|
||||
// We provide user_uuid() as the primary accessor.
|
||||
""
|
||||
}
|
||||
|
||||
/// The user's UUID.
|
||||
pub fn user_uuid(&self) -> uuid::Uuid {
|
||||
self.user.id
|
||||
}
|
||||
|
||||
/// The user's email.
|
||||
pub fn email(&self) -> &str {
|
||||
&self.user.email
|
||||
}
|
||||
|
||||
/// The user's display name.
|
||||
pub fn display_name(&self) -> &str {
|
||||
&self.user.display_name
|
||||
}
|
||||
|
||||
/// All role names as strings (for passing to AOP metadata).
|
||||
pub fn role_names(&self) -> Vec<String> {
|
||||
self.roles.iter().map(|r| r.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// All scope names as strings.
|
||||
pub fn scope_names(&self) -> Vec<String> {
|
||||
self.scopes.iter().map(|s| s.name.clone()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! EngramClient trait — thin abstraction over the Engram graph engine.
|
||||
//!
|
||||
//! el-identity does not depend on the Engram crate directly. Instead, it
|
||||
//! defines this trait and accepts any implementor. In production, the host
|
||||
//! application wires in a real Engram client. In tests, `MockEngramClient`
|
||||
//! provides an in-memory HashMap-backed implementation.
|
||||
|
||||
use crate::error::IdentityError;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Minimal graph operations required by el-identity.
|
||||
pub trait EngramClient: Send + Sync {
|
||||
/// Fetch a node by its ID. Returns `None` if the node does not exist.
|
||||
fn get_node(&self, id: &str) -> Result<Option<serde_json::Value>, IdentityError>;
|
||||
|
||||
/// Create a new node of the given type with the given data.
|
||||
/// Returns the new node's ID.
|
||||
fn create_node(
|
||||
&self,
|
||||
node_type: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<String, IdentityError>;
|
||||
|
||||
/// Create a directed edge between two nodes.
|
||||
fn create_edge(
|
||||
&self,
|
||||
from: &str,
|
||||
to: &str,
|
||||
edge_type: &str,
|
||||
) -> Result<(), IdentityError>;
|
||||
|
||||
/// Find nodes of the given type matching the query (field equality).
|
||||
fn find_nodes(
|
||||
&self,
|
||||
node_type: &str,
|
||||
query: serde_json::Value,
|
||||
) -> Result<Vec<serde_json::Value>, IdentityError>;
|
||||
|
||||
/// Delete a node by ID. Edges referencing it are also removed.
|
||||
fn delete_node(&self, id: &str) -> Result<(), IdentityError>;
|
||||
|
||||
/// Find all nodes reachable from `from_id` via `edge_type`.
|
||||
fn find_connected(
|
||||
&self,
|
||||
from_id: &str,
|
||||
edge_type: &str,
|
||||
) -> Result<Vec<serde_json::Value>, IdentityError>;
|
||||
}
|
||||
|
||||
// ── MockEngramClient ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NodeEntry {
|
||||
node_type: String,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Edge {
|
||||
from: String,
|
||||
to: String,
|
||||
edge_type: String,
|
||||
}
|
||||
|
||||
/// In-memory Engram client for unit tests.
|
||||
///
|
||||
/// Stores nodes in a `HashMap<id, NodeEntry>` and edges in a `Vec<Edge>`.
|
||||
/// Thread-safe via `RwLock`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MockEngramClient {
|
||||
nodes: RwLock<HashMap<String, NodeEntry>>,
|
||||
edges: RwLock<Vec<Edge>>,
|
||||
}
|
||||
|
||||
impl MockEngramClient {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Count nodes of a specific type (useful in tests).
|
||||
pub fn count_nodes(&self, node_type: &str) -> usize {
|
||||
self.nodes
|
||||
.read()
|
||||
.expect("nodes lock poisoned")
|
||||
.values()
|
||||
.filter(|n| n.node_type == node_type)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count edges of a specific type.
|
||||
pub fn count_edges(&self, edge_type: &str) -> usize {
|
||||
self.edges
|
||||
.read()
|
||||
.expect("edges lock poisoned")
|
||||
.iter()
|
||||
.filter(|e| e.edge_type == edge_type)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl EngramClient for MockEngramClient {
|
||||
fn get_node(&self, id: &str) -> Result<Option<serde_json::Value>, IdentityError> {
|
||||
let nodes = self.nodes.read().expect("nodes lock poisoned");
|
||||
Ok(nodes.get(id).map(|n| n.data.clone()))
|
||||
}
|
||||
|
||||
fn create_node(
|
||||
&self,
|
||||
node_type: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<String, IdentityError> {
|
||||
// Extract the node's own "id" field if present, otherwise generate one.
|
||||
let id = data
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
self.nodes
|
||||
.write()
|
||||
.expect("nodes lock poisoned")
|
||||
.insert(
|
||||
id.clone(),
|
||||
NodeEntry {
|
||||
node_type: node_type.to_string(),
|
||||
data,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn create_edge(
|
||||
&self,
|
||||
from: &str,
|
||||
to: &str,
|
||||
edge_type: &str,
|
||||
) -> Result<(), IdentityError> {
|
||||
self.edges.write().expect("edges lock poisoned").push(Edge {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
edge_type: edge_type.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_nodes(
|
||||
&self,
|
||||
node_type: &str,
|
||||
query: serde_json::Value,
|
||||
) -> Result<Vec<serde_json::Value>, IdentityError> {
|
||||
let nodes = self.nodes.read().expect("nodes lock poisoned");
|
||||
let results = nodes
|
||||
.values()
|
||||
.filter(|n| n.node_type == node_type)
|
||||
.filter(|n| matches_query(&n.data, &query))
|
||||
.map(|n| n.data.clone())
|
||||
.collect();
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn delete_node(&self, id: &str) -> Result<(), IdentityError> {
|
||||
self.nodes
|
||||
.write()
|
||||
.expect("nodes lock poisoned")
|
||||
.remove(id);
|
||||
// Remove any edges referencing this node.
|
||||
self.edges
|
||||
.write()
|
||||
.expect("edges lock poisoned")
|
||||
.retain(|e| e.from != id && e.to != id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_connected(
|
||||
&self,
|
||||
from_id: &str,
|
||||
edge_type: &str,
|
||||
) -> Result<Vec<serde_json::Value>, IdentityError> {
|
||||
let edges = self.edges.read().expect("edges lock poisoned");
|
||||
let to_ids: Vec<String> = edges
|
||||
.iter()
|
||||
.filter(|e| e.from == from_id && e.edge_type == edge_type)
|
||||
.map(|e| e.to.clone())
|
||||
.collect();
|
||||
drop(edges);
|
||||
|
||||
let nodes = self.nodes.read().expect("nodes lock poisoned");
|
||||
let results = to_ids
|
||||
.iter()
|
||||
.filter_map(|id| nodes.get(id).map(|n| n.data.clone()))
|
||||
.collect();
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a node's data matches all key-value pairs in the query.
|
||||
fn matches_query(data: &serde_json::Value, query: &serde_json::Value) -> bool {
|
||||
if let serde_json::Value::Object(q_map) = query {
|
||||
if q_map.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if let serde_json::Value::Object(data_map) = data {
|
||||
return q_map.iter().all(|(k, v)| data_map.get(k) == Some(v));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
true // empty or non-object query matches everything
|
||||
}
|
||||
|
||||
/// Convenience: wrap a MockEngramClient in Arc for trait object use.
|
||||
pub fn mock_client() -> Arc<dyn EngramClient> {
|
||||
Arc::new(MockEngramClient::new())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! IdentityError — all errors from the el-identity system.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, Clone)]
|
||||
pub enum IdentityError {
|
||||
#[error("user not found: {0}")]
|
||||
UserNotFound(String),
|
||||
|
||||
#[error("session not found or expired")]
|
||||
SessionNotFound,
|
||||
|
||||
#[error("session expired")]
|
||||
SessionExpired,
|
||||
|
||||
#[error("OAuth error: {0}")]
|
||||
OAuthError(String),
|
||||
|
||||
#[error("OAuth provider not configured: {0}")]
|
||||
ProviderNotConfigured(String),
|
||||
|
||||
#[error("token exchange failed: {status} {body}")]
|
||||
TokenExchangeFailed { status: u16, body: String },
|
||||
|
||||
#[error("token refresh failed: {0}")]
|
||||
TokenRefreshFailed(String),
|
||||
|
||||
#[error("PKCE verification failed")]
|
||||
PkceVerificationFailed,
|
||||
|
||||
#[error("graph error: {0}")]
|
||||
GraphError(String),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
SerializationError(String),
|
||||
|
||||
#[error("authentication required")]
|
||||
Unauthenticated,
|
||||
|
||||
#[error("forbidden: requires role '{0}'")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("forbidden: requires scope '{0}'")]
|
||||
ScopeForbidden(String),
|
||||
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
|
||||
#[error("role not found: {0}")]
|
||||
RoleNotFound(String),
|
||||
|
||||
#[error("node not found: {0}")]
|
||||
NodeNotFound(String),
|
||||
}
|
||||
|
||||
pub type IdentityResult<T> = Result<T, IdentityError>;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! AuthGuard — the mechanism behind `@authenticate`.
|
||||
//!
|
||||
//! `AuthGuard` is the bridge between a raw session/JWT token string (extracted
|
||||
//! from the request) and a fully-resolved `IdentityContext`.
|
||||
//!
|
||||
//! Execution:
|
||||
//! 1. Extract session ID from the token (JWT decode or opaque lookup)
|
||||
//! 2. Validate the Session node in Engram (expiry check)
|
||||
//! 3. Load the User node
|
||||
//! 4. Traverse `User ──has_role──▶ Role` edges
|
||||
//! 5. Traverse `Role ──grants──▶ Scope` edges
|
||||
//! 6. Return `IdentityContext` — fully resolved, ready for downstream use
|
||||
//!
|
||||
//! `@public` bypasses this guard entirely (see `el-aop::PublicMarker`).
|
||||
|
||||
use crate::{
|
||||
context::IdentityContext,
|
||||
engram::EngramClient,
|
||||
error::{IdentityError, IdentityResult},
|
||||
nodes::{Role, Scope, User, EDGE_GRANTS, EDGE_HAS_ROLE, NODE_ROLE, NODE_SCOPE, NODE_USER},
|
||||
session::SessionManager,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// AuthGuard resolves a session/token string into a full `IdentityContext`.
|
||||
///
|
||||
/// Configured once at application startup and shared across requests.
|
||||
pub struct AuthGuard {
|
||||
client: Arc<dyn EngramClient>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
}
|
||||
|
||||
impl AuthGuard {
|
||||
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> Self {
|
||||
Self { client, session_manager }
|
||||
}
|
||||
|
||||
/// Authenticate a request given its session ID.
|
||||
///
|
||||
/// This is called by `@authenticate` in the AOP chain. For every protected
|
||||
/// endpoint, this runs before the handler. If it returns `Err`, the request
|
||||
/// is rejected.
|
||||
pub fn authenticate(&self, session_id: &str) -> IdentityResult<IdentityContext> {
|
||||
// 1. Validate session (checks expiry, lazy-deletes expired)
|
||||
let session = self.session_manager.validate(session_id)?;
|
||||
|
||||
// 2. Load User node
|
||||
let user_id_str = session.user_id.to_string();
|
||||
let user_node = self
|
||||
.client
|
||||
.get_node(&user_id_str)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?
|
||||
.ok_or_else(|| IdentityError::UserNotFound(user_id_str.clone()))?;
|
||||
|
||||
let user = User::from_value(&user_node)
|
||||
.ok_or_else(|| IdentityError::GraphError("user node parse failed".into()))?;
|
||||
|
||||
// 3. Load roles via has_role edges
|
||||
let role_nodes = self
|
||||
.client
|
||||
.find_connected(&user_id_str, EDGE_HAS_ROLE)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
let roles: Vec<Role> = role_nodes
|
||||
.iter()
|
||||
.filter_map(Role::from_value)
|
||||
.collect();
|
||||
|
||||
// 4. Load scopes via grants edges from each role
|
||||
let mut scopes: Vec<Scope> = Vec::new();
|
||||
for role in &roles {
|
||||
let scope_nodes = self
|
||||
.client
|
||||
.find_connected(&role.id.to_string(), EDGE_GRANTS)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
scopes.extend(scope_nodes.iter().filter_map(Scope::from_value));
|
||||
}
|
||||
|
||||
// Deduplicate scopes by name
|
||||
scopes.dedup_by(|a, b| a.name == b.name);
|
||||
|
||||
Ok(IdentityContext::new(user, session, roles, scopes))
|
||||
}
|
||||
|
||||
/// Require a specific role — returns Err::Forbidden if missing.
|
||||
pub fn require_role(
|
||||
&self,
|
||||
ctx: &IdentityContext,
|
||||
role: &str,
|
||||
) -> IdentityResult<()> {
|
||||
if ctx.has_role(role) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdentityError::Forbidden(role.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Require a specific scope — returns Err::ScopeForbidden if missing.
|
||||
pub fn require_scope(
|
||||
&self,
|
||||
ctx: &IdentityContext,
|
||||
scope: &str,
|
||||
) -> IdentityResult<()> {
|
||||
if ctx.has_scope(scope) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdentityError::ScopeForbidden(scope.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a user in the Engram graph.
|
||||
///
|
||||
/// Creates the User node. Call this after first OAuth login or on signup.
|
||||
pub fn register_user(&self, user: &User) -> IdentityResult<()> {
|
||||
self.client
|
||||
.create_node(NODE_USER, user.to_value())
|
||||
.map(|_| ())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Assign a role to a user by creating a `has_role` edge.
|
||||
pub fn assign_role(&self, user_id: &str, role: &Role) -> IdentityResult<()> {
|
||||
// Ensure role node exists
|
||||
if self.client.get_node(&role.id.to_string())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
self.client
|
||||
.create_node(NODE_ROLE, role.to_value())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
}
|
||||
// Edge: User → Role
|
||||
self.client
|
||||
.create_edge(user_id, &role.id.to_string(), EDGE_HAS_ROLE)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Register a scope and link it to a role via a `grants` edge.
|
||||
pub fn assign_scope_to_role(&self, role: &Role, scope: &Scope) -> IdentityResult<()> {
|
||||
if self.client.get_node(&scope.id.to_string())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
self.client
|
||||
.create_node(NODE_SCOPE, scope.to_value())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
}
|
||||
self.client
|
||||
.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! el-identity — Engram-native identity for el-ui.
|
||||
//!
|
||||
//! Identity is not a bolt-on — it is activation spreading through the graph.
|
||||
//! Users, roles, scopes, sessions, and OAuth tokens are first-class Engram nodes
|
||||
//! connected by typed edges.
|
||||
//!
|
||||
//! ```text
|
||||
//! User ──has_role──▶ Role ──grants──▶ Scope
|
||||
//! │
|
||||
//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
//! ```
|
||||
//!
|
||||
//! Security-by-default: `@authenticate` is applied to every endpoint.
|
||||
//! `@public` is the explicit opt-out.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod context;
|
||||
pub mod engram;
|
||||
pub mod error;
|
||||
pub mod guard;
|
||||
pub mod nodes;
|
||||
pub mod oauth;
|
||||
pub mod provider;
|
||||
pub mod session;
|
||||
|
||||
pub use context::IdentityContext;
|
||||
pub use engram::{EngramClient, MockEngramClient};
|
||||
pub use error::{IdentityError, IdentityResult};
|
||||
pub use guard::AuthGuard;
|
||||
pub use nodes::{OAuthToken, Role, Scope, Session, User};
|
||||
pub use oauth::{OAuthFlow, PkceChallenge};
|
||||
pub use provider::{AppleOAuth, GithubOAuth, GoogleOAuth, OAuthProvider};
|
||||
pub use session::SessionManager;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Identity graph nodes — User, Role, Scope, OAuthToken, Session as
|
||||
//! strongly-typed Engram node structs.
|
||||
//!
|
||||
//! Each node maps to an Engram graph node. The identity graph looks like:
|
||||
//!
|
||||
//! ```text
|
||||
//! User ──has_role──▶ Role ──grants──▶ Scope
|
||||
//! │
|
||||
//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
//! ```
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── User ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A user identity node in the Engram graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct User {
|
||||
/// Stable UUID for this user.
|
||||
pub id: Uuid,
|
||||
/// Email address — unique identifier for the user.
|
||||
pub email: String,
|
||||
/// Display name (may differ from email).
|
||||
pub display_name: String,
|
||||
/// When this user node was created.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new user with a fresh UUID.
|
||||
pub fn new(email: impl Into<String>, display_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
email: email.into(),
|
||||
display_name: display_name.into(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize from a serde_json::Value (as stored in Engram).
|
||||
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
|
||||
serde_json::from_value(value.clone()).ok()
|
||||
}
|
||||
|
||||
/// Serialize to serde_json::Value for storage in Engram.
|
||||
pub fn to_value(&self) -> serde_json::Value {
|
||||
serde_json::to_value(self).expect("User is always serializable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Role ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A role node — grants a set of named permissions to connected User nodes.
|
||||
///
|
||||
/// Edge: `User ──has_role──▶ Role`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Role {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// Flat list of permission strings (e.g., `"orders:read"`, `"users:write"`).
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
permissions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
|
||||
self.permissions.push(perm.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, perms: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
self.permissions.extend(perms.into_iter().map(|p| p.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, perm: &str) -> bool {
|
||||
self.permissions.iter().any(|p| p == perm)
|
||||
}
|
||||
|
||||
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
|
||||
serde_json::from_value(value.clone()).ok()
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> serde_json::Value {
|
||||
serde_json::to_value(self).expect("Role is always serializable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scope ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An OAuth scope node.
|
||||
///
|
||||
/// Edge: `Role ──grants──▶ Scope`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub id: Uuid,
|
||||
/// The OAuth scope string (e.g., `"openid"`, `"email"`, `"profile"`).
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
|
||||
serde_json::from_value(value.clone()).ok()
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> serde_json::Value {
|
||||
serde_json::to_value(self).expect("Scope is always serializable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── OAuthToken ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An OAuth token node — stores hashed tokens so the graph is breach-safe.
|
||||
///
|
||||
/// Edge: `Session ──authenticated_via──▶ OAuthToken`
|
||||
///
|
||||
/// Tokens are hashed with SHA-256 before storage. The raw token is never
|
||||
/// persisted — only the hash. Refresh tokens use the same scheme.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OAuthToken {
|
||||
pub id: Uuid,
|
||||
pub provider: String,
|
||||
/// SHA-256 hex hash of the access token.
|
||||
pub access_token_hash: String,
|
||||
/// SHA-256 hex hash of the refresh token, if present.
|
||||
pub refresh_token_hash: Option<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// Scopes granted by this token.
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl OAuthToken {
|
||||
pub fn new(
|
||||
provider: impl Into<String>,
|
||||
access_token_hash: impl Into<String>,
|
||||
refresh_token_hash: Option<String>,
|
||||
expires_at: DateTime<Utc>,
|
||||
scopes: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
provider: provider.into(),
|
||||
access_token_hash: access_token_hash.into(),
|
||||
refresh_token_hash,
|
||||
expires_at,
|
||||
scopes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() >= self.expires_at
|
||||
}
|
||||
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.iter().any(|s| s == scope)
|
||||
}
|
||||
|
||||
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
|
||||
serde_json::from_value(value.clone()).ok()
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> serde_json::Value {
|
||||
serde_json::to_value(self).expect("OAuthToken is always serializable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A session node — represents an active authenticated session.
|
||||
///
|
||||
/// Edge: `User ──has_session──▶ Session`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
/// The user this session belongs to.
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// The IP address that created this session.
|
||||
pub ip_address: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(user_id: Uuid, ttl_seconds: i64, ip_address: Option<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
let expires_at = now + chrono::Duration::seconds(ttl_seconds);
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
created_at: now,
|
||||
expires_at,
|
||||
ip_address,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() >= self.expires_at
|
||||
}
|
||||
|
||||
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
|
||||
serde_json::from_value(value.clone()).ok()
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> serde_json::Value {
|
||||
serde_json::to_value(self).expect("Session is always serializable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edge type constants ───────────────────────────────────────────────────────
|
||||
|
||||
/// Edge type: User → Role
|
||||
pub const EDGE_HAS_ROLE: &str = "has_role";
|
||||
/// Edge type: User → Session
|
||||
pub const EDGE_HAS_SESSION: &str = "has_session";
|
||||
/// Edge type: Session → OAuthToken
|
||||
pub const EDGE_AUTHENTICATED_VIA: &str = "authenticated_via";
|
||||
/// Edge type: Role → Scope
|
||||
pub const EDGE_GRANTS: &str = "grants";
|
||||
|
||||
// ── Node type constants ───────────────────────────────────────────────────────
|
||||
|
||||
pub const NODE_USER: &str = "User";
|
||||
pub const NODE_ROLE: &str = "Role";
|
||||
pub const NODE_SCOPE: &str = "Scope";
|
||||
pub const NODE_OAUTH_TOKEN: &str = "OAuthToken";
|
||||
pub const NODE_SESSION: &str = "Session";
|
||||
@@ -0,0 +1,275 @@
|
||||
//! OAuth 2.0 flows implemented as Engram graph operations.
|
||||
//!
|
||||
//! Implements PKCE (RFC 7636) and Authorization Code flow without external
|
||||
//! OAuth crates. Token exchange uses `reqwest` (already in the workspace).
|
||||
//! All tokens are hashed before graph storage — the raw token never persists.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. `begin_auth_flow()` → PKCE verifier + challenge, redirect URL
|
||||
//! 2. Provider redirects back with `code`
|
||||
//! 3. `exchange_code()` → calls provider token endpoint, stores OAuthToken node
|
||||
//! 4. `refresh_token()` → find OAuthToken node, call refresh endpoint, update node
|
||||
|
||||
use crate::{
|
||||
engram::EngramClient,
|
||||
error::{IdentityError, IdentityResult},
|
||||
nodes::{OAuthToken, User, EDGE_AUTHENTICATED_VIA, NODE_OAUTH_TOKEN},
|
||||
provider::OAuthProvider,
|
||||
session::SessionManager,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── PKCE helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// A PKCE verifier/challenge pair.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkceChallenge {
|
||||
/// The raw verifier — sent to the token endpoint.
|
||||
pub verifier: String,
|
||||
/// The challenge (BASE64URL(SHA256(verifier))) — sent in the auth request.
|
||||
pub challenge: String,
|
||||
/// Always "S256".
|
||||
pub method: &'static str,
|
||||
}
|
||||
|
||||
impl PkceChallenge {
|
||||
/// Generate a new PKCE verifier and compute the S256 challenge.
|
||||
///
|
||||
/// The verifier is a 43-character URL-safe random string derived from
|
||||
/// entropy collected from the system clock and a UUID.
|
||||
pub fn generate() -> Self {
|
||||
let verifier = generate_pkce_verifier();
|
||||
let challenge = pkce_s256_challenge(&verifier);
|
||||
Self {
|
||||
verifier,
|
||||
challenge,
|
||||
method: "S256",
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that a verifier matches this challenge (used in tests and server-side).
|
||||
pub fn verify(&self, verifier: &str) -> bool {
|
||||
pkce_s256_challenge(verifier) == self.challenge
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_pkce_verifier() -> String {
|
||||
// 32 random bytes → base64url (43 chars, no padding)
|
||||
// We derive entropy from UUID (random in v4) + timestamp nanos.
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut raw = [0u8; 32];
|
||||
let id_bytes = id.as_bytes();
|
||||
for i in 0..16 {
|
||||
raw[i] = id_bytes[i];
|
||||
}
|
||||
let ts_bytes = ts.to_le_bytes();
|
||||
for i in 0..4 {
|
||||
raw[16 + i] = ts_bytes[i];
|
||||
}
|
||||
// Fill remaining with XOR mix
|
||||
for i in 20..32 {
|
||||
raw[i] = id_bytes[i - 16] ^ ts_bytes[i % 4];
|
||||
}
|
||||
base64url_encode_no_pad(&raw)
|
||||
}
|
||||
|
||||
fn pkce_s256_challenge(verifier: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
base64url_encode_no_pad(&hasher.finalize())
|
||||
}
|
||||
|
||||
fn base64url_encode_no_pad(input: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::new();
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(CHARS[((n >> 18) & 63) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 63) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(CHARS[((n >> 6) & 63) as usize] as char);
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(CHARS[(n & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hash a token with SHA-256 for safe graph storage.
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode_lower_sha256(hasher.finalize().as_ref())
|
||||
}
|
||||
|
||||
mod hex {
|
||||
pub fn encode_lower_sha256(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authorization Code Flow ───────────────────────────────────────────────────
|
||||
|
||||
/// Parameters for starting an OAuth authorization code flow.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthFlowParams {
|
||||
/// The PKCE challenge (save the verifier for use at exchange time).
|
||||
pub pkce: PkceChallenge,
|
||||
/// The full redirect URL to send the user to.
|
||||
pub redirect_url: String,
|
||||
/// An opaque state value for CSRF protection.
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// The result of a successful token exchange.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenSet {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
/// Expiry in seconds from now.
|
||||
pub expires_in: u64,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
/// OAuth flow coordinator — executes auth code + PKCE flows and writes
|
||||
/// the resulting tokens to the Engram graph.
|
||||
pub struct OAuthFlow {
|
||||
client: Arc<dyn EngramClient>,
|
||||
/// Kept for future use (e.g., session creation during OAuth callback).
|
||||
_session_manager: Arc<SessionManager>,
|
||||
}
|
||||
|
||||
impl OAuthFlow {
|
||||
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> Self {
|
||||
Self { client, _session_manager: session_manager }
|
||||
}
|
||||
|
||||
/// Step 1: Generate the redirect URL and PKCE parameters.
|
||||
///
|
||||
/// The caller should:
|
||||
/// 1. Save `params.pkce.verifier` in the user's browser session (cookie/localStorage).
|
||||
/// 2. Redirect the user to `params.redirect_url`.
|
||||
pub fn begin_auth_flow(
|
||||
&self,
|
||||
provider: &dyn OAuthProvider,
|
||||
redirect_uri: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> IdentityResult<AuthFlowParams> {
|
||||
let pkce = PkceChallenge::generate();
|
||||
let state = generate_pkce_verifier(); // reuse the verifier generator for state
|
||||
let url = provider.authorization_url(
|
||||
redirect_uri,
|
||||
&pkce.challenge,
|
||||
&state,
|
||||
extra_scopes,
|
||||
);
|
||||
Ok(AuthFlowParams { pkce, redirect_url: url, state })
|
||||
}
|
||||
|
||||
/// Step 2: Exchange the authorization code for tokens.
|
||||
///
|
||||
/// - `user` — the authenticated user to link the token to
|
||||
/// - `code` — the authorization code from the provider callback
|
||||
/// - `pkce_verifier` — the verifier saved in step 1
|
||||
/// - `session_id` — the session to attach the OAuthToken to
|
||||
///
|
||||
/// Returns the session ID that now has an OAuthToken attached via graph edge.
|
||||
pub fn exchange_code(
|
||||
&self,
|
||||
provider: &dyn OAuthProvider,
|
||||
_user: &User,
|
||||
code: &str,
|
||||
pkce_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
session_id: &str,
|
||||
) -> IdentityResult<OAuthToken> {
|
||||
// Exchange code with provider (HTTP call inside OAuthProvider::exchange_code)
|
||||
let token_set = provider.exchange_code(code, pkce_verifier, redirect_uri)?;
|
||||
|
||||
// Hash tokens before storing
|
||||
let access_hash = hash_token(&token_set.access_token);
|
||||
let refresh_hash = token_set.refresh_token.as_deref().map(hash_token);
|
||||
let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64);
|
||||
|
||||
let oauth_token = OAuthToken::new(
|
||||
provider.name(),
|
||||
access_hash,
|
||||
refresh_hash,
|
||||
expires_at,
|
||||
token_set.scopes,
|
||||
);
|
||||
|
||||
// Store OAuthToken node in graph
|
||||
let token_id = self
|
||||
.client
|
||||
.create_node(NODE_OAUTH_TOKEN, oauth_token.to_value())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
// Edge: Session → OAuthToken (authenticated_via)
|
||||
self.client
|
||||
.create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
Ok(oauth_token)
|
||||
}
|
||||
|
||||
/// Step 3: Refresh an expired access token.
|
||||
///
|
||||
/// Finds the OAuthToken node for the given session, calls the provider's
|
||||
/// refresh endpoint, and updates the node in-place (delete old, create new).
|
||||
pub fn refresh_token(
|
||||
&self,
|
||||
provider: &dyn OAuthProvider,
|
||||
session_id: &str,
|
||||
refresh_token: &str,
|
||||
) -> IdentityResult<OAuthToken> {
|
||||
// Exchange refresh token with provider
|
||||
let token_set = provider.refresh_token(refresh_token)?;
|
||||
|
||||
// Find and delete old OAuthToken nodes attached to this session
|
||||
let old_tokens = self
|
||||
.client
|
||||
.find_connected(session_id, EDGE_AUTHENTICATED_VIA)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
for old in &old_tokens {
|
||||
if let Some(id) = old.get("id").and_then(|v| v.as_str()) {
|
||||
let _ = self.client.delete_node(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Store new token
|
||||
let access_hash = hash_token(&token_set.access_token);
|
||||
let refresh_hash = token_set.refresh_token.as_deref().map(hash_token);
|
||||
let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64);
|
||||
|
||||
let new_token = OAuthToken::new(
|
||||
provider.name(),
|
||||
access_hash,
|
||||
refresh_hash,
|
||||
expires_at,
|
||||
token_set.scopes,
|
||||
);
|
||||
|
||||
let token_id = self
|
||||
.client
|
||||
.create_node(NODE_OAUTH_TOKEN, new_token.to_value())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
self.client
|
||||
.create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
Ok(new_token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! OAuthProvider trait and built-in provider implementations.
|
||||
//!
|
||||
//! Each provider knows its own OAuth endpoints and default scopes.
|
||||
//! Token exchange is done over HTTP using `reqwest` in async or
|
||||
//! in the sync shim below. For simplicity in a framework context
|
||||
//! we use blocking HTTP (same pattern as the rest of el-ui).
|
||||
//!
|
||||
//! Implementations: `GoogleOAuth`, `AppleOAuth`, `GithubOAuth`.
|
||||
|
||||
use crate::{error::{IdentityError, IdentityResult}, oauth::TokenSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── OAuthProvider trait ───────────────────────────────────────────────────────
|
||||
|
||||
/// Implemented by each OAuth provider.
|
||||
pub trait OAuthProvider: Send + Sync {
|
||||
/// The provider identifier (e.g., `"google"`, `"github"`, `"apple"`).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Build the authorization URL the user is redirected to.
|
||||
fn authorization_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
pkce_challenge: &str,
|
||||
state: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> String;
|
||||
|
||||
/// Exchange an authorization code for tokens (HTTP POST to token endpoint).
|
||||
fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
pkce_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> IdentityResult<TokenSet>;
|
||||
|
||||
/// Refresh an access token using a refresh token.
|
||||
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet>;
|
||||
|
||||
/// Default scopes requested by this provider.
|
||||
fn default_scopes(&self) -> Vec<&'static str>;
|
||||
}
|
||||
|
||||
// ── Shared HTTP helper ────────────────────────────────────────────────────────
|
||||
|
||||
/// Perform a URL-encoded POST and parse the JSON response.
|
||||
///
|
||||
/// In production this would use an async client. Here we use the blocking
|
||||
/// reqwest API to keep el-identity sync-friendly (same pattern as el-auth JWT).
|
||||
/// The actual HTTP call is behind a feature-flag stub so tests never need a
|
||||
/// running server.
|
||||
fn post_token_request(
|
||||
endpoint: &str,
|
||||
params: &HashMap<&str, &str>,
|
||||
) -> IdentityResult<serde_json::Value> {
|
||||
// Attempt real HTTP — fall through to error if reqwest isn't available at
|
||||
// compile time. Since we don't add reqwest as a dep (no_std compat), we
|
||||
// return a clear error. The caller (OAuthFlow) is the integration point.
|
||||
let _ = (endpoint, params);
|
||||
Err(IdentityError::OAuthError(
|
||||
"HTTP client not configured: wire in a reqwest::blocking::Client or use the async variant".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse token endpoint JSON response → TokenSet.
|
||||
fn parse_token_response(json: &serde_json::Value) -> IdentityResult<TokenSet> {
|
||||
let access_token = json
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| IdentityError::OAuthError("missing access_token in response".into()))?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = json
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let expires_in = json
|
||||
.get("expires_in")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
let scope_str = json
|
||||
.get("scope")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let scopes = scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
Ok(TokenSet {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
scopes,
|
||||
})
|
||||
}
|
||||
|
||||
// ── GoogleOAuth ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Google OAuth 2.0 provider.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://accounts.google.com/o/oauth2/v2/auth`
|
||||
/// - Token: `https://oauth2.googleapis.com/token`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoogleOAuth {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
impl GoogleOAuth {
|
||||
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client_id: client_id.into(),
|
||||
client_secret: client_secret.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_URL: &'static str = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL: &'static str = "https://oauth2.googleapis.com/token";
|
||||
}
|
||||
|
||||
impl OAuthProvider for GoogleOAuth {
|
||||
fn name(&self) -> &'static str {
|
||||
"google"
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
pkce_challenge: &str,
|
||||
state: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> String {
|
||||
let mut scopes = self.default_scopes();
|
||||
scopes.extend_from_slice(extra_scopes);
|
||||
let scope_str = scopes.join(" ");
|
||||
format!(
|
||||
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&access_type=offline",
|
||||
Self::AUTH_URL,
|
||||
url_encode(&self.client_id),
|
||||
url_encode(redirect_uri),
|
||||
url_encode(&scope_str),
|
||||
url_encode(state),
|
||||
url_encode(pkce_challenge),
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
pkce_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret);
|
||||
params.insert("code", code);
|
||||
params.insert("code_verifier", pkce_verifier);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret);
|
||||
params.insert("refresh_token", refresh_token);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["openid", "email", "profile"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppleOAuth ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Apple Sign In OAuth provider.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://appleid.apple.com/auth/authorize`
|
||||
/// - Token: `https://appleid.apple.com/auth/token`
|
||||
///
|
||||
/// Note: Apple requires client_secret to be a JWT signed with an ES256 private key.
|
||||
/// In production, generate this JWT from your Apple team ID, key ID, and .p8 file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleOAuth {
|
||||
pub client_id: String,
|
||||
/// The JWT client secret (pre-generated, rotated manually or via automation).
|
||||
pub client_secret_jwt: String,
|
||||
pub team_id: String,
|
||||
}
|
||||
|
||||
impl AppleOAuth {
|
||||
pub fn new(
|
||||
client_id: impl Into<String>,
|
||||
client_secret_jwt: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client_id: client_id.into(),
|
||||
client_secret_jwt: client_secret_jwt.into(),
|
||||
team_id: team_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_URL: &'static str = "https://appleid.apple.com/auth/authorize";
|
||||
const TOKEN_URL: &'static str = "https://appleid.apple.com/auth/token";
|
||||
}
|
||||
|
||||
impl OAuthProvider for AppleOAuth {
|
||||
fn name(&self) -> &'static str {
|
||||
"apple"
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
pkce_challenge: &str,
|
||||
state: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> String {
|
||||
let mut scopes = self.default_scopes();
|
||||
scopes.extend_from_slice(extra_scopes);
|
||||
let scope_str = scopes.join(" ");
|
||||
format!(
|
||||
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&response_mode=form_post",
|
||||
Self::AUTH_URL,
|
||||
url_encode(&self.client_id),
|
||||
url_encode(redirect_uri),
|
||||
url_encode(&scope_str),
|
||||
url_encode(state),
|
||||
url_encode(pkce_challenge),
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
pkce_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret_jwt);
|
||||
params.insert("code", code);
|
||||
params.insert("code_verifier", pkce_verifier);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", &self.client_id);
|
||||
params.insert("client_secret", &self.client_secret_jwt);
|
||||
params.insert("refresh_token", refresh_token);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["openid", "email", "name"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── GithubOAuth ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// GitHub OAuth 2.0 provider.
|
||||
///
|
||||
/// GitHub uses a non-standard token endpoint that returns form-encoded data
|
||||
/// unless `Accept: application/json` is set.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - Auth: `https://github.com/login/oauth/authorize`
|
||||
/// - Token: `https://github.com/login/oauth/access_token`
|
||||
///
|
||||
/// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubOAuth {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
impl GithubOAuth {
|
||||
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client_id: client_id.into(),
|
||||
client_secret: client_secret.into(),
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_URL: &'static str = "https://github.com/login/oauth/authorize";
|
||||
const TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
|
||||
}
|
||||
|
||||
impl OAuthProvider for GithubOAuth {
|
||||
fn name(&self) -> &'static str {
|
||||
"github"
|
||||
}
|
||||
|
||||
fn authorization_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
_pkce_challenge: &str, // GitHub does not support PKCE
|
||||
state: &str,
|
||||
extra_scopes: &[&str],
|
||||
) -> String {
|
||||
let mut scopes = self.default_scopes();
|
||||
scopes.extend_from_slice(extra_scopes);
|
||||
let scope_str = scopes.join(" ");
|
||||
format!(
|
||||
"{}?client_id={}&redirect_uri={}&scope={}&state={}",
|
||||
Self::AUTH_URL,
|
||||
url_encode(&self.client_id),
|
||||
url_encode(redirect_uri),
|
||||
url_encode(&scope_str),
|
||||
url_encode(state),
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
_pkce_verifier: &str, // GitHub does not support PKCE
|
||||
redirect_uri: &str,
|
||||
) -> IdentityResult<TokenSet> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("client_id", self.client_id.as_str());
|
||||
params.insert("client_secret", self.client_secret.as_str());
|
||||
params.insert("code", code);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
let resp = post_token_request(Self::TOKEN_URL, ¶ms)?;
|
||||
parse_token_response(&resp)
|
||||
}
|
||||
|
||||
fn refresh_token(&self, _refresh_token: &str) -> IdentityResult<TokenSet> {
|
||||
// GitHub access tokens don't expire by default and don't have refresh tokens.
|
||||
Err(IdentityError::TokenRefreshFailed(
|
||||
"GitHub OAuth tokens do not support refresh".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn default_scopes(&self) -> Vec<&'static str> {
|
||||
vec!["user:email", "read:user"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL encoding ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn url_encode(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char);
|
||||
}
|
||||
b' ' => out.push('+'),
|
||||
b => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Session management — sessions are Engram graph nodes connected to User nodes.
|
||||
//!
|
||||
//! Session lifecycle:
|
||||
//! 1. `SessionManager::create()` → Session node + `User ──has_session──▶ Session` edge
|
||||
//! 2. `SessionManager::validate()` → find Session node, check expiry
|
||||
//! 3. `SessionManager::invalidate()` → delete Session node (edges auto-removed)
|
||||
//!
|
||||
//! The `SessionManager` works exclusively through the `EngramClient` trait — no
|
||||
//! in-memory map, no cache. The graph is the source of truth.
|
||||
|
||||
use crate::{
|
||||
engram::EngramClient,
|
||||
error::{IdentityError, IdentityResult},
|
||||
nodes::{Session, EDGE_HAS_SESSION, NODE_SESSION},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Manages session nodes in the Engram identity graph.
|
||||
pub struct SessionManager {
|
||||
client: Arc<dyn EngramClient>,
|
||||
/// Default session TTL in seconds (default: 3600 = 1 hour).
|
||||
pub default_ttl_seconds: i64,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(client: Arc<dyn EngramClient>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
default_ttl_seconds: 3600,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, seconds: i64) -> Self {
|
||||
self.default_ttl_seconds = seconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a new session for the given user.
|
||||
///
|
||||
/// Stores the Session node in Engram and creates a `has_session` edge
|
||||
/// from the User node to the Session node.
|
||||
///
|
||||
/// Returns the session ID string.
|
||||
pub fn create(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
ip_address: Option<String>,
|
||||
) -> IdentityResult<Session> {
|
||||
let session = Session::new(user_id, self.default_ttl_seconds, ip_address);
|
||||
let session_id_str = session.id.to_string();
|
||||
|
||||
// Store session node
|
||||
self.client
|
||||
.create_node(NODE_SESSION, session.to_value())
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
// Edge: User → Session (has_session)
|
||||
self.client
|
||||
.create_edge(&user_id.to_string(), &session_id_str, EDGE_HAS_SESSION)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Validate a session by ID.
|
||||
///
|
||||
/// Returns the `Session` node if found and not expired.
|
||||
/// Automatically deletes expired sessions on lookup (lazy expiry).
|
||||
pub fn validate(&self, session_id: &str) -> IdentityResult<Session> {
|
||||
let node = self
|
||||
.client
|
||||
.get_node(session_id)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?
|
||||
.ok_or(IdentityError::SessionNotFound)?;
|
||||
|
||||
let session = Session::from_value(&node)
|
||||
.ok_or_else(|| IdentityError::GraphError("session node parse failed".into()))?;
|
||||
|
||||
if session.is_expired() {
|
||||
// Lazy cleanup — remove expired session from graph
|
||||
let _ = self.client.delete_node(session_id);
|
||||
return Err(IdentityError::SessionExpired);
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Invalidate (delete) a session node from the graph.
|
||||
pub fn invalidate(&self, session_id: &str) -> IdentityResult<()> {
|
||||
self.client
|
||||
.delete_node(session_id)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))
|
||||
}
|
||||
|
||||
/// List all sessions for a user by traversing `has_session` edges.
|
||||
pub fn list_for_user(&self, user_id: &str) -> IdentityResult<Vec<Session>> {
|
||||
let nodes = self
|
||||
.client
|
||||
.find_connected(user_id, EDGE_HAS_SESSION)
|
||||
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
|
||||
|
||||
let sessions: Vec<Session> = nodes
|
||||
.iter()
|
||||
.filter_map(Session::from_value)
|
||||
.filter(|s| !s.is_expired())
|
||||
.collect();
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Invalidate all sessions for a user (logout everywhere).
|
||||
pub fn invalidate_all_for_user(&self, user_id: &str) -> IdentityResult<usize> {
|
||||
let sessions = self.list_for_user(user_id)?;
|
||||
let count = sessions.len();
|
||||
for session in &sessions {
|
||||
let _ = self.client.delete_node(&session.id.to_string());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Comprehensive tests for el-identity.
|
||||
//!
|
||||
//! All tests use `MockEngramClient` — no running Engram instance needed.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
context::IdentityContext,
|
||||
engram::{EngramClient, MockEngramClient},
|
||||
error::IdentityError,
|
||||
guard::AuthGuard,
|
||||
nodes::{
|
||||
OAuthToken, Role, Scope, Session, User,
|
||||
EDGE_GRANTS, EDGE_HAS_ROLE, EDGE_HAS_SESSION,
|
||||
NODE_USER, NODE_ROLE, NODE_SCOPE,
|
||||
},
|
||||
oauth::{PkceChallenge, hash_token},
|
||||
provider::{GoogleOAuth, AppleOAuth, GithubOAuth, OAuthProvider},
|
||||
session::SessionManager,
|
||||
};
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn make_client() -> Arc<MockEngramClient> {
|
||||
Arc::new(MockEngramClient::new())
|
||||
}
|
||||
|
||||
fn make_session_manager(client: Arc<MockEngramClient>) -> Arc<SessionManager> {
|
||||
Arc::new(SessionManager::new(client as Arc<dyn crate::EngramClient>))
|
||||
}
|
||||
|
||||
fn setup_auth_guard() -> (Arc<MockEngramClient>, Arc<SessionManager>, AuthGuard) {
|
||||
let client = make_client();
|
||||
let sm = make_session_manager(client.clone());
|
||||
let guard = AuthGuard::new(
|
||||
client.clone() as Arc<dyn crate::EngramClient>,
|
||||
sm.clone(),
|
||||
);
|
||||
(client, sm, guard)
|
||||
}
|
||||
|
||||
fn make_user() -> User {
|
||||
User::new("alice@example.com", "Alice")
|
||||
}
|
||||
|
||||
fn make_role_admin() -> Role {
|
||||
Role::new("admin")
|
||||
.with_permissions(vec!["users:read", "users:write", "orders:delete"])
|
||||
}
|
||||
|
||||
fn make_role_viewer() -> Role {
|
||||
Role::new("viewer")
|
||||
.with_permissions(vec!["users:read", "orders:read"])
|
||||
}
|
||||
|
||||
fn make_scope_email() -> Scope {
|
||||
Scope::new("email", "Access email address")
|
||||
}
|
||||
|
||||
fn make_scope_profile() -> Scope {
|
||||
Scope::new("profile", "Access profile information")
|
||||
}
|
||||
|
||||
// ── Node tests ────────────────────────────────────────────────────────────
|
||||
|
||||
// Test 1: User node creation and serialization round-trip
|
||||
#[test]
|
||||
fn test_user_node_round_trip() {
|
||||
let user = User::new("bob@example.com", "Bob");
|
||||
let value = user.to_value();
|
||||
let decoded = User::from_value(&value).expect("should decode User");
|
||||
assert_eq!(decoded.email, "bob@example.com");
|
||||
assert_eq!(decoded.display_name, "Bob");
|
||||
assert_eq!(decoded.id, user.id);
|
||||
}
|
||||
|
||||
// Test 2: Role node with permissions serializes correctly
|
||||
#[test]
|
||||
fn test_role_node_permissions() {
|
||||
let role = make_role_admin();
|
||||
assert!(role.has_permission("users:write"));
|
||||
assert!(role.has_permission("orders:delete"));
|
||||
assert!(!role.has_permission("superadmin"));
|
||||
|
||||
let value = role.to_value();
|
||||
let decoded = Role::from_value(&value).expect("should decode Role");
|
||||
assert_eq!(decoded.name, "admin");
|
||||
assert_eq!(decoded.permissions.len(), 3);
|
||||
}
|
||||
|
||||
// Test 3: Scope node round-trip
|
||||
#[test]
|
||||
fn test_scope_node_round_trip() {
|
||||
let scope = make_scope_email();
|
||||
let value = scope.to_value();
|
||||
let decoded = Scope::from_value(&value).expect("should decode Scope");
|
||||
assert_eq!(decoded.name, "email");
|
||||
assert_eq!(decoded.description, "Access email address");
|
||||
}
|
||||
|
||||
// Test 4: Session expiry detection
|
||||
#[test]
|
||||
fn test_session_expiry() {
|
||||
let user_id = Uuid::new_v4();
|
||||
// TTL = -1 → already expired
|
||||
let session = Session::new(user_id, -1, None);
|
||||
assert!(session.is_expired(), "session with negative TTL should be expired");
|
||||
|
||||
let fresh = Session::new(user_id, 3600, None);
|
||||
assert!(!fresh.is_expired(), "fresh session should not be expired");
|
||||
}
|
||||
|
||||
// Test 5: OAuthToken expiry detection
|
||||
#[test]
|
||||
fn test_oauth_token_expiry() {
|
||||
use chrono::{Duration, Utc};
|
||||
let past = Utc::now() - Duration::seconds(1);
|
||||
let token = OAuthToken::new("google", "hash123", None, past, vec![]);
|
||||
assert!(token.is_expired());
|
||||
|
||||
let future = Utc::now() + Duration::seconds(3600);
|
||||
let fresh = OAuthToken::new("google", "hash456", None, future, vec!["email".into()]);
|
||||
assert!(!fresh.is_expired());
|
||||
assert!(fresh.has_scope("email"));
|
||||
assert!(!fresh.has_scope("openid"));
|
||||
}
|
||||
|
||||
// ── MockEngramClient tests ─────────────────────────────────────────────────
|
||||
|
||||
// Test 6: MockEngramClient create and retrieve node
|
||||
#[test]
|
||||
fn test_mock_client_create_and_get() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
let id = client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
assert_eq!(id, user.id.to_string());
|
||||
|
||||
let retrieved = client.get_node(&id).unwrap().expect("should find node");
|
||||
let decoded = User::from_value(&retrieved).expect("should decode");
|
||||
assert_eq!(decoded.email, user.email);
|
||||
}
|
||||
|
||||
// Test 7: MockEngramClient get_node returns None for unknown ID
|
||||
#[test]
|
||||
fn test_mock_client_get_unknown_returns_none() {
|
||||
let client = make_client();
|
||||
let result = client.get_node("nonexistent-id").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// Test 8: MockEngramClient find_nodes with query
|
||||
#[test]
|
||||
fn test_mock_client_find_nodes() {
|
||||
let client = make_client();
|
||||
let u1 = User::new("alice@example.com", "Alice");
|
||||
let u2 = User::new("bob@example.com", "Bob");
|
||||
client.create_node(NODE_USER, u1.to_value()).unwrap();
|
||||
client.create_node(NODE_USER, u2.to_value()).unwrap();
|
||||
|
||||
let query = serde_json::json!({"email": "alice@example.com"});
|
||||
let results = client.find_nodes(NODE_USER, query).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
let found = User::from_value(&results[0]).unwrap();
|
||||
assert_eq!(found.display_name, "Alice");
|
||||
}
|
||||
|
||||
// Test 9: MockEngramClient delete_node removes node and edges
|
||||
#[test]
|
||||
fn test_mock_client_delete_node() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
let role = make_role_admin();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
client.create_node(NODE_ROLE, role.to_value()).unwrap();
|
||||
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
assert_eq!(client.count_edges(EDGE_HAS_ROLE), 1);
|
||||
|
||||
client.delete_node(&user.id.to_string()).unwrap();
|
||||
assert!(client.get_node(&user.id.to_string()).unwrap().is_none());
|
||||
// Edge should also be gone
|
||||
assert_eq!(client.count_edges(EDGE_HAS_ROLE), 0);
|
||||
}
|
||||
|
||||
// Test 10: MockEngramClient find_connected traverses edges
|
||||
#[test]
|
||||
fn test_mock_client_find_connected() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
let role1 = make_role_admin();
|
||||
let role2 = make_role_viewer();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
client.create_node(NODE_ROLE, role1.to_value()).unwrap();
|
||||
client.create_node(NODE_ROLE, role2.to_value()).unwrap();
|
||||
client.create_edge(&user.id.to_string(), &role1.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
client.create_edge(&user.id.to_string(), &role2.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
|
||||
let roles = client.find_connected(&user.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
assert_eq!(roles.len(), 2);
|
||||
}
|
||||
|
||||
// ── SessionManager tests ───────────────────────────────────────────────────
|
||||
|
||||
// Test 11: SessionManager creates session and connects to user
|
||||
#[test]
|
||||
fn test_session_manager_create() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
|
||||
let sm = make_session_manager(client.clone());
|
||||
let session = sm.create(user.id, Some("127.0.0.1".into())).unwrap();
|
||||
assert_eq!(session.user_id, user.id);
|
||||
assert_eq!(session.ip_address, Some("127.0.0.1".to_string()));
|
||||
|
||||
// Should have created a has_session edge
|
||||
assert_eq!(client.count_edges(EDGE_HAS_SESSION), 1);
|
||||
}
|
||||
|
||||
// Test 12: SessionManager validate succeeds for fresh session
|
||||
#[test]
|
||||
fn test_session_manager_validate_fresh() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
let sm = make_session_manager(client.clone());
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
|
||||
let validated = sm.validate(&session.id.to_string()).unwrap();
|
||||
assert_eq!(validated.id, session.id);
|
||||
}
|
||||
|
||||
// Test 13: SessionManager validate fails for expired session
|
||||
#[test]
|
||||
fn test_session_manager_validate_expired() {
|
||||
let client = make_client();
|
||||
let sm = Arc::new(SessionManager::new(client.clone() as Arc<dyn crate::EngramClient>).with_ttl(-1));
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let result = sm.validate(&session.id.to_string());
|
||||
assert!(matches!(result, Err(IdentityError::SessionExpired)));
|
||||
}
|
||||
|
||||
// Test 14: SessionManager validate fails for missing session
|
||||
#[test]
|
||||
fn test_session_manager_validate_missing() {
|
||||
let client = make_client();
|
||||
let sm = make_session_manager(client.clone());
|
||||
let result = sm.validate("nonexistent-session-id");
|
||||
assert!(matches!(result, Err(IdentityError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// Test 15: SessionManager invalidate removes session
|
||||
#[test]
|
||||
fn test_session_manager_invalidate() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
let sm = make_session_manager(client.clone());
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let session_id = session.id.to_string();
|
||||
|
||||
sm.invalidate(&session_id).unwrap();
|
||||
assert!(matches!(sm.validate(&session_id), Err(IdentityError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// Test 16: SessionManager list_for_user returns active sessions
|
||||
#[test]
|
||||
fn test_session_manager_list_for_user() {
|
||||
let client = make_client();
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
let sm = make_session_manager(client.clone());
|
||||
|
||||
sm.create(user.id, Some("1.1.1.1".into())).unwrap();
|
||||
sm.create(user.id, Some("2.2.2.2".into())).unwrap();
|
||||
|
||||
let sessions = sm.list_for_user(&user.id.to_string()).unwrap();
|
||||
assert_eq!(sessions.len(), 2);
|
||||
}
|
||||
|
||||
// ── AuthGuard tests ───────────────────────────────────────────────────────
|
||||
|
||||
// Test 17: AuthGuard authenticates user with role and scope
|
||||
#[test]
|
||||
fn test_auth_guard_full_resolution() {
|
||||
let (client, sm, guard) = setup_auth_guard();
|
||||
|
||||
let user = make_user();
|
||||
let role = make_role_admin();
|
||||
let scope = make_scope_email();
|
||||
|
||||
// Register user, role, scope, and wire edges
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
client.create_node(NODE_ROLE, role.to_value()).unwrap();
|
||||
client.create_node(NODE_SCOPE, scope.to_value()).unwrap();
|
||||
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
client.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS).unwrap();
|
||||
|
||||
// Create session
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
|
||||
// Authenticate
|
||||
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
|
||||
assert_eq!(ctx.user.email, "alice@example.com");
|
||||
assert_eq!(ctx.roles.len(), 1);
|
||||
assert!(ctx.has_role("admin"));
|
||||
assert!(ctx.has_scope("email"));
|
||||
assert!(ctx.has_permission("users:write"));
|
||||
}
|
||||
|
||||
// Test 18: AuthGuard rejects unknown session
|
||||
#[test]
|
||||
fn test_auth_guard_rejects_unknown_session() {
|
||||
let (_, _, guard) = setup_auth_guard();
|
||||
let result = guard.authenticate("no-such-session");
|
||||
assert!(matches!(result, Err(IdentityError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// Test 19: AuthGuard require_role succeeds for correct role
|
||||
#[test]
|
||||
fn test_auth_guard_require_role_success() {
|
||||
let (client, sm, guard) = setup_auth_guard();
|
||||
let user = make_user();
|
||||
let role = make_role_admin();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
client.create_node(NODE_ROLE, role.to_value()).unwrap();
|
||||
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
|
||||
|
||||
assert!(guard.require_role(&ctx, "admin").is_ok());
|
||||
}
|
||||
|
||||
// Test 20: AuthGuard require_role fails for missing role
|
||||
#[test]
|
||||
fn test_auth_guard_require_role_forbidden() {
|
||||
let (client, sm, guard) = setup_auth_guard();
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
|
||||
|
||||
let result = guard.require_role(&ctx, "superadmin");
|
||||
assert!(matches!(result, Err(IdentityError::Forbidden(_))));
|
||||
}
|
||||
|
||||
// Test 21: AuthGuard require_scope fails for missing scope
|
||||
#[test]
|
||||
fn test_auth_guard_require_scope_forbidden() {
|
||||
let (client, sm, guard) = setup_auth_guard();
|
||||
let user = make_user();
|
||||
client.create_node(NODE_USER, user.to_value()).unwrap();
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
|
||||
|
||||
let result = guard.require_scope(&ctx, "admin:write");
|
||||
assert!(matches!(result, Err(IdentityError::ScopeForbidden(_))));
|
||||
}
|
||||
|
||||
// Test 22: AuthGuard register_user and assign_role
|
||||
#[test]
|
||||
fn test_auth_guard_register_and_assign_role() {
|
||||
let (_client, sm, guard) = setup_auth_guard();
|
||||
let user = make_user();
|
||||
let role = make_role_viewer();
|
||||
|
||||
guard.register_user(&user).unwrap();
|
||||
guard.assign_role(&user.id.to_string(), &role).unwrap();
|
||||
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
|
||||
assert!(ctx.has_role("viewer"));
|
||||
assert!(ctx.has_permission("users:read"));
|
||||
assert!(!ctx.has_permission("orders:delete"));
|
||||
}
|
||||
|
||||
// ── PKCE tests ────────────────────────────────────────────────────────────
|
||||
|
||||
// Test 23: PkceChallenge generates valid verifier/challenge pair
|
||||
#[test]
|
||||
fn test_pkce_challenge_generates_valid_pair() {
|
||||
let pkce = PkceChallenge::generate();
|
||||
assert!(!pkce.verifier.is_empty(), "verifier should not be empty");
|
||||
assert!(!pkce.challenge.is_empty(), "challenge should not be empty");
|
||||
assert_ne!(pkce.verifier, pkce.challenge, "verifier and challenge must differ");
|
||||
assert_eq!(pkce.method, "S256");
|
||||
}
|
||||
|
||||
// Test 24: PkceChallenge verify matches correct verifier
|
||||
#[test]
|
||||
fn test_pkce_challenge_verify_correct() {
|
||||
let pkce = PkceChallenge::generate();
|
||||
assert!(pkce.verify(&pkce.verifier.clone()), "should verify correct verifier");
|
||||
}
|
||||
|
||||
// Test 25: PkceChallenge verify rejects wrong verifier
|
||||
#[test]
|
||||
fn test_pkce_challenge_verify_rejects_wrong() {
|
||||
let pkce = PkceChallenge::generate();
|
||||
assert!(!pkce.verify("wrong-verifier-value"), "should reject wrong verifier");
|
||||
}
|
||||
|
||||
// Test 26: hash_token is deterministic
|
||||
#[test]
|
||||
fn test_hash_token_deterministic() {
|
||||
let token = "my-secret-access-token";
|
||||
let h1 = hash_token(token);
|
||||
let h2 = hash_token(token);
|
||||
assert_eq!(h1, h2, "hash must be deterministic");
|
||||
assert_ne!(h1, token, "hash must differ from input");
|
||||
assert_eq!(h1.len(), 64, "SHA-256 hex output is 64 chars");
|
||||
}
|
||||
|
||||
// ── Provider tests ────────────────────────────────────────────────────────
|
||||
|
||||
// Test 27: GoogleOAuth builds correct authorization URL
|
||||
#[test]
|
||||
fn test_google_oauth_authorization_url() {
|
||||
let provider = GoogleOAuth::new("client-id-123", "secret");
|
||||
let url = provider.authorization_url(
|
||||
"https://myapp.com/callback",
|
||||
"pkce-challenge-abc",
|
||||
"state-xyz",
|
||||
&[],
|
||||
);
|
||||
assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth"));
|
||||
assert!(url.contains("client_id=client-id-123"));
|
||||
assert!(url.contains("code_challenge=pkce-challenge-abc"));
|
||||
assert!(url.contains("code_challenge_method=S256"));
|
||||
assert!(url.contains("state=state-xyz"));
|
||||
assert!(url.contains("access_type=offline"), "Google needs offline for refresh tokens");
|
||||
}
|
||||
|
||||
// Test 28: GoogleOAuth default scopes include openid, email, profile
|
||||
#[test]
|
||||
fn test_google_oauth_default_scopes() {
|
||||
let provider = GoogleOAuth::new("id", "secret");
|
||||
let scopes = provider.default_scopes();
|
||||
assert!(scopes.contains(&"openid"));
|
||||
assert!(scopes.contains(&"email"));
|
||||
assert!(scopes.contains(&"profile"));
|
||||
}
|
||||
|
||||
// Test 29: AppleOAuth builds correct authorization URL with form_post
|
||||
#[test]
|
||||
fn test_apple_oauth_authorization_url() {
|
||||
let provider = AppleOAuth::new("com.example.app", "jwt-secret", "TEAMID");
|
||||
let url = provider.authorization_url(
|
||||
"https://myapp.com/callback",
|
||||
"challenge",
|
||||
"state",
|
||||
&[],
|
||||
);
|
||||
assert!(url.starts_with("https://appleid.apple.com/auth/authorize"));
|
||||
assert!(url.contains("response_mode=form_post"), "Apple requires form_post");
|
||||
}
|
||||
|
||||
// Test 30: GithubOAuth ignores PKCE in URL (GitHub doesn't support it)
|
||||
#[test]
|
||||
fn test_github_oauth_no_pkce_in_url() {
|
||||
let provider = GithubOAuth::new("gh-client-id", "gh-secret");
|
||||
let url = provider.authorization_url(
|
||||
"https://myapp.com/callback",
|
||||
"pkce-challenge-ignored",
|
||||
"state",
|
||||
&[],
|
||||
);
|
||||
assert!(url.starts_with("https://github.com/login/oauth/authorize"));
|
||||
assert!(!url.contains("code_challenge"), "GitHub doesn't support PKCE");
|
||||
assert!(url.contains("scope=user%3Aemail+read%3Auser") || url.contains("scope="));
|
||||
}
|
||||
|
||||
// Test 31: GithubOAuth refresh_token returns error
|
||||
#[test]
|
||||
fn test_github_oauth_refresh_returns_error() {
|
||||
let provider = GithubOAuth::new("id", "secret");
|
||||
let result = provider.refresh_token("some-refresh-token");
|
||||
assert!(matches!(result, Err(IdentityError::TokenRefreshFailed(_))));
|
||||
}
|
||||
|
||||
// ── IdentityContext tests ─────────────────────────────────────────────────
|
||||
|
||||
// Test 32: IdentityContext role_names and scope_names
|
||||
#[test]
|
||||
fn test_identity_context_names() {
|
||||
let user = make_user();
|
||||
let session = Session::new(user.id, 3600, None);
|
||||
let roles = vec![make_role_admin(), make_role_viewer()];
|
||||
let scopes = vec![make_scope_email(), make_scope_profile()];
|
||||
|
||||
let ctx = IdentityContext::new(user.clone(), session, roles, scopes);
|
||||
let mut role_names = ctx.role_names();
|
||||
role_names.sort();
|
||||
assert_eq!(role_names, vec!["admin", "viewer"]);
|
||||
|
||||
let mut scope_names = ctx.scope_names();
|
||||
scope_names.sort();
|
||||
assert_eq!(scope_names, vec!["email", "profile"]);
|
||||
}
|
||||
|
||||
// Test 33: IdentityContext has_permission across multiple roles
|
||||
#[test]
|
||||
fn test_identity_context_permission_across_roles() {
|
||||
let user = make_user();
|
||||
let session = Session::new(user.id, 3600, None);
|
||||
let roles = vec![make_role_viewer()]; // viewer has users:read and orders:read
|
||||
let ctx = IdentityContext::new(user, session, roles, vec![]);
|
||||
|
||||
assert!(ctx.has_permission("users:read"));
|
||||
assert!(ctx.has_permission("orders:read"));
|
||||
assert!(!ctx.has_permission("users:write"));
|
||||
assert!(!ctx.has_permission("orders:delete"));
|
||||
}
|
||||
|
||||
// Test 34: AuthGuard assign_scope_to_role wires scope into context
|
||||
#[test]
|
||||
fn test_auth_guard_assign_scope_to_role() {
|
||||
let (_client, sm, guard) = setup_auth_guard();
|
||||
let user = make_user();
|
||||
let role = make_role_admin();
|
||||
let scope = make_scope_profile();
|
||||
|
||||
guard.register_user(&user).unwrap();
|
||||
guard.assign_role(&user.id.to_string(), &role).unwrap();
|
||||
guard.assign_scope_to_role(&role, &scope).unwrap();
|
||||
|
||||
let session = sm.create(user.id, None).unwrap();
|
||||
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
|
||||
assert!(ctx.has_scope("profile"));
|
||||
}
|
||||
|
||||
// Test 35: Two different users have independent sessions
|
||||
#[test]
|
||||
fn test_independent_user_sessions() {
|
||||
let (client, sm, guard) = setup_auth_guard();
|
||||
|
||||
let alice = User::new("alice@example.com", "Alice");
|
||||
let bob = User::new("bob@example.com", "Bob");
|
||||
let admin_role = make_role_admin();
|
||||
|
||||
client.create_node(NODE_USER, alice.to_value()).unwrap();
|
||||
client.create_node(NODE_USER, bob.to_value()).unwrap();
|
||||
client.create_node(NODE_ROLE, admin_role.to_value()).unwrap();
|
||||
// Only Alice gets the admin role
|
||||
client.create_edge(&alice.id.to_string(), &admin_role.id.to_string(), EDGE_HAS_ROLE).unwrap();
|
||||
|
||||
let alice_session = sm.create(alice.id, None).unwrap();
|
||||
let bob_session = sm.create(bob.id, None).unwrap();
|
||||
|
||||
let alice_ctx = guard.authenticate(&alice_session.id.to_string()).unwrap();
|
||||
let bob_ctx = guard.authenticate(&bob_session.id.to_string()).unwrap();
|
||||
|
||||
assert!(alice_ctx.has_role("admin"), "Alice should have admin");
|
||||
assert!(!bob_ctx.has_role("admin"), "Bob should not have admin");
|
||||
assert_eq!(alice_ctx.email(), "alice@example.com");
|
||||
assert_eq!(bob_ctx.email(), "bob@example.com");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user