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]

Reply via email to