This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-gateway.git
The following commit(s) were added to refs/heads/main by this push:
new c28f203 [SPARK-58901] Add scg-auth crate
c28f203 is described below
commit c28f2038a7e59b7ecd4533492b4850d4bb537e41
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Fri Aug 21 00:29:01 2026 -0700
[SPARK-58901] Add scg-auth crate
### What changes were proposed in this pull request?
Part of importing the Spark Connect Gateway reference implementation into
`apache/spark-connect-gateway` as a series of small, bottom-up PRs (tracked
in
the do-not-merge reference PR #1).
This adds the `scg-auth` crate — the authentication layer for the gateway.
It
defines the `Authenticator` abstraction and the verified `Identity`
attached to
an authenticated request, a tonic interceptor that runs an authenticator and
stamps the identity onto the request, and several authenticator
implementations:
- `AnonymousAuthenticator` — used when auth is explicitly disabled
- `StaticTokenAuthenticator` — a Bearer-token allowlist
- `JwtAuthenticator` — verifies a signed JWT against a local key
- `OidcAuthenticator` — verifies JWTs against a remote JWKS endpoint
`scg-auth` is a leaf crate (no internal dependencies), so it compiles and
tests
on its own (30 unit tests). It is appended to the workspace `members` list,
and
is a prerequisite for the `tenant` and `proxy` crates that arrive in later
PRs.
### Why are the changes needed?
Implements the reference gateway from the approved SPIP (SPARK-58455),
imported
one reviewable piece at a time.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
`cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D
warnings`,
`cargo build --workspace`, and `cargo test --workspace` all pass locally
(scg-auth has 30 unit tests).
### Was this patch authored or co-authored using generative AI tooling?
Yes, co-authored with Claude Code.
Closes #5 from viirya/import-04-auth.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
Cargo.lock | 1421 ++++++++++++++++++++++++++++++++++++++--
Cargo.toml | 1 +
crates/auth/Cargo.toml | 47 ++
crates/auth/src/anonymous.rs | 48 ++
crates/auth/src/identity.rs | 57 ++
crates/auth/src/interceptor.rs | 75 +++
crates/auth/src/jwt.rs | 409 ++++++++++++
crates/auth/src/lib.rs | 118 ++++
crates/auth/src/oidc.rs | 809 +++++++++++++++++++++++
crates/auth/src/token.rs | 232 +++++++
10 files changed, 3161 insertions(+), 56 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index c38a817..b7227f0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,16 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "async-trait"
version = "0.1.91"
@@ -34,6 +44,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
[[package]]
name = "axum"
version = "0.8.9"
@@ -83,24 +99,144 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+[[package]]
+name = "cc"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+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 = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "deadpool"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
+dependencies = [
+ "deadpool-runtime",
+ "lazy_static",
+ "num_cpus",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-runtime"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "const-oid",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
[[package]]
name = "either"
version = "1.17.0"
@@ -120,7 +256,7 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -129,6 +265,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -147,45 +289,126 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
[[package]]
name = "futures-channel"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
+ "futures-sink",
]
[[package]]
name = "futures-core"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
[[package]]
name = "futures-sink"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
-version = "0.3.33"
+version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
+ "futures-channel",
"futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
"futures-task",
+ "memchr",
"pin-project-lite",
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -193,8 +416,11 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -237,6 +463,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
[[package]]
name = "http"
version = "1.5.0"
@@ -304,6 +536,22 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
[[package]]
name = "hyper-timeout"
version = "0.5.2"
@@ -323,13 +571,16 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
"libc",
+ "percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
@@ -337,6 +588,109 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -347,6 +701,12 @@ dependencies = [
"hashbrown 0.17.1",
]
+[[package]]
+name = "ipnet"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -362,18 +722,65 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -389,6 +796,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "matchit"
version = "0.8.4"
@@ -415,7 +828,7 @@ checksum =
"30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -425,56 +838,146 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
-name = "once_cell"
-version = "1.21.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-
-[[package]]
-name = "parking_lot"
-version = "0.12.5"
+name = "num-bigint"
+version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
- "lock_api",
- "parking_lot_core",
+ "num-integer",
+ "num-traits",
]
[[package]]
-name = "parking_lot_core"
-version = "0.9.12"
+name = "num-bigint-dig"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.7",
"smallvec",
- "windows-link",
+ "zeroize",
]
[[package]]
-name = "percent-encoding"
-version = "2.3.2"
+name = "num-conv"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
-name = "petgraph"
-version = "0.8.3"
+name = "num-integer"
+version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
- "fixedbitset",
- "hashbrown 0.15.5",
- "indexmap",
+ "num-traits",
]
[[package]]
-name = "pin-project"
-version = "1.1.13"
+name = "num-iter"
+version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
@@ -496,6 +999,51 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -588,6 +1136,62 @@ dependencies = [
"pulldown-cmark",
]
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -603,6 +1207,62 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -641,6 +1301,84 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "rustix"
version = "1.1.4"
@@ -651,15 +1389,77 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
]
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "scg-auth"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "base64",
+ "jsonwebtoken",
+ "parking_lot",
+ "rand 0.8.7",
+ "reqwest",
+ "rsa",
+ "serde",
+ "serde_json",
+ "subtle",
+ "thiserror",
+ "tokio",
+ "tonic",
+ "tracing",
+ "wiremock",
+]
+
[[package]]
name = "scg-config"
version = "0.1.0"
@@ -727,6 +1527,31 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -740,6 +1565,12 @@ dependencies = [
"unsafe-libyaml",
]
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -750,6 +1581,28 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror",
+ "time",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -757,20 +1610,48 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
-name = "smallvec"
-version = "1.15.2"
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
-name = "socket2"
-version = "0.6.5"
+name = "subtle"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
-dependencies = [
- "libc",
- "windows-sys",
-]
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
@@ -799,6 +1680,20 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
[[package]]
name = "tempfile"
@@ -807,10 +1702,10 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -833,6 +1728,61 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tokio"
version = "1.53.1"
@@ -847,7 +1797,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -861,6 +1811,16 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -973,6 +1933,24 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -1022,6 +2000,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "unicase"
version = "2.9.0"
@@ -1040,6 +2024,36 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "want"
version = "0.3.1"
@@ -1055,12 +2069,105 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1069,3 +2176,205 @@ checksum =
"ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wiremock"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
+dependencies = [
+ "assert-json-diff",
+ "base64",
+ "deadpool",
+ "futures",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "once_cell",
+ "regex",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index d2c44c7..c8b3f91 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ members = [
"crates/genproto",
"crates/routing",
"crates/config",
+ "crates/auth",
]
[workspace.package]
diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml
new file mode 100644
index 0000000..e83890f
--- /dev/null
+++ b/crates/auth/Cargo.toml
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[package]
+name = "scg-auth"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+readme.workspace = true
+keywords.workspace = true
+categories.workspace = true
+description = "Pluggable authentication (anonymous / static-token / JWT /
OIDC) for the Spark Connect Gateway."
+publish = false
+
+[dependencies]
+async-trait = { workspace = true }
+tonic = { workspace = true }
+tokio = { workspace = true }
+parking_lot = { workspace = true }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+serde = { workspace = true }
+subtle = { workspace = true }
+jsonwebtoken = { workspace = true }
+reqwest = { workspace = true }
+serde_json = "1"
+
+[dev-dependencies]
+tokio = { workspace = true }
+wiremock = "0.6"
+rsa = "0.9"
+rand = "0.8"
+base64 = "0.22"
diff --git a/crates/auth/src/anonymous.rs b/crates/auth/src/anonymous.rs
new file mode 100644
index 0000000..b3d6032
--- /dev/null
+++ b/crates/auth/src/anonymous.rs
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! `AnonymousAuthenticator` — used when auth is explicitly disabled.
+//!
+//! Returns a fixed `Identity { user_id: "anonymous", … }`: the
+//! gateway accepts every request without authentication. Fine for
+//! trusted in-cluster networks, never for external exposure.
+
+use async_trait::async_trait;
+use tonic::metadata::MetadataMap;
+use tonic::Status;
+
+use crate::{Authenticator, Identity};
+
+#[derive(Debug, Default)]
+pub struct AnonymousAuthenticator;
+
+#[async_trait]
+impl Authenticator for AnonymousAuthenticator {
+ async fn authenticate(&self, _metadata: &MetadataMap) -> Result<Identity,
Status> {
+ Ok(Identity::user("anonymous"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn always_accepts() {
+ let a = AnonymousAuthenticator;
+ let id = a.authenticate(&MetadataMap::new()).await.unwrap();
+ assert_eq!(id.user_id, "anonymous");
+ }
+}
diff --git a/crates/auth/src/identity.rs b/crates/auth/src/identity.rs
new file mode 100644
index 0000000..be2f85b
--- /dev/null
+++ b/crates/auth/src/identity.rs
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! The verified identity attached to an authenticated request.
+
+use std::sync::Arc;
+
+/// What the gateway *knows* about the caller after auth has succeeded.
+///
+/// `user_id` drives session-affinity routing-key construction and
+/// gets injected into the forwarded `UserContext.user_id`. `tenant`
+/// is consumed by the tenant resolver, per-tenant pool router, and
+/// rate limiter. `groups` is recorded on session-lifecycle audit
+/// events; the gateway has no RBAC layer yet, so groups are
+/// informational at the data plane.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Identity {
+ /// Stable user identifier. Whatever the authenticator declares this
+ /// to be is what gets injected into the Spark Connect
+ /// `UserContext.user_id` field on forward.
+ pub user_id: String,
+ /// Optional tenant identifier (workspace, org, project, …).
+ pub tenant: Option<String>,
+ /// Optional group memberships (LDAP groups, JWT `groups` claim,
+ /// custom claim).
+ pub groups: Vec<String>,
+}
+
+impl Identity {
+ /// Build an `Identity` with only `user_id` set.
+ pub fn user(user_id: impl Into<String>) -> Self {
+ Self {
+ user_id: user_id.into(),
+ tenant: None,
+ groups: Vec::new(),
+ }
+ }
+}
+
+/// Tonic request-extension wrapper. The interceptor inserts an
+/// `IdentityExt(Arc<Identity>)` into the request's extensions so
+/// downstream handlers can read the verified identity without
+/// re-parsing headers.
+#[derive(Debug, Clone)]
+pub struct IdentityExt(pub Arc<Identity>);
diff --git a/crates/auth/src/interceptor.rs b/crates/auth/src/interceptor.rs
new file mode 100644
index 0000000..9fb5ab6
--- /dev/null
+++ b/crates/auth/src/interceptor.rs
@@ -0,0 +1,75 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Tonic interceptor that runs an `Authenticator` and stamps the
+//! verified `Identity` onto the request's extensions.
+//!
+//! Note on async vs sync interceptors:
+//!
+//! Tonic's built-in `Interceptor` trait is *sync*. Some authenticators
+//! (notably OIDC with JWKS rotation) need to await network I/O, which
+//! a sync interceptor can't do. We therefore expose this interceptor
+//! via a free function that the proxy calls explicitly at the head of
+//! every RPC handler — see `crates/proxy` for the call site.
+//!
+//! That keeps the framework boundary clean: the proxy is responsible
+//! for reading the inbound metadata, calling `authenticate`, attaching
+//! the resulting identity, and *then* dispatching to the per-RPC
+//! method body. The Spark Connect service surface is small enough
+//! (under twenty RPCs) that calling `authenticate` per handler is
+//! manageable; a tower-layer-based async interceptor would be a
+//! future refactor.
+
+use std::sync::Arc;
+use tonic::metadata::MetadataMap;
+use tonic::Status;
+
+use crate::identity::IdentityExt;
+use crate::Authenticator;
+
+/// Owned wrapper around an `Arc<dyn Authenticator>`. The proxy holds
+/// one of these; calling [`AuthInterceptor::authenticate`] is what
+/// every RPC does as its first step.
+#[derive(Clone)]
+pub struct AuthInterceptor {
+ inner: Arc<dyn Authenticator>,
+}
+
+impl AuthInterceptor {
+ pub fn new(inner: Arc<dyn Authenticator>) -> Self {
+ Self { inner }
+ }
+
+ /// Run authentication against the metadata; on success return the
+ /// `IdentityExt` the proxy should attach to the request before
+ /// dispatching.
+ pub async fn authenticate(&self, metadata: &MetadataMap) ->
Result<IdentityExt, Status> {
+ let id = self.inner.authenticate(metadata).await?;
+ Ok(IdentityExt(Arc::new(id)))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::AnonymousAuthenticator;
+
+ #[tokio::test]
+ async fn delegates_to_inner_authenticator() {
+ let i = AuthInterceptor::new(Arc::new(AnonymousAuthenticator));
+ let ext = i.authenticate(&MetadataMap::new()).await.unwrap();
+ assert_eq!(ext.0.user_id, "anonymous");
+ }
+}
diff --git a/crates/auth/src/jwt.rs b/crates/auth/src/jwt.rs
new file mode 100644
index 0000000..3982de3
--- /dev/null
+++ b/crates/auth/src/jwt.rs
@@ -0,0 +1,409 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! `JwtAuthenticator` — verifies a signed JWT against a local key.
+//!
+//! No network access at request time: the public key is loaded once
+//! at startup. Suitable for deployments where the IdP signs tokens
+//! offline and the public key is provisioned via config / Secret.
+//!
+//! For deployments where the IdP rotates keys via JWKS, see the
+//! [`crate::oidc`] module.
+
+use std::collections::HashMap;
+
+use async_trait::async_trait;
+use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
+use serde::Deserialize;
+use tonic::metadata::MetadataMap;
+use tonic::Status;
+use tracing::debug;
+
+use crate::{bearer_token, Authenticator, Identity};
+
+/// Where to find the verification key.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum KeySource {
+ /// Path to a PEM file on disk (RSA, EC, or Ed25519 public key).
+ PemFile { path: String },
+ /// Inline PEM contents (useful when injecting via env var or
+ /// Kubernetes Secret).
+ PemInline { pem: String },
+ /// Inline HMAC secret. Discouraged for production.
+ HmacSecret { secret: String },
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct JwtConfig {
+ pub key: KeySource,
+ /// Permitted signing algorithms. Multiple values mean "any of",
+ /// matching `jsonwebtoken::Validation::algorithms`.
+ pub algorithms: Vec<String>,
+ #[serde(default)]
+ pub issuer: Option<String>,
+ #[serde(default)]
+ pub audience: Option<String>,
+ /// Claim name that maps to `Identity.user_id`. Defaults to `sub`.
+ #[serde(default = "default_user_id_claim")]
+ pub user_id_claim: String,
+ /// Optional tenant claim (mapped to `Identity.tenant`).
+ #[serde(default)]
+ pub tenant_claim: Option<String>,
+ /// Optional groups claim. JWT must encode this as an array of
+ /// strings.
+ #[serde(default)]
+ pub groups_claim: Option<String>,
+}
+
+fn default_user_id_claim() -> String {
+ "sub".into()
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum JwtError {
+ #[error("read PEM file {path}: {source}")]
+ KeyRead {
+ path: String,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("parse PEM key: {0}")]
+ KeyParse(jsonwebtoken::errors::Error),
+ #[error("unknown JWT algorithm: {0}")]
+ UnknownAlg(String),
+ #[error("at least one algorithm must be configured")]
+ NoAlgorithms,
+ #[error("user_id_claim must not be empty")]
+ EmptyUserIdClaim,
+}
+
+pub struct JwtAuthenticator {
+ decoding_key: DecodingKey,
+ validation: Validation,
+ user_id_claim: String,
+ tenant_claim: Option<String>,
+ groups_claim: Option<String>,
+}
+
+impl JwtAuthenticator {
+ pub fn new(cfg: JwtConfig) -> Result<Self, JwtError> {
+ if cfg.algorithms.is_empty() {
+ return Err(JwtError::NoAlgorithms);
+ }
+ if cfg.user_id_claim.is_empty() {
+ return Err(JwtError::EmptyUserIdClaim);
+ }
+ let algos: Vec<Algorithm> = cfg
+ .algorithms
+ .iter()
+ .map(|a| parse_algorithm(a))
+ .collect::<Result<_, _>>()?;
+
+ let decoding_key = match &cfg.key {
+ KeySource::PemFile { path } => {
+ let bytes = std::fs::read(path).map_err(|e| JwtError::KeyRead {
+ path: path.clone(),
+ source: e,
+ })?;
+ build_pem_key(&algos, &bytes)?
+ }
+ KeySource::PemInline { pem } => build_pem_key(&algos,
pem.as_bytes())?,
+ KeySource::HmacSecret { secret } =>
DecodingKey::from_secret(secret.as_bytes()),
+ };
+
+ let mut validation = Validation::new(algos[0]);
+ validation.algorithms = algos;
+ if let Some(iss) = cfg.issuer {
+ validation.set_issuer(&[iss]);
+ }
+ if let Some(aud) = cfg.audience {
+ validation.set_audience(&[aud]);
+ } else {
+ validation.validate_aud = false;
+ }
+
+ Ok(Self {
+ decoding_key,
+ validation,
+ user_id_claim: cfg.user_id_claim,
+ tenant_claim: cfg.tenant_claim,
+ groups_claim: cfg.groups_claim,
+ })
+ }
+}
+
+fn parse_algorithm(name: &str) -> Result<Algorithm, JwtError> {
+ match name.to_ascii_uppercase().as_str() {
+ "HS256" => Ok(Algorithm::HS256),
+ "HS384" => Ok(Algorithm::HS384),
+ "HS512" => Ok(Algorithm::HS512),
+ "RS256" => Ok(Algorithm::RS256),
+ "RS384" => Ok(Algorithm::RS384),
+ "RS512" => Ok(Algorithm::RS512),
+ "PS256" => Ok(Algorithm::PS256),
+ "PS384" => Ok(Algorithm::PS384),
+ "PS512" => Ok(Algorithm::PS512),
+ "ES256" => Ok(Algorithm::ES256),
+ "ES384" => Ok(Algorithm::ES384),
+ "EDDSA" => Ok(Algorithm::EdDSA),
+ other => Err(JwtError::UnknownAlg(other.into())),
+ }
+}
+
+fn build_pem_key(algos: &[Algorithm], bytes: &[u8]) -> Result<DecodingKey,
JwtError> {
+ // Pick the parser by the first listed algorithm. Mixing RSA and EC
+ // keys in one config doesn't make sense; if it ever did, callers
+ // can run separate authenticators.
+ let alg = algos[0];
+ let res = match alg {
+ Algorithm::RS256
+ | Algorithm::RS384
+ | Algorithm::RS512
+ | Algorithm::PS256
+ | Algorithm::PS384
+ | Algorithm::PS512 => DecodingKey::from_rsa_pem(bytes),
+ Algorithm::ES256 | Algorithm::ES384 => DecodingKey::from_ec_pem(bytes),
+ Algorithm::EdDSA => DecodingKey::from_ed_pem(bytes),
+ Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
+ // Reaching here means caller passed `kind: pem_*` with an
+ // HS algorithm — odd but handle by treating bytes as
+ // secret.
+ return Ok(DecodingKey::from_secret(bytes));
+ }
+ };
+ res.map_err(JwtError::KeyParse)
+}
+
+#[async_trait]
+impl Authenticator for JwtAuthenticator {
+ async fn authenticate(&self, metadata: &MetadataMap) -> Result<Identity,
Status> {
+ let token = bearer_token(metadata).ok_or_else(|| {
+ Status::unauthenticated("missing or malformed Authorization:
Bearer header")
+ })?;
+ let claims = decode::<HashMap<String, serde_json::Value>>(
+ token,
+ &self.decoding_key,
+ &self.validation,
+ )
+ .map_err(|e| {
+ debug!(error = %e, "jwt validation failed");
+ Status::unauthenticated("invalid JWT")
+ })?;
+
+ let user_id = claims
+ .claims
+ .get(&self.user_id_claim)
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| {
+ Status::unauthenticated(format!(
+ "JWT missing required claim {:?}",
+ self.user_id_claim
+ ))
+ })?
+ .to_string();
+
+ let tenant = self
+ .tenant_claim
+ .as_ref()
+ .and_then(|c| claims.claims.get(c))
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+
+ let groups: Vec<String> = self
+ .groups_claim
+ .as_ref()
+ .and_then(|c| claims.claims.get(c))
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ Ok(Identity {
+ user_id,
+ tenant,
+ groups,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use jsonwebtoken::{encode, EncodingKey, Header};
+ use serde::Serialize;
+
+ #[derive(Serialize)]
+ struct Claims<'a> {
+ sub: &'a str,
+ exp: usize,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ tenant: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ groups: Option<Vec<&'a str>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ iss: Option<&'a str>,
+ }
+
+ fn future_exp() -> usize {
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ + 600) as usize
+ }
+
+ fn past_exp() -> usize {
+ // jsonwebtoken's default leeway is 60s, so we need to be
+ // comfortably outside that window for the expiry check to fire.
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ - 600) as usize
+ }
+
+ fn sign(claims: Claims<'_>, secret: &[u8]) -> String {
+ encode(
+ &Header::new(Algorithm::HS256),
+ &claims,
+ &EncodingKey::from_secret(secret),
+ )
+ .unwrap()
+ }
+
+ fn md(token: &str) -> MetadataMap {
+ let mut md = MetadataMap::new();
+ md.insert(
+ "authorization",
+ format!("Bearer {}", token).parse().unwrap(),
+ );
+ md
+ }
+
+ fn auth(secret: &str, issuer: Option<&str>) -> JwtAuthenticator {
+ JwtAuthenticator::new(JwtConfig {
+ key: KeySource::HmacSecret {
+ secret: secret.into(),
+ },
+ algorithms: vec!["HS256".into()],
+ issuer: issuer.map(str::to_string),
+ audience: None,
+ user_id_claim: "sub".into(),
+ tenant_claim: Some("tenant".into()),
+ groups_claim: Some("groups".into()),
+ })
+ .unwrap()
+ }
+
+ #[tokio::test]
+ async fn valid_token_resolves_identity() {
+ let token = sign(
+ Claims {
+ sub: "alice",
+ exp: future_exp(),
+ tenant: Some("team-a"),
+ groups: Some(vec!["devs", "admins"]),
+ iss: None,
+ },
+ b"my-secret",
+ );
+ let id = auth("my-secret", None)
+ .authenticate(&md(&token))
+ .await
+ .unwrap();
+ assert_eq!(id.user_id, "alice");
+ assert_eq!(id.tenant.as_deref(), Some("team-a"));
+ assert_eq!(id.groups, vec!["devs", "admins"]);
+ }
+
+ #[tokio::test]
+ async fn expired_token_rejected() {
+ let token = sign(
+ Claims {
+ sub: "alice",
+ exp: past_exp(),
+ tenant: None,
+ groups: None,
+ iss: None,
+ },
+ b"my-secret",
+ );
+ let err = auth("my-secret", None)
+ .authenticate(&md(&token))
+ .await
+ .unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn wrong_secret_rejected() {
+ let token = sign(
+ Claims {
+ sub: "alice",
+ exp: future_exp(),
+ tenant: None,
+ groups: None,
+ iss: None,
+ },
+ b"my-secret",
+ );
+ let err = auth("OTHER-SECRET", None)
+ .authenticate(&md(&token))
+ .await
+ .unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn issuer_mismatch_rejected() {
+ let token = sign(
+ Claims {
+ sub: "alice",
+ exp: future_exp(),
+ tenant: None,
+ groups: None,
+ iss: Some("attacker"),
+ },
+ b"my-secret",
+ );
+ let err = auth("my-secret", Some("expected-iss"))
+ .authenticate(&md(&token))
+ .await
+ .unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn missing_user_id_claim_rejected() {
+ // Build a token without `sub`; we can't use Claims helper so
+ // hand-roll one with serde_json.
+ let claims = serde_json::json!({ "exp": future_exp() });
+ let token = encode(
+ &Header::new(Algorithm::HS256),
+ &claims,
+ &EncodingKey::from_secret(b"my-secret"),
+ )
+ .unwrap();
+ let err = auth("my-secret", None)
+ .authenticate(&md(&token))
+ .await
+ .unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+}
diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs
new file mode 100644
index 0000000..282bf17
--- /dev/null
+++ b/crates/auth/src/lib.rs
@@ -0,0 +1,118 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Authentication for the Spark Connect Gateway.
+//!
+//! This crate exposes a single `Authenticator` trait that takes a tonic
+//! `MetadataMap` (i.e. the gRPC headers attached to a request) and
+//! returns a verified `Identity` — or a `Status::unauthenticated` if no
+//! valid credential is present.
+//!
+//! Three concrete implementations are shipped:
+//!
+//! * [`AnonymousAuthenticator`] — accepts everything, attaches a fixed
+//! `Identity { user_id: "anonymous", … }`. Used when auth is
+//! explicitly disabled (`auth: { type: none }`) and in unit tests.
+//! * [`StaticTokenAuthenticator`] — Bearer-token allowlist with
+//! constant-time comparison.
+//! * [`JwtAuthenticator`] — verifies a signed JWT against a local PEM /
+//! JWK key (no network required).
+//! * [`OidcAuthenticator`] (in [`crate::oidc`]) — fetches JWKS from a
+//! remote IdP and verifies signatures against rotating keys.
+//!
+//! All four implement the same trait, so the gateway picks the right
+//! one at startup based on config and treats them uniformly.
+
+pub mod anonymous;
+pub mod identity;
+pub mod interceptor;
+pub mod jwt;
+pub mod oidc;
+pub mod token;
+
+pub use anonymous::AnonymousAuthenticator;
+pub use identity::Identity;
+pub use interceptor::AuthInterceptor;
+pub use jwt::{JwtAuthenticator, JwtConfig, JwtError, KeySource};
+pub use oidc::{OidcAuthenticator, OidcConfig, OidcError};
+pub use token::{StaticTokenAuthenticator, StaticTokenError, TokenEntry};
+
+use async_trait::async_trait;
+use tonic::metadata::MetadataMap;
+use tonic::Status;
+
+/// Verifies caller credentials and returns a trusted `Identity`.
+///
+/// Implementations must be `Send + Sync + 'static` so the gateway can
+/// hold them in an `Arc` and clone references into per-request futures.
+#[async_trait]
+pub trait Authenticator: Send + Sync + 'static {
+ /// Inspect `metadata`, validate any credential present, and return
+ /// the verified identity. On failure, return a tonic `Status` —
+ /// typically `Status::unauthenticated(...)`.
+ async fn authenticate(&self, metadata: &MetadataMap) -> Result<Identity,
Status>;
+}
+
+/// Helper that pulls `authorization: Bearer <token>` out of a
+/// MetadataMap. Returns `None` if the header is absent or malformed,
+/// rather than a Status, so callers can decide how to react (e.g. a
+/// "demand auth" implementation rejects, an "anonymous fallback"
+/// implementation does not).
+pub(crate) fn bearer_token(metadata: &MetadataMap) -> Option<&str> {
+ let raw = metadata.get("authorization")?.to_str().ok()?;
+ let trimmed = raw.trim();
+ let lower_prefix = trimmed.get(..7)?;
+ if lower_prefix.eq_ignore_ascii_case("Bearer ") {
+ Some(trimmed[7..].trim())
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn md(value: &str) -> MetadataMap {
+ let mut md = MetadataMap::new();
+ md.insert("authorization", value.parse().unwrap());
+ md
+ }
+
+ #[test]
+ fn parses_standard_bearer() {
+ assert_eq!(bearer_token(&md("Bearer abc123")), Some("abc123"));
+ }
+
+ #[test]
+ fn parses_lower_case_bearer() {
+ assert_eq!(bearer_token(&md("bearer abc123")), Some("abc123"));
+ }
+
+ #[test]
+ fn ignores_extra_whitespace() {
+ assert_eq!(bearer_token(&md(" Bearer abc123 ")), Some("abc123"));
+ }
+
+ #[test]
+ fn rejects_non_bearer() {
+ assert!(bearer_token(&md("Basic dXNlcjpwYXNz")).is_none());
+ }
+
+ #[test]
+ fn rejects_missing_header() {
+ assert!(bearer_token(&MetadataMap::new()).is_none());
+ }
+}
diff --git a/crates/auth/src/oidc.rs b/crates/auth/src/oidc.rs
new file mode 100644
index 0000000..221a666
--- /dev/null
+++ b/crates/auth/src/oidc.rs
@@ -0,0 +1,809 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! `OidcAuthenticator` — verifies JWTs against a remote JWKS endpoint.
+//!
+//! On startup we fetch the JWKS once (or, if `discovery_url` is set,
+//! we resolve the OIDC discovery document first to find the JWKS URL).
+//! Keys are cached in memory; if a token's `kid` is missing from the
+//! cache, we refresh once before failing the request — that handles
+//! the IdP rotating keys without us paying a network round-trip per
+//! request.
+//!
+//! Validation rules (issuer, audience, claim mapping) are reused from
+//! [`crate::jwt`] — JWKS only changes *where* the verification key
+//! comes from.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use async_trait::async_trait;
+use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
+use parking_lot::RwLock;
+use serde::Deserialize;
+use tokio::sync::Mutex as AsyncMutex;
+use tonic::metadata::MetadataMap;
+use tonic::Status;
+use tracing::{debug, warn};
+
+use crate::{bearer_token, Authenticator, Identity};
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct OidcConfig {
+ /// JWKS URL. Either this or `discovery_url` must be set.
+ #[serde(default)]
+ pub jwks_url: Option<String>,
+ /// OIDC discovery document URL. We resolve `jwks_uri` from it.
+ #[serde(default)]
+ pub discovery_url: Option<String>,
+ pub algorithms: Vec<String>,
+ #[serde(default)]
+ pub issuer: Option<String>,
+ #[serde(default)]
+ pub audience: Option<String>,
+ #[serde(default = "default_user_id_claim")]
+ pub user_id_claim: String,
+ #[serde(default)]
+ pub tenant_claim: Option<String>,
+ #[serde(default)]
+ pub groups_claim: Option<String>,
+ /// Minimum interval between forced refreshes when an unknown `kid`
+ /// is seen. Prevents a barrage of bogus tokens from hammering the
+ /// IdP. Defaults to 60s.
+ #[serde(default = "default_refresh_floor_secs")]
+ pub refresh_floor_secs: u64,
+}
+
+fn default_user_id_claim() -> String {
+ "sub".into()
+}
+fn default_refresh_floor_secs() -> u64 {
+ 60
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum OidcError {
+ #[error("oidc: must set jwks_url or discovery_url")]
+ NoJwksSource,
+ #[error("oidc: at least one algorithm must be configured")]
+ NoAlgorithms,
+ #[error("oidc: unknown algorithm {0}")]
+ UnknownAlg(String),
+ #[error("oidc: HTTP error fetching {url}: {source}")]
+ Http {
+ url: String,
+ #[source]
+ source: reqwest::Error,
+ },
+ #[error("oidc: discovery document at {url} missing jwks_uri")]
+ DiscoveryMissingJwks { url: String },
+ #[error("oidc: parse JWKS at {url}: {source}")]
+ JwksParse {
+ url: String,
+ #[source]
+ source: reqwest::Error,
+ },
+ #[error("oidc: bad PEM in JWKS for kid {kid}: {source}")]
+ BadKey {
+ kid: String,
+ #[source]
+ source: jsonwebtoken::errors::Error,
+ },
+}
+
+#[derive(Debug, Deserialize)]
+struct DiscoveryDoc {
+ jwks_uri: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Jwks {
+ keys: Vec<JwksKey>,
+}
+
+#[derive(Debug, Deserialize)]
+struct JwksKey {
+ kid: Option<String>,
+ kty: String,
+ // We deserialize but don't enforce `alg` per-key; per-token alg is
+ // already constrained by the configured algorithms list.
+ #[serde(default, rename = "alg")]
+ _alg: Option<String>,
+ // RSA modulus / exponent
+ #[serde(default)]
+ n: Option<String>,
+ #[serde(default)]
+ e: Option<String>,
+ // EC coordinates
+ #[serde(default)]
+ crv: Option<String>,
+ #[serde(default)]
+ x: Option<String>,
+ #[serde(default)]
+ y: Option<String>,
+}
+
+struct CachedKeys {
+ keys: HashMap<String, DecodingKey>,
+ last_refreshed: Instant,
+}
+
+pub struct OidcAuthenticator {
+ jwks_url: String,
+ http: reqwest::Client,
+ cache: Arc<RwLock<CachedKeys>>,
+ refresh_lock: Arc<AsyncMutex<()>>,
+ refresh_floor: Duration,
+
+ algorithms: Vec<Algorithm>,
+ issuer: Option<String>,
+ audience: Option<String>,
+ user_id_claim: String,
+ tenant_claim: Option<String>,
+ groups_claim: Option<String>,
+}
+
+impl OidcAuthenticator {
+ /// Build the authenticator and perform an initial JWKS fetch.
+ pub async fn new(cfg: OidcConfig) -> Result<Self, OidcError> {
+ if cfg.algorithms.is_empty() {
+ return Err(OidcError::NoAlgorithms);
+ }
+ let algorithms: Vec<Algorithm> = cfg
+ .algorithms
+ .iter()
+ .map(|a| parse_algorithm(a))
+ .collect::<Result<_, _>>()?;
+ let http = reqwest::Client::builder()
+ .timeout(Duration::from_secs(10))
+ .build()
+ .expect("reqwest client");
+
+ let jwks_url = match (&cfg.jwks_url, &cfg.discovery_url) {
+ (Some(u), _) => u.clone(),
+ (None, Some(u)) => resolve_jwks_url(&http, u).await?,
+ (None, None) => return Err(OidcError::NoJwksSource),
+ };
+
+ let keys = fetch_jwks(&http, &jwks_url).await?;
+ let cache = Arc::new(RwLock::new(CachedKeys {
+ keys,
+ last_refreshed: Instant::now(),
+ }));
+
+ Ok(Self {
+ jwks_url,
+ http,
+ cache,
+ refresh_lock: Arc::new(AsyncMutex::new(())),
+ refresh_floor: Duration::from_secs(cfg.refresh_floor_secs),
+ algorithms,
+ issuer: cfg.issuer,
+ audience: cfg.audience,
+ user_id_claim: cfg.user_id_claim,
+ tenant_claim: cfg.tenant_claim,
+ groups_claim: cfg.groups_claim,
+ })
+ }
+
+ /// Look up the decoding key for `kid`, refreshing the JWKS once
+ /// (subject to the refresh floor) on miss.
+ async fn key_for(&self, kid: &str) -> Option<DecodingKey> {
+ if let Some(k) = self.cache.read().keys.get(kid) {
+ return Some(k.clone());
+ }
+ // Miss → try to refresh, but rate-limited.
+ let guard = self.refresh_lock.lock().await;
+ // Re-check under lock — another caller may have refreshed already.
+ if let Some(k) = self.cache.read().keys.get(kid) {
+ return Some(k.clone());
+ }
+ let elapsed = self.cache.read().last_refreshed.elapsed();
+ if elapsed < self.refresh_floor {
+ debug!(?elapsed, "oidc: skipping refresh — within floor");
+ return None;
+ }
+ match fetch_jwks(&self.http, &self.jwks_url).await {
+ Ok(new_keys) => {
+ // Scope the write guard so it's dropped before the
+ // subsequent read — `parking_lot::RwLock` deadlocks
+ // if the same thread holds a write guard while
+ // attempting to acquire a read guard.
+ {
+ let mut g = self.cache.write();
+ g.keys = new_keys;
+ g.last_refreshed = Instant::now();
+ }
+ drop(guard);
+ self.cache.read().keys.get(kid).cloned()
+ }
+ Err(e) => {
+ warn!(error = %e, "oidc: JWKS refresh failed");
+ None
+ }
+ }
+ }
+
+ fn validation(&self) -> Validation {
+ let mut v = Validation::new(self.algorithms[0]);
+ v.algorithms = self.algorithms.clone();
+ if let Some(iss) = &self.issuer {
+ v.set_issuer(std::slice::from_ref(iss));
+ }
+ if let Some(aud) = &self.audience {
+ v.set_audience(std::slice::from_ref(aud));
+ } else {
+ v.validate_aud = false;
+ }
+ v
+ }
+}
+
+#[async_trait]
+impl Authenticator for OidcAuthenticator {
+ async fn authenticate(&self, metadata: &MetadataMap) -> Result<Identity,
Status> {
+ let token = bearer_token(metadata).ok_or_else(|| {
+ Status::unauthenticated("missing or malformed Authorization:
Bearer header")
+ })?;
+
+ let header = decode_header(token).map_err(|e| {
+ debug!(error = %e, "oidc: malformed JWT header");
+ Status::unauthenticated("invalid JWT header")
+ })?;
+ let kid = header
+ .kid
+ .ok_or_else(|| Status::unauthenticated("JWT missing kid; OIDC
requires keyed JWTs"))?;
+ let key = self.key_for(&kid).await.ok_or_else(|| {
+ debug!(%kid, "oidc: no matching key");
+ Status::unauthenticated("JWT kid not in JWKS")
+ })?;
+
+ let claims = decode::<HashMap<String, serde_json::Value>>(token, &key,
&self.validation())
+ .map_err(|e| {
+ debug!(error = %e, "oidc: validation failed");
+ Status::unauthenticated("invalid JWT")
+ })?;
+ let user_id = claims
+ .claims
+ .get(&self.user_id_claim)
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| {
+ Status::unauthenticated(format!(
+ "JWT missing required claim {:?}",
+ self.user_id_claim
+ ))
+ })?
+ .to_string();
+ let tenant = self
+ .tenant_claim
+ .as_ref()
+ .and_then(|c| claims.claims.get(c))
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+ let groups: Vec<String> = self
+ .groups_claim
+ .as_ref()
+ .and_then(|c| claims.claims.get(c))
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ Ok(Identity {
+ user_id,
+ tenant,
+ groups,
+ })
+ }
+}
+
+async fn resolve_jwks_url(http: &reqwest::Client, url: &str) -> Result<String,
OidcError> {
+ let resp = http.get(url).send().await.map_err(|e| OidcError::Http {
+ url: url.into(),
+ source: e,
+ })?;
+ let doc: DiscoveryDoc = resp.json().await.map_err(|e| OidcError::Http {
+ url: url.into(),
+ source: e,
+ })?;
+ doc.jwks_uri
+ .ok_or_else(|| OidcError::DiscoveryMissingJwks { url: url.into() })
+}
+
+async fn fetch_jwks(
+ http: &reqwest::Client,
+ url: &str,
+) -> Result<HashMap<String, DecodingKey>, OidcError> {
+ let resp = http.get(url).send().await.map_err(|e| OidcError::Http {
+ url: url.into(),
+ source: e,
+ })?;
+ let jwks: Jwks = resp.json().await.map_err(|e| OidcError::JwksParse {
+ url: url.into(),
+ source: e,
+ })?;
+ let mut out = HashMap::with_capacity(jwks.keys.len());
+ for k in jwks.keys {
+ let kid = k.kid.clone().unwrap_or_default();
+ if kid.is_empty() {
+ // No kid → can't index; skip.
+ continue;
+ }
+ let dk = match k.kty.as_str() {
+ "RSA" => match (k.n.as_deref(), k.e.as_deref()) {
+ (Some(n), Some(e)) => {
+ DecodingKey::from_rsa_components(n, e).map_err(|err|
OidcError::BadKey {
+ kid: kid.clone(),
+ source: err,
+ })?
+ }
+ _ => continue,
+ },
+ "EC" => match (k.crv.as_deref(), k.x.as_deref(), k.y.as_deref()) {
+ (Some(_crv), Some(x), Some(y)) => {
+ DecodingKey::from_ec_components(x, y).map_err(|err|
OidcError::BadKey {
+ kid: kid.clone(),
+ source: err,
+ })?
+ }
+ _ => continue,
+ },
+ other => {
+ debug!(kty = %other, "oidc: skipping unsupported key type");
+ continue;
+ }
+ };
+ out.insert(kid, dk);
+ }
+ Ok(out)
+}
+
+fn parse_algorithm(name: &str) -> Result<Algorithm, OidcError> {
+ match name.to_ascii_uppercase().as_str() {
+ "RS256" => Ok(Algorithm::RS256),
+ "RS384" => Ok(Algorithm::RS384),
+ "RS512" => Ok(Algorithm::RS512),
+ "PS256" => Ok(Algorithm::PS256),
+ "PS384" => Ok(Algorithm::PS384),
+ "PS512" => Ok(Algorithm::PS512),
+ "ES256" => Ok(Algorithm::ES256),
+ "ES384" => Ok(Algorithm::ES384),
+ "EDDSA" => Ok(Algorithm::EdDSA),
+ other => Err(OidcError::UnknownAlg(other.into())),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ //! OIDC tests stand up a `wiremock` HTTP server that plays the role
+ //! of the identity provider, exposing a JWKS document (and
+ //! optionally an OIDC discovery document) backed by an RSA key
+ //! pair we generate per-test. Tokens are signed with the private
+ //! half using `jsonwebtoken`; the authenticator fetches the
+ //! public half over HTTP and verifies. The whole loop runs
+ //! in-process — no real IdP, no internet, no test fixtures on
+ //! disk.
+ //!
+ //! Key rotation is exercised by swapping the JWKS body the mock
+ //! server returns mid-test and driving a token signed under the
+ //! new key through the authenticator; the first miss triggers a
+ //! single re-fetch (subject to `refresh_floor_secs`) so the
+ //! refresh path is the one being measured, not the static cache.
+ use super::*;
+ use base64::Engine;
+ use jsonwebtoken::{encode, EncodingKey, Header};
+ use rsa::pkcs1::EncodeRsaPrivateKey;
+ use rsa::traits::PublicKeyParts;
+ use rsa::{RsaPrivateKey, RsaPublicKey};
+ use serde_json::json;
+ use wiremock::matchers::{method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ /// One RSA key pair plus its JWKS representation. Lives across a
+ /// test so we can both sign tokens and serve the public half.
+ struct TestKey {
+ kid: String,
+ private_pem: String,
+ jwks_entry: serde_json::Value,
+ }
+
+ fn gen_key(kid: &str) -> TestKey {
+ let mut rng = rand::thread_rng();
+ let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA
key");
+ let pub_key = RsaPublicKey::from(&priv_key);
+
+ let private_pem = priv_key
+ .to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
+ .expect("encode private key as PEM")
+ .to_string();
+
+ let n =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be());
+ let e =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be());
+ let jwks_entry = json!({
+ "kid": kid,
+ "kty": "RSA",
+ "use": "sig",
+ "alg": "RS256",
+ "n": n,
+ "e": e,
+ });
+
+ TestKey {
+ kid: kid.into(),
+ private_pem,
+ jwks_entry,
+ }
+ }
+
+ fn jwks_body(keys: &[&TestKey]) -> serde_json::Value {
+ json!({
+ "keys": keys.iter().map(|k|
k.jwks_entry.clone()).collect::<Vec<_>>()
+ })
+ }
+
+ fn future_exp() -> usize {
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ + 600) as usize
+ }
+
+ fn past_exp() -> usize {
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+ - 600) as usize
+ }
+
+ fn sign(key: &TestKey, claims: serde_json::Value) -> String {
+ let mut header = Header::new(Algorithm::RS256);
+ header.kid = Some(key.kid.clone());
+ encode(
+ &header,
+ &claims,
+ &EncodingKey::from_rsa_pem(key.private_pem.as_bytes()).unwrap(),
+ )
+ .unwrap()
+ }
+
+ /// Sign with a kid that doesn't match the encoded key — useful for
+ /// the "kid not in JWKS" path.
+ fn sign_with_kid(key: &TestKey, kid: &str, claims: serde_json::Value) ->
String {
+ let mut header = Header::new(Algorithm::RS256);
+ header.kid = Some(kid.into());
+ encode(
+ &header,
+ &claims,
+ &EncodingKey::from_rsa_pem(key.private_pem.as_bytes()).unwrap(),
+ )
+ .unwrap()
+ }
+
+ fn md(token: &str) -> MetadataMap {
+ let mut md = MetadataMap::new();
+ md.insert(
+ "authorization",
+ format!("Bearer {}", token).parse().unwrap(),
+ );
+ md
+ }
+
+ fn base_cfg(jwks_url: String) -> OidcConfig {
+ OidcConfig {
+ jwks_url: Some(jwks_url),
+ discovery_url: None,
+ algorithms: vec!["RS256".into()],
+ issuer: None,
+ audience: None,
+ user_id_claim: "sub".into(),
+ tenant_claim: Some("tenant".into()),
+ groups_claim: Some("groups".into()),
+ refresh_floor_secs: 0, // tests advance rotation explicitly
+ }
+ }
+
+ #[tokio::test]
+ async fn valid_token_resolves_identity() {
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let auth = OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri())))
+ .await
+ .unwrap();
+ let token = sign(
+ &key,
+ json!({
+ "sub": "alice",
+ "exp": future_exp(),
+ "tenant": "team-a",
+ "groups": ["devs", "admins"],
+ }),
+ );
+ let id = auth.authenticate(&md(&token)).await.unwrap();
+ assert_eq!(id.user_id, "alice");
+ assert_eq!(id.tenant.as_deref(), Some("team-a"));
+ assert_eq!(id.groups, vec!["devs", "admins"]);
+ }
+
+ #[tokio::test]
+ async fn discovery_url_resolves_jwks_uri() {
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ let jwks_uri = format!("{}/keys", server.uri());
+ Mock::given(method("GET"))
+ .and(path("/.well-known/openid-configuration"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jwks_uri": jwks_uri })))
+ .mount(&server)
+ .await;
+ Mock::given(method("GET"))
+ .and(path("/keys"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let mut cfg = base_cfg(String::new());
+ cfg.jwks_url = None;
+ cfg.discovery_url =
Some(format!("{}/.well-known/openid-configuration", server.uri()));
+
+ let auth = OidcAuthenticator::new(cfg).await.unwrap();
+ let token = sign(&key, json!({ "sub": "alice", "exp": future_exp() }));
+ let id = auth.authenticate(&md(&token)).await.unwrap();
+ assert_eq!(id.user_id, "alice");
+ }
+
+ #[tokio::test]
+ async fn discovery_doc_missing_jwks_uri_is_clear_error() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/.well-known/openid-configuration"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": "x" })))
+ .mount(&server)
+ .await;
+
+ let mut cfg = base_cfg(String::new());
+ cfg.jwks_url = None;
+ cfg.discovery_url =
Some(format!("{}/.well-known/openid-configuration", server.uri()));
+
+ let err = match OidcAuthenticator::new(cfg).await {
+ Ok(_) => panic!("expected DiscoveryMissingJwks error"),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(err, OidcError::DiscoveryMissingJwks { .. }),
+ "got: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn initial_jwks_fetch_failure_propagates() {
+ // No mock mounted → wiremock returns 404 for every request.
+ let server = MockServer::start().await;
+ let err = match OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri()))).await {
+ Ok(_) => panic!("expected JwksParse or Http error"),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(err, OidcError::JwksParse { .. } | OidcError::Http { ..
}),
+ "got: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn expired_token_rejected() {
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let auth = OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri())))
+ .await
+ .unwrap();
+ let token = sign(&key, json!({ "sub": "alice", "exp": past_exp() }));
+ let err = auth.authenticate(&md(&token)).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn issuer_mismatch_rejected() {
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let mut cfg = base_cfg(format!("{}/jwks", server.uri()));
+ cfg.issuer = Some("https://expected".into());
+
+ let auth = OidcAuthenticator::new(cfg).await.unwrap();
+ let token = sign(
+ &key,
+ json!({
+ "sub": "alice",
+ "exp": future_exp(),
+ "iss": "https://attacker",
+ }),
+ );
+ let err = auth.authenticate(&md(&token)).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn audience_mismatch_rejected() {
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let mut cfg = base_cfg(format!("{}/jwks", server.uri()));
+ cfg.audience = Some("scg".into());
+
+ let auth = OidcAuthenticator::new(cfg).await.unwrap();
+ let token = sign(
+ &key,
+ json!({
+ "sub": "alice",
+ "exp": future_exp(),
+ "aud": "some-other-service",
+ }),
+ );
+ let err = auth.authenticate(&md(&token)).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn missing_kid_in_header_rejected() {
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let auth = OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri())))
+ .await
+ .unwrap();
+ // Sign without setting header.kid.
+ let token = encode(
+ &Header::new(Algorithm::RS256),
+ &json!({ "sub": "alice", "exp": future_exp() }),
+ &EncodingKey::from_rsa_pem(key.private_pem.as_bytes()).unwrap(),
+ )
+ .unwrap();
+ let err = auth.authenticate(&md(&token)).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn unknown_kid_triggers_refresh_then_succeeds() {
+ // Initial JWKS has only key-1; mid-test we'll rotate so that
+ // key-2 appears. A token signed with key-2 should drive the
+ // authenticator into the unknown-kid refresh path and succeed
+ // on the retry.
+ let key_old = gen_key("key-1");
+ let key_new = gen_key("key-2");
+ let server = MockServer::start().await;
+
+ // First, only the old key is present.
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key_old])))
+ .up_to_n_times(1)
+ .mount(&server)
+ .await;
+ // After the first response is consumed, the rotated body is
+ // served. wiremock matches mocks in priority + insertion order
+ // so this one fires for subsequent GETs.
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+ .respond_with(
+ ResponseTemplate::new(200).set_body_json(jwks_body(&[&key_old,
&key_new])),
+ )
+ .mount(&server)
+ .await;
+
+ let auth = OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri())))
+ .await
+ .unwrap();
+ // Token signed under the new key, with the new key's kid.
+ let token = sign(&key_new, json!({ "sub": "bob", "exp": future_exp()
}));
+ let id = auth.authenticate(&md(&token)).await.unwrap();
+ assert_eq!(id.user_id, "bob");
+ }
+
+ #[tokio::test]
+ async fn unknown_kid_still_rejected_after_refresh_if_absent() {
+ // Even after a refresh, if the kid is not in the JWKS, reject.
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .mount(&server)
+ .await;
+
+ let auth = OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri())))
+ .await
+ .unwrap();
+ // Sign with key-1 bytes but claim kid=ghost.
+ let token = sign_with_kid(&key, "ghost", json!({ "sub": "x", "exp":
future_exp() }));
+ let err = auth.authenticate(&md(&token)).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn refresh_floor_blocks_repeated_misses() {
+ // With a non-zero refresh floor, a second unknown-kid attempt
+ // within the window must NOT hit the JWKS endpoint again.
+ let key = gen_key("key-1");
+ let server = MockServer::start().await;
+ // Expect at most ONE GET — the initial fetch. The refresh
+ // floor must suppress any miss-driven re-fetch within the
+ // window.
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(jwks_body(&[&key])))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut cfg = base_cfg(format!("{}/jwks", server.uri()));
+ cfg.refresh_floor_secs = 60;
+ let auth = OidcAuthenticator::new(cfg).await.unwrap();
+
+ // Two consecutive unknown-kid attempts.
+ let ghost_token = sign_with_kid(&key, "ghost", json!({ "sub": "x",
"exp": future_exp() }));
+ let _ = auth.authenticate(&md(&ghost_token)).await;
+ let _ = auth.authenticate(&md(&ghost_token)).await;
+
+ // Mock's `.expect(1)` verifies on Drop that the endpoint was
+ // hit exactly once.
+ }
+
+ #[tokio::test]
+ async fn malformed_jwks_response_is_clear_error() {
+ let server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path("/jwks"))
+ .respond_with(ResponseTemplate::new(200).set_body_string("not json
at all"))
+ .mount(&server)
+ .await;
+
+ let err = match OidcAuthenticator::new(base_cfg(format!("{}/jwks",
server.uri()))).await {
+ Ok(_) => panic!("expected JwksParse error"),
+ Err(e) => e,
+ };
+ assert!(matches!(err, OidcError::JwksParse { .. }), "got: {err:?}");
+ }
+}
diff --git a/crates/auth/src/token.rs b/crates/auth/src/token.rs
new file mode 100644
index 0000000..a2956c2
--- /dev/null
+++ b/crates/auth/src/token.rs
@@ -0,0 +1,232 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! `StaticTokenAuthenticator` — Bearer-token allowlist.
+//!
+//! Loads a `token → user_id` map at startup; on each request, looks up
+//! the inbound `Authorization: Bearer <token>` and returns the
+//! matching `Identity`. Constant-time comparison prevents the
+//! authenticator from leaking *which* token is wrong via timing.
+//!
+//! Intended for dev / test / single-team use. JWT or OIDC is the right
+//! choice for anything beyond that.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use parking_lot::RwLock;
+use serde::Deserialize;
+use subtle::ConstantTimeEq;
+use tonic::metadata::MetadataMap;
+use tonic::Status;
+use tracing::debug;
+
+use crate::{bearer_token, Authenticator, Identity};
+
+/// One entry in the static token table.
+#[derive(Debug, Clone, Deserialize)]
+pub struct TokenEntry {
+ /// The opaque token string clients present.
+ pub token: String,
+ /// What `user_id` to attach to authenticated requests.
+ pub user_id: String,
+ /// Optional tenant (workspace / org / project).
+ #[serde(default)]
+ pub tenant: Option<String>,
+ /// Optional groups. Recorded on `session.create` /
+ /// `session.release` audit events so operators can see which
+ /// memberships an identity carries; not currently consulted for
+ /// authorization (the gateway has no RBAC layer yet).
+ #[serde(default)]
+ pub groups: Vec<String>,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum StaticTokenError {
+ #[error("static token authenticator: at least one token required")]
+ Empty,
+ #[error("static token authenticator: duplicate token entry for {0:?}")]
+ Duplicate(String),
+}
+
+#[derive(Debug)]
+pub struct StaticTokenAuthenticator {
+ /// We keep tokens behind a RwLock so a future "reload" hook can
+ /// rotate them without restarting. Reads on the hot path acquire a
+ /// read lock, which is uncontended in steady state.
+ inner: Arc<RwLock<HashMap<String, IdentityTemplate>>>,
+}
+
+/// What we materialize into an `Identity` on a hit. Stored alongside
+/// the token to avoid reallocating per-request.
+#[derive(Debug, Clone)]
+struct IdentityTemplate {
+ user_id: String,
+ tenant: Option<String>,
+ groups: Vec<String>,
+}
+
+impl StaticTokenAuthenticator {
+ pub fn new(entries: Vec<TokenEntry>) -> Result<Self, StaticTokenError> {
+ if entries.is_empty() {
+ return Err(StaticTokenError::Empty);
+ }
+ let mut map = HashMap::with_capacity(entries.len());
+ for e in entries {
+ if map.contains_key(&e.token) {
+ return Err(StaticTokenError::Duplicate(e.token));
+ }
+ map.insert(
+ e.token,
+ IdentityTemplate {
+ user_id: e.user_id,
+ tenant: e.tenant,
+ groups: e.groups,
+ },
+ );
+ }
+ Ok(Self {
+ inner: Arc::new(RwLock::new(map)),
+ })
+ }
+
+ /// Constant-time lookup. Iterates the full table on every call so
+ /// timing does not depend on which slot is occupied.
+ fn lookup(&self, presented: &str) -> Option<IdentityTemplate> {
+ let table = self.inner.read();
+ let mut found: Option<IdentityTemplate> = None;
+ for (stored, tmpl) in table.iter() {
+ // ConstantTimeEq is constant-time *for the byte slices it
+ // compares*. Different lengths return false fast — that
+ // does leak length info, but token length isn't sensitive
+ // (clients see their own token's length anyway).
+ if stored.as_bytes().ct_eq(presented.as_bytes()).into() {
+ found = Some(tmpl.clone());
+ // Continue iterating so timing doesn't depend on hit position.
+ }
+ }
+ found
+ }
+}
+
+#[async_trait]
+impl Authenticator for StaticTokenAuthenticator {
+ async fn authenticate(&self, metadata: &MetadataMap) -> Result<Identity,
Status> {
+ let token = bearer_token(metadata).ok_or_else(|| {
+ Status::unauthenticated("missing or malformed Authorization:
Bearer header")
+ })?;
+ match self.lookup(token) {
+ Some(tmpl) => {
+ debug!(user_id = %tmpl.user_id, "static token auth: ok");
+ Ok(Identity {
+ user_id: tmpl.user_id,
+ tenant: tmpl.tenant,
+ groups: tmpl.groups,
+ })
+ }
+ None => {
+ debug!("static token auth: unknown token");
+ Err(Status::unauthenticated("invalid token"))
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn auth() -> StaticTokenAuthenticator {
+ StaticTokenAuthenticator::new(vec![
+ TokenEntry {
+ token: "alice-secret".into(),
+ user_id: "alice".into(),
+ tenant: Some("team-a".into()),
+ groups: vec!["devs".into()],
+ },
+ TokenEntry {
+ token: "bob-secret".into(),
+ user_id: "bob".into(),
+ tenant: None,
+ groups: vec![],
+ },
+ ])
+ .unwrap()
+ }
+
+ fn md(value: &str) -> MetadataMap {
+ let mut md = MetadataMap::new();
+ md.insert("authorization", value.parse().unwrap());
+ md
+ }
+
+ #[tokio::test]
+ async fn known_token_resolves_to_identity() {
+ let id = auth()
+ .authenticate(&md("Bearer alice-secret"))
+ .await
+ .unwrap();
+ assert_eq!(id.user_id, "alice");
+ assert_eq!(id.tenant.as_deref(), Some("team-a"));
+ assert_eq!(id.groups, vec!["devs"]);
+ }
+
+ #[tokio::test]
+ async fn second_token_is_independent() {
+ let id = auth().authenticate(&md("Bearer bob-secret")).await.unwrap();
+ assert_eq!(id.user_id, "bob");
+ assert!(id.tenant.is_none());
+ }
+
+ #[tokio::test]
+ async fn unknown_token_rejected() {
+ let err = auth().authenticate(&md("Bearer nope")).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[tokio::test]
+ async fn missing_header_rejected() {
+ let err = auth().authenticate(&MetadataMap::new()).await.unwrap_err();
+ assert_eq!(err.code(), tonic::Code::Unauthenticated);
+ }
+
+ #[test]
+ fn empty_table_rejected() {
+ assert!(matches!(
+ StaticTokenAuthenticator::new(vec![]).unwrap_err(),
+ StaticTokenError::Empty
+ ));
+ }
+
+ #[test]
+ fn duplicate_token_rejected() {
+ let res = StaticTokenAuthenticator::new(vec![
+ TokenEntry {
+ token: "x".into(),
+ user_id: "a".into(),
+ tenant: None,
+ groups: vec![],
+ },
+ TokenEntry {
+ token: "x".into(),
+ user_id: "b".into(),
+ tenant: None,
+ groups: vec![],
+ },
+ ]);
+ assert!(matches!(res.unwrap_err(), StaticTokenError::Duplicate(_)));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]