From dfa0578954eda52b46b4b4658d1f34efa04e7e3b Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 5 Jun 2026 17:05:39 +1200 Subject: [PATCH 1/2] M3b: add trust-auth RS verification crate and align token claims. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Cargo workspace with crates/trust-auth for resource-server JWT verification: JWKS fetch/cache, TOKEN-SPEC steps 2–8 (signature, issuer, aud, time, scope), AuthedWallet, and an Axum BearerAuth extractor with RFC 6750 error responses. Align trust-relay minting with ADR 0004 by dropping tier/att from AccessTokenClaims (reserved in TOKEN-SPEC, not emitted). Update session tests and AGENTS.md workspace layout. --- AGENTS.md | 13 +- Cargo.lock | 341 +++++++++++++++++++++++++++++- Cargo.toml | 4 + crates/trust-auth/Cargo.toml | 39 ++++ crates/trust-auth/src/axum_ext.rs | 85 ++++++++ crates/trust-auth/src/claims.rs | 60 ++++++ crates/trust-auth/src/config.rs | 60 ++++++ crates/trust-auth/src/error.rs | 33 +++ crates/trust-auth/src/jwks.rs | 224 ++++++++++++++++++++ crates/trust-auth/src/lib.rs | 19 ++ crates/trust-auth/src/verifier.rs | 112 ++++++++++ crates/trust-auth/tests/verify.rs | 102 +++++++++ docs/TOKEN-SPEC.md | 2 +- src/models/claims.rs | 25 +-- src/models/mod.rs | 2 +- src/services/token.rs | 9 +- tests/session.rs | 6 +- 17 files changed, 1095 insertions(+), 41 deletions(-) create mode 100644 crates/trust-auth/Cargo.toml create mode 100644 crates/trust-auth/src/axum_ext.rs create mode 100644 crates/trust-auth/src/claims.rs create mode 100644 crates/trust-auth/src/config.rs create mode 100644 crates/trust-auth/src/error.rs create mode 100644 crates/trust-auth/src/jwks.rs create mode 100644 crates/trust-auth/src/lib.rs create mode 100644 crates/trust-auth/src/verifier.rs create mode 100644 crates/trust-auth/tests/verify.rs diff --git a/AGENTS.md b/AGENTS.md index 61cb885..f510ae7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,10 @@ cargo fmt --check # run before considering a task done ## Project Structure ```text -trust-relay/ +trust-relay/ # Cargo workspace root ├── Cargo.toml +├── crates/ +│ └── trust-auth/ # RS JWT verification (JWKS + Axum extractor) ├── README.md ├── AGENTS.md ├── config/ @@ -81,6 +83,15 @@ until the code that needs it exists. | HTTP middleware | `tower-http` | 0.6 (trace, cors) | | Service trait | `tower` | 0.5 | +### `trust-auth` crate (M3b) + +| Purpose | Crate | Version | +| --- | --- | --- | +| JWKS fetch | `reqwest` | 0.12 (rustls) | + +Rust resource servers (e.g. beacon-relay) depend on `trust-auth` for TOKEN-SPEC +§7 verification. Revocation/quota remain in the adopting service. + ### Planned (add with the milestone that needs them) | Purpose | Crate | Notes | diff --git a/Cargo.lock b/Cargo.lock index e375b74..88ae425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", - "untrusted", + "untrusted 0.7.1", "zeroize", ] @@ -211,6 +211,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.10.0" @@ -635,8 +641,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -646,9 +654,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -781,6 +791,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "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]] @@ -789,13 +816,21 @@ 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 0.6.4", "tokio", "tower-service", + "tracing", ] [[package]] @@ -925,6 +960,12 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "iri-string" version = "0.7.12" @@ -1052,6 +1093,12 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1273,6 +1320,61 @@ dependencies = [ "yansi", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1301,10 +1403,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1326,6 +1438,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1335,6 +1457,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -1393,6 +1524,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[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 = "rfc6979" version = "0.4.0" @@ -1403,6 +1572,26 @@ dependencies = [ "subtle", ] +[[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 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1412,6 +1601,41 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1685,6 +1909,9 @@ 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" @@ -1767,6 +1994,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" @@ -1795,6 +2037,16 @@ dependencies = [ "syn", ] +[[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-util" version = "0.7.18" @@ -1873,13 +2125,16 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-util", "http", "http-body", "http-body-util", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -1969,6 +2224,28 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "trust-auth" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "ed25519-dalek", + "http", + "http-body-util", + "jsonwebtoken", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tower", + "tracing", + "uuid", +] + [[package]] name = "trust-relay" version = "0.1.0" @@ -2001,6 +2278,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2034,6 +2317,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[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" @@ -2075,6 +2364,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2112,6 +2410,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2178,6 +2486,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 4c321b6..7c113c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = [".", "crates/trust-auth"] +resolver = "2" + [package] name = "trust-relay" version = "0.1.0" diff --git a/crates/trust-auth/Cargo.toml b/crates/trust-auth/Cargo.toml new file mode 100644 index 0000000..4c6ddbf --- /dev/null +++ b/crates/trust-auth/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "trust-auth" +version = "0.1.0" +edition = "2021" +description = "Resource-server JWT verification for trust-relay bearer tokens" +license = "MIT" +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" + +[features] +default = ["axum"] +axum = ["dep:axum", "dep:http"] + +[dependencies] +axum = { version = "0.8", optional = true } +http = { version = "1", optional = true } +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["sync", "time"] } +tracing = "0.1" + +[dev-dependencies] +axum = "0.8" +base64 = "0.22" +ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8"] } +http-body-util = "0.1" +rand = "0.8" +serde_json = "1" +time = { version = "0.3", features = ["formatting", "parsing"] } +tower = { version = "0.5", features = ["util"] } +uuid = { version = "1", features = ["v4"] } diff --git a/crates/trust-auth/src/axum_ext.rs b/crates/trust-auth/src/axum_ext.rs new file mode 100644 index 0000000..336d451 --- /dev/null +++ b/crates/trust-auth/src/axum_ext.rs @@ -0,0 +1,85 @@ +//! Axum extractors for bearer verification. + +use axum::{ + extract::{FromRef, FromRequestParts}, + http::{header::AUTHORIZATION, request::Parts, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use std::{future::Future, sync::Arc}; + +use crate::{error::AuthError, verifier::AuthedWallet, TokenVerifier}; + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + error_description: Option, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let status = + StatusCode::from_u16(self.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let add_www_authenticate = matches!( + &self, + AuthError::MissingAuthorization + | AuthError::InvalidAuthorization + | AuthError::InvalidToken(_) + ); + let description = self.to_string(); + let body = Json(ErrorBody { + error: self.error_code().to_string(), + error_description: Some(description), + }); + let mut response = (status, body).into_response(); + if add_www_authenticate { + if let Ok(value) = HeaderValue::from_str(r#"Bearer error="invalid_token""#) { + response + .headers_mut() + .insert(http::header::WWW_AUTHENTICATE, value); + } + } + response + } +} + +pub fn bearer_token(headers: &HeaderMap) -> Result { + let value = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(AuthError::MissingAuthorization)?; + let token = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + .ok_or(AuthError::InvalidAuthorization)?; + if token.trim().is_empty() { + return Err(AuthError::InvalidAuthorization); + } + Ok(token.trim().to_string()) +} + +/// Axum extractor: verified wallet from `Authorization: Bearer`. +pub struct BearerAuth(pub AuthedWallet); + +impl FromRequestParts for BearerAuth +where + S: Send + Sync, + Arc: FromRef, +{ + type Rejection = AuthError; + + fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> impl Future> + Send { + let token = bearer_token(&parts.headers); + let verifier = Arc::::from_ref(state); + async move { + let token = token?; + let wallet = verifier.verify(&token).await?; + Ok(BearerAuth(wallet)) + } + } +} diff --git a/crates/trust-auth/src/claims.rs b/crates/trust-auth/src/claims.rs new file mode 100644 index 0000000..dea82a7 --- /dev/null +++ b/crates/trust-auth/src/claims.rs @@ -0,0 +1,60 @@ +//! Deserialized access-token claims (TOKEN-SPEC §2). + +use serde::Deserialize; +use serde_json::Value; + +/// Wallet-tier access token payload. `tier` and `att` are reserved and optional. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TokenClaims { + pub iss: String, + pub sub: String, + pub aud: Value, + pub iat: i64, + pub nbf: i64, + pub exp: i64, + pub jti: String, + pub scope: String, + #[serde(default)] + pub tier: Option, + #[serde(default)] + pub chain_id: Option, + #[serde(default)] + pub ver: Option, +} + +impl TokenClaims { + pub fn scopes(&self) -> impl Iterator { + self.scope.split_whitespace() + } + + pub fn has_scope(&self, required: &str) -> bool { + self.scopes().any(|s| s == required) + } +} + +pub(crate) fn audience_contains(aud: &Value, expected: &str) -> bool { + match aud { + Value::String(s) => s == expected, + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .any(|s| s == expected), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn audience_accepts_string_or_array() { + assert!(audience_contains(&json!("nodle-backend"), "nodle-backend")); + assert!(audience_contains( + &json!(["nodle-backend", "other"]), + "nodle-backend" + )); + assert!(!audience_contains(&json!("other"), "nodle-backend")); + } +} diff --git a/crates/trust-auth/src/config.rs b/crates/trust-auth/src/config.rs new file mode 100644 index 0000000..e1cdd41 --- /dev/null +++ b/crates/trust-auth/src/config.rs @@ -0,0 +1,60 @@ +//! Verifier configuration. + +use std::time::Duration; + +/// Configuration for [`crate::TokenVerifier`]. +#[derive(Debug, Clone)] +pub struct VerifierConfig { + /// JWKS URL (e.g. `https://trust-relay.nodle.com/.well-known/jwks.json`). + pub jwks_url: Option, + /// Inline JWKS JSON for tests or static deploys (overrides fetch when set). + pub jwks_json: Option, + /// Trusted session issuers (`iss` claim). Nodle: one trust-relay URL. + pub issuers: Vec, + /// Required audience value (`aud` must contain this string). + pub audience: String, + /// Allowed clock skew for `exp` / `nbf` / `iat` (seconds). Default 60. + pub clock_skew_secs: u64, + /// JWKS cache TTL before background refresh. Default 1 hour. + pub jwks_cache_ttl: Duration, +} + +impl VerifierConfig { + pub fn new(issuers: impl Into>, audience: impl Into) -> Self { + Self { + jwks_url: None, + jwks_json: None, + issuers: issuers.into(), + audience: audience.into(), + clock_skew_secs: 60, + jwks_cache_ttl: Duration::from_secs(3600), + } + } + + pub fn jwks_url(mut self, url: impl Into) -> Self { + self.jwks_url = Some(url.into()); + self + } + + pub fn jwks_json(mut self, json: impl Into) -> Self { + self.jwks_json = Some(json.into()); + self + } + + pub fn clock_skew_secs(mut self, secs: u64) -> Self { + self.clock_skew_secs = secs; + self + } + + pub fn jwks_cache_ttl(mut self, ttl: Duration) -> Self { + self.jwks_cache_ttl = ttl; + self + } + + pub(crate) fn has_jwks_source(&self) -> bool { + self.jwks_json + .as_ref() + .is_some_and(|s| !s.trim().is_empty()) + || self.jwks_url.as_ref().is_some_and(|s| !s.trim().is_empty()) + } +} diff --git a/crates/trust-auth/src/error.rs b/crates/trust-auth/src/error.rs new file mode 100644 index 0000000..437bb88 --- /dev/null +++ b/crates/trust-auth/src/error.rs @@ -0,0 +1,33 @@ +//! Auth errors mapped to TOKEN-SPEC HTTP responses. + +use thiserror::Error; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum AuthError { + #[error("missing Authorization header")] + MissingAuthorization, + #[error("Authorization must be Bearer")] + InvalidAuthorization, + #[error("{0}")] + InvalidToken(String), + #[error("token does not grant scope {0}")] + InsufficientScope(String), +} + +impl AuthError { + pub fn status_code(&self) -> u16 { + match self { + Self::MissingAuthorization | Self::InvalidAuthorization | Self::InvalidToken(_) => 401, + Self::InsufficientScope(_) => 403, + } + } + + pub fn error_code(&self) -> &'static str { + match self { + Self::MissingAuthorization | Self::InvalidAuthorization | Self::InvalidToken(_) => { + "invalid_token" + } + Self::InsufficientScope(_) => "insufficient_scope", + } + } +} diff --git a/crates/trust-auth/src/jwks.rs b/crates/trust-auth/src/jwks.rs new file mode 100644 index 0000000..cb74716 --- /dev/null +++ b/crates/trust-auth/src/jwks.rs @@ -0,0 +1,224 @@ +//! JWKS fetch and in-memory cache. + +use std::{ + collections::HashMap, + sync::Arc, + time::Instant, +}; + +use jsonwebtoken::{decode_header, Algorithm, DecodingKey}; +use serde_json::Value; +use tokio::sync::RwLock; +use tracing::warn; + +use crate::{config::VerifierConfig, error::AuthError}; + +#[derive(Clone)] +pub(crate) struct JwksCache { + inner: Arc>, + config: VerifierConfig, + client: reqwest::Client, +} + +struct CacheState { + keys: HashMap, + fetched_at: Option, +} + +impl JwksCache { + pub fn new(config: VerifierConfig) -> Self { + Self { + inner: Arc::new(RwLock::new(CacheState { + keys: HashMap::new(), + fetched_at: None, + })), + config, + client: reqwest::Client::new(), + } + } + + pub async fn decoding_key_for( + &self, + kid: &str, + alg: Algorithm, + ) -> Result { + { + let guard = self.inner.read().await; + if let Some(key) = guard.keys.get(kid) { + return Ok(key.clone()); + } + } + + self.refresh().await?; + + let guard = self.inner.read().await; + guard + .keys + .get(kid) + .cloned() + .ok_or_else(|| AuthError::InvalidToken(format!("unknown kid {kid} for alg {alg:?}"))) + } + + pub async fn ensure_loaded(&self) -> Result<(), AuthError> { + let needs_fetch = { + let guard = self.inner.read().await; + guard.fetched_at.is_none() + || guard + .fetched_at + .is_some_and(|t| t.elapsed() >= self.config.jwks_cache_ttl) + }; + if needs_fetch { + self.refresh().await?; + } + Ok(()) + } + + async fn refresh(&self) -> Result<(), AuthError> { + let json = self.load_jwks_json().await?; + let keys = parse_jwks(&json)?; + let mut guard = self.inner.write().await; + guard.keys = keys; + guard.fetched_at = Some(Instant::now()); + Ok(()) + } + + async fn load_jwks_json(&self) -> Result { + if let Some(ref inline) = self.config.jwks_json { + if !inline.trim().is_empty() { + return Ok(inline.clone()); + } + } + let url = self + .config + .jwks_url + .as_ref() + .ok_or_else(|| AuthError::InvalidToken("no JWKS source configured".into()))?; + self.client + .get(url) + .send() + .await + .map_err(|e| AuthError::InvalidToken(format!("jwks fetch failed: {e}")))? + .error_for_status() + .map_err(|e| AuthError::InvalidToken(format!("jwks fetch status: {e}")))? + .text() + .await + .map_err(|e| AuthError::InvalidToken(format!("jwks body read failed: {e}"))) + } +} + +fn parse_jwks(json: &str) -> Result, AuthError> { + let value: Value = serde_json::from_str(json) + .map_err(|e| AuthError::InvalidToken(format!("jwks json parse: {e}")))?; + let keys = value + .get("keys") + .and_then(Value::as_array) + .ok_or_else(|| AuthError::InvalidToken("jwks missing keys array".into()))?; + + let mut map = HashMap::new(); + for key in keys { + let kid = key + .get("kid") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::InvalidToken("jwks entry missing kid".into()))?; + match decoding_key_from_jwk(key) { + Ok(decoding_key) => { + map.insert(kid.to_string(), decoding_key); + } + Err(e) => { + warn!(kid, error = %e, "skipping unsupported jwks entry"); + } + } + } + if map.is_empty() { + return Err(AuthError::InvalidToken( + "jwks contained no usable verification keys".into(), + )); + } + Ok(map) +} + +fn decoding_key_from_jwk(jwk: &Value) -> Result { + let kty = jwk + .get("kty") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::InvalidToken("jwk missing kty".into()))?; + match kty { + "OKP" => { + let crv = jwk.get("crv").and_then(Value::as_str).unwrap_or(""); + if crv != "Ed25519" { + return Err(AuthError::InvalidToken(format!( + "unsupported OKP crv {crv}" + ))); + } + let x = jwk + .get("x") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::InvalidToken("Ed25519 jwk missing x".into()))?; + DecodingKey::from_ed_components(x) + .map_err(|e| AuthError::InvalidToken(format!("ed25519 jwk: {e}"))) + } + "EC" => { + let x = jwk + .get("x") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::InvalidToken("EC jwk missing x".into()))?; + let y = jwk + .get("y") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::InvalidToken("EC jwk missing y".into()))?; + DecodingKey::from_ec_components(x, y) + .map_err(|e| AuthError::InvalidToken(format!("ec jwk: {e}"))) + } + other => Err(AuthError::InvalidToken(format!( + "unsupported jwk kty {other}" + ))), + } +} + +pub(crate) fn allowed_algorithm(alg: Algorithm) -> Result<(), AuthError> { + match alg { + Algorithm::EdDSA | Algorithm::ES256 => Ok(()), + other => Err(AuthError::InvalidToken(format!( + "unsupported or rejected algorithm {other:?}" + ))), + } +} + +pub(crate) fn parse_access_token_header(token: &str) -> Result<(String, Algorithm), AuthError> { + let header = decode_header(token) + .map_err(|e| AuthError::InvalidToken(format!("jwt header parse: {e}")))?; + if header.typ.as_deref() != Some("at+jwt") { + return Err(AuthError::InvalidToken( + "jwt typ must be at+jwt".to_string(), + )); + } + let alg = header.alg; + allowed_algorithm(alg)?; + let kid = header + .kid + .ok_or_else(|| AuthError::InvalidToken("jwt header missing kid".into()))?; + Ok((kid, alg)) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use ed25519_dalek::{SigningKey, VerifyingKey}; + use rand::rngs::OsRng; + + fn sample_jwks() -> String { + let signing = SigningKey::generate(&mut OsRng); + let verifying: VerifyingKey = signing.verifying_key(); + let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifying.to_bytes()); + format!( + r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","kid":"k1","use":"sig","alg":"EdDSA","x":"{x}"}}]}}"# + ) + } + + #[test] + fn parse_jwks_loads_ed25519_key() { + let map = parse_jwks(&sample_jwks()).unwrap(); + assert!(map.contains_key("k1")); + } +} diff --git a/crates/trust-auth/src/lib.rs b/crates/trust-auth/src/lib.rs new file mode 100644 index 0000000..bd3fc09 --- /dev/null +++ b/crates/trust-auth/src/lib.rs @@ -0,0 +1,19 @@ +//! JWT bearer verification for Nodle resource servers consuming trust-relay tokens. +//! +//! Implements TOKEN-SPEC §7 steps 1–8 (signature, issuer, audience, time, scope). +//! Revocation and quota checks are out of scope for this crate (HTTP consume or Redis +//! replica in the adopting service). + +pub mod claims; +pub mod config; +pub mod error; +pub mod jwks; +pub mod verifier; + +#[cfg(feature = "axum")] +pub mod axum_ext; + +pub use claims::TokenClaims; +pub use config::VerifierConfig; +pub use error::AuthError; +pub use verifier::{AuthedWallet, TokenVerifier}; diff --git a/crates/trust-auth/src/verifier.rs b/crates/trust-auth/src/verifier.rs new file mode 100644 index 0000000..663d7e9 --- /dev/null +++ b/crates/trust-auth/src/verifier.rs @@ -0,0 +1,112 @@ +//! Token verification against cached JWKS. + +use std::sync::Arc; + +use jsonwebtoken::{decode, Validation}; + +use crate::{ + claims::{audience_contains, TokenClaims}, + config::VerifierConfig, + error::AuthError, + jwks::{parse_access_token_header, JwksCache}, +}; + +/// Verified caller identity extracted from a trust-relay access token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthedWallet { + pub address: String, + pub scopes: Vec, + pub jti: String, + pub issuer: String, + pub expires_at: i64, +} + +impl AuthedWallet { + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.iter().any(|s| s == scope) + } +} + +/// Resource-server JWT verifier (JWKS fetch + TOKEN-SPEC claim checks). +#[derive(Clone)] +pub struct TokenVerifier { + config: VerifierConfig, + jwks: JwksCache, +} + +impl TokenVerifier { + pub fn new(config: VerifierConfig) -> Result { + if !config.has_jwks_source() { + return Err(AuthError::InvalidToken( + "VerifierConfig requires jwks_url or jwks_json".into(), + )); + } + if config.issuers.is_empty() { + return Err(AuthError::InvalidToken( + "VerifierConfig requires at least one trusted issuer".into(), + )); + } + Ok(Self { + config: config.clone(), + jwks: JwksCache::new(config), + }) + } + + pub async fn warm_jwks(&self) -> Result<(), AuthError> { + self.jwks.ensure_loaded().await + } + + /// Verify a compact JWT and return the authenticated wallet (steps 2–7). + pub async fn verify(&self, token: &str) -> Result { + let (kid, alg) = parse_access_token_header(token)?; + let decoding_key = self.jwks.decoding_key_for(&kid, alg).await?; + let mut validation = Validation::new(alg); + let issuer_refs: Vec<&str> = self.config.issuers.iter().map(String::as_str).collect(); + validation.set_issuer(&issuer_refs); + validation.validate_aud = false; + validation.validate_exp = true; + validation.validate_nbf = true; + validation.set_required_spec_claims(&["exp", "nbf", "iat"]); + validation.leeway = self.config.clock_skew_secs; + + let data = decode::(token, &decoding_key, &validation) + .map_err(|e| AuthError::InvalidToken(format!("jwt verify failed: {e}")))?; + + if !audience_contains(&data.claims.aud, &self.config.audience) { + return Err(AuthError::InvalidToken(format!( + "aud does not contain {}", + self.config.audience + ))); + } + + Ok(AuthedWallet { + address: data.claims.sub, + scopes: data + .claims + .scope + .split_whitespace() + .map(str::to_string) + .collect(), + jti: data.claims.jti, + issuer: data.claims.iss, + expires_at: data.claims.exp, + }) + } + + /// Verify and require a scope (step 8). Returns `403` on missing scope. + pub async fn verify_scope(&self, token: &str, scope: &str) -> Result { + let wallet = self.verify(token).await?; + if wallet.has_scope(scope) { + Ok(wallet) + } else { + Err(AuthError::InsufficientScope(scope.to_string())) + } + } +} + +/// Shared verifier handle for Axum state. +pub type SharedVerifier = Arc; + +pub fn shared(verifier: TokenVerifier) -> SharedVerifier { + Arc::new(verifier) +} diff --git a/crates/trust-auth/tests/verify.rs b/crates/trust-auth/tests/verify.rs new file mode 100644 index 0000000..99835c8 --- /dev/null +++ b/crates/trust-auth/tests/verify.rs @@ -0,0 +1,102 @@ +//! End-to-end verify: sign like trust-relay, verify with trust-auth. + +use base64::Engine; +use ed25519_dalek::{pkcs8::EncodePrivateKey, SigningKey}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use rand::rngs::OsRng; +use serde::Serialize; +use time::OffsetDateTime; +use trust_auth::{TokenVerifier, VerifierConfig}; +use uuid::Uuid; + +#[derive(Serialize)] +struct Claims { + iss: String, + sub: String, + aud: String, + iat: i64, + nbf: i64, + exp: i64, + jti: String, + scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + chain_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ver: Option, +} + +fn mint_test_token(signing: &SigningKey, kid: &str, issuer: &str) -> (String, String) { + let x = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let jwks = format!( + r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","kid":"{kid}","use":"sig","alg":"EdDSA","x":"{x}"}}]}}"# + ); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let claims = Claims { + iss: issuer.into(), + sub: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".into(), + aud: "nodle-backend".into(), + iat: now, + nbf: now, + exp: now + 3600, + jti: Uuid::new_v4().to_string(), + scope: "ai:invoke scan:submit".into(), + chain_id: Some(324), + ver: Some(1), + }; + let der = signing.to_pkcs8_der().unwrap(); + let encoding_key = EncodingKey::from_ed_der(der.as_bytes()); + let mut header = Header::new(Algorithm::EdDSA); + header.typ = Some("at+jwt".to_string()); + header.kid = Some(kid.to_string()); + let token = encode(&header, &claims, &encoding_key).unwrap(); + (token, jwks) +} + +#[tokio::test] +async fn verifies_trust_relay_shaped_token() { + let signing = SigningKey::generate(&mut OsRng); + let issuer = "http://localhost:3001"; + let (token, jwks) = mint_test_token(&signing, "test-kid", issuer); + + let verifier = TokenVerifier::new( + VerifierConfig::new(vec![issuer.to_string()], "nodle-backend").jwks_json(jwks), + ) + .unwrap(); + + let wallet = verifier.verify(&token).await.unwrap(); + assert_eq!(wallet.address, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + assert!(wallet.has_scope("scan:submit")); + assert!(!wallet.has_scope("mint:request")); +} + +#[tokio::test] +async fn verify_scope_rejects_missing_scope() { + let signing = SigningKey::generate(&mut OsRng); + let issuer = "http://localhost:3001"; + let (token, jwks) = mint_test_token(&signing, "test-kid", issuer); + + let verifier = TokenVerifier::new( + VerifierConfig::new(vec![issuer.to_string()], "nodle-backend").jwks_json(jwks), + ) + .unwrap(); + + let err = verifier + .verify_scope(&token, "mint:request") + .await + .unwrap_err(); + assert!(matches!(err, trust_auth::AuthError::InsufficientScope(_))); +} + +#[tokio::test] +async fn rejects_wrong_issuer() { + let signing = SigningKey::generate(&mut OsRng); + let (token, jwks) = mint_test_token(&signing, "test-kid", "http://evil.example"); + + let verifier = TokenVerifier::new( + VerifierConfig::new(vec!["http://localhost:3001".into()], "nodle-backend").jwks_json(jwks), + ) + .unwrap(); + + assert!(verifier.verify(&token).await.is_err()); +} diff --git a/docs/TOKEN-SPEC.md b/docs/TOKEN-SPEC.md index f946bfb..44a1f1c 100644 --- a/docs/TOKEN-SPEC.md +++ b/docs/TOKEN-SPEC.md @@ -242,7 +242,7 @@ The `trust-auth` crate SHOULD expose an Axum extractor backed by a Tower layer: ```rust // Pseudocode of the intended ergonomics — not yet implemented. async fn invoke_ai(caller: AuthedWallet /* requires scope "ai:invoke" */) -> impl IntoResponse { - // caller.address, caller.scopes, caller.tier are verified and available. + // caller.address, caller.scopes are verified and available. } ``` diff --git a/src/models/claims.rs b/src/models/claims.rs index 2ebe709..7c87fae 100644 --- a/src/models/claims.rs +++ b/src/models/claims.rs @@ -2,27 +2,7 @@ use serde::{Deserialize, Serialize}; -/// Trust tier carried in the `tier` claim. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum TrustTier { - Wallet, - Attested, -} - -/// Attestation metadata (`att` claim) when `tier == "attested"`. -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct AttestationMetadata { - pub device_address: String, - pub key_type: String, - pub pubkey_hash: String, - pub app_id: String, - pub app_version: String, - pub attester: String, - pub anchor_block: u64, -} - -/// Access-token payload for wallet- and attested-tier sessions. +/// Access-token payload (wallet-tier; `tier`/`att` reserved and not emitted). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AccessTokenClaims { pub iss: String, @@ -33,9 +13,6 @@ pub struct AccessTokenClaims { pub exp: i64, pub jti: String, pub scope: String, - pub tier: TrustTier, - #[serde(skip_serializing_if = "Option::is_none")] - pub att: Option, #[serde(skip_serializing_if = "Option::is_none")] pub chain_id: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/models/mod.rs b/src/models/mod.rs index e89fd15..23b7a52 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,4 +2,4 @@ pub mod claims; -pub use claims::{AccessTokenClaims, AttestationMetadata, TrustTier}; +pub use claims::AccessTokenClaims; diff --git a/src/services/token.rs b/src/services/token.rs index 27da6db..3423867 100644 --- a/src/services/token.rs +++ b/src/services/token.rs @@ -8,11 +8,7 @@ use serde_json::{json, Value}; use time::OffsetDateTime; use uuid::Uuid; -use crate::{ - config::SigningConfig, - error::AppError, - models::claims::{AccessTokenClaims, TrustTier}, -}; +use crate::{config::SigningConfig, error::AppError, models::claims::AccessTokenClaims}; pub struct TokenService { verifying_key: VerifyingKey, @@ -75,8 +71,6 @@ impl TokenService { exp, jti: Uuid::new_v4().to_string(), scope: scopes.join(" "), - tier: TrustTier::Wallet, - att: None, chain_id: Some(chain_id), ver: Some(1), }; @@ -192,7 +186,6 @@ mod tests { .unwrap(); let claims = svc.verify(&token).unwrap(); assert_eq!(claims.sub, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); - assert_eq!(claims.tier, TrustTier::Wallet); assert_eq!(claims.scope, "ai:invoke mint:request"); assert_eq!(claims.chain_id, Some(324)); assert_eq!(claims.ver, Some(1)); diff --git a/tests/session.rs b/tests/session.rs index 363a84d..f7cca4d 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -13,9 +13,7 @@ use http_body_util::BodyExt; use rand::rngs::OsRng; use serde_json::json; use tower::ServiceExt; -use trust_relay::{ - models::claims::TrustTier, routes::test_router_with_config, services::token::TokenService, -}; +use trust_relay::{routes::test_router_with_config, services::token::TokenService}; fn stable_test_config() -> trust_relay::config::Config { let mut config = trust_relay::config::Config::load().expect("config"); @@ -83,7 +81,7 @@ async fn session_mints_wallet_tier_jwt() { let token_svc = TokenService::new(signing).unwrap(); let claims = token_svc.verify(token).unwrap(); assert_eq!(claims.sub, TEST_WALLET); - assert_eq!(claims.tier, TrustTier::Wallet); + assert!(!claims.scope.is_empty()); } #[tokio::test] From 216f1b866b0e22c23e2a1d60c5fb05dc6c64e1ff Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 5 Jun 2026 18:55:54 +1200 Subject: [PATCH 2/2] Add trust-auth live E2E test and align SIWE nonce format. Run trust-relay and verify session JWTs through trust-auth in CI; issue hyphen-free alphanumeric nonces so EIP-4361 parsers (siwe npm) accept them. --- .github/workflows/ci.yml | 17 +++++ Cargo.lock | 2 + Cargo.toml | 2 + crates/trust-auth/src/jwks.rs | 6 +- src/services/nonce.rs | 3 +- src/services/siwe/mod.rs | 8 +-- src/services/siwe/parse.rs | 26 ++++++-- tests/config.rs | 7 +- tests/nonce.rs | 5 ++ tests/siwe_conformance.rs | 12 ---- tests/trust_auth_e2e.rs | 118 ++++++++++++++++++++++++++++++++++ 11 files changed, 178 insertions(+), 28 deletions(-) create mode 100644 tests/trust_auth_e2e.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f76d38..3c92eaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,3 +147,20 @@ jobs: run: | echo "::error::Coverage is below the required threshold (line >= ${MIN_LINE_COVERAGE}%, region >= ${MIN_REGION_COVERAGE}%)." exit 1 + + trust-auth-e2e: + name: trust-relay ↔ trust-auth E2E + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci + cache-on-failure: true + + - name: Run live-server trust-auth integration test + run: cargo test --test trust_auth_e2e -- --nocapture diff --git a/Cargo.lock b/Cargo.lock index 88ae425..8a2f83c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2262,6 +2262,7 @@ dependencies = [ "k256", "rand 0.8.6", "redis", + "reqwest", "serde", "serde_json", "sha2", @@ -2274,6 +2275,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "trust-auth", "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 7c113c2..7470113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,3 +67,5 @@ http-body-util = "0.1" base64 = "0.22" ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +trust-auth = { path = "crates/trust-auth" } diff --git a/crates/trust-auth/src/jwks.rs b/crates/trust-auth/src/jwks.rs index cb74716..016f9a0 100644 --- a/crates/trust-auth/src/jwks.rs +++ b/crates/trust-auth/src/jwks.rs @@ -1,10 +1,6 @@ //! JWKS fetch and in-memory cache. -use std::{ - collections::HashMap, - sync::Arc, - time::Instant, -}; +use std::{collections::HashMap, sync::Arc, time::Instant}; use jsonwebtoken::{decode_header, Algorithm, DecodingKey}; use serde_json::Value; diff --git a/src/services/nonce.rs b/src/services/nonce.rs index 689a3e7..fff66a3 100644 --- a/src/services/nonce.rs +++ b/src/services/nonce.rs @@ -28,7 +28,8 @@ impl NonceService { } pub async fn issue(&self, client_ip: Option) -> Result { - let nonce = Uuid::new_v4().to_string(); + // EIP-4361 / Spruce SIWE ABNF: nonce must be alphanumeric (no UUID hyphens). + let nonce = Uuid::new_v4().as_simple().to_string(); let issued_at = OffsetDateTime::now_utc(); let expires_at = issued_at + time::Duration::seconds(self.config.nonce_ttl_secs as i64); let record = NonceRecord { diff --git a/src/services/siwe/mod.rs b/src/services/siwe/mod.rs index bc042c4..4caec88 100644 --- a/src/services/siwe/mod.rs +++ b/src/services/siwe/mod.rs @@ -89,20 +89,20 @@ mod tests { #[tokio::test] async fn verify_accepts_valid_eoa_signature() { let auth = test_auth(); - let msg = build_message("localhost", "http://localhost:3001", 324, "test-nonce-1"); + let msg = build_message("localhost", "http://localhost:3001", 324, "testnonce01"); let sig = sign_message(&msg); let verified = verify_siwe_session(&auth, &msg, &sig).await.unwrap(); assert_eq!( verified.wallet_address, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" ); - assert_eq!(verified.nonce, "test-nonce-1"); + assert_eq!(verified.nonce, "testnonce01"); } #[tokio::test] async fn verify_rejects_wrong_chain() { let auth = test_auth(); - let msg = build_message("localhost", "http://localhost:3001", 1, "test-nonce-1"); + let msg = build_message("localhost", "http://localhost:3001", 1, "testnonce01"); let sig = sign_message(&msg); let err = verify_siwe_session(&auth, &msg, &sig).await.unwrap_err(); assert!(matches!(err, AppError::InvalidRequest(_))); @@ -111,7 +111,7 @@ mod tests { #[tokio::test] async fn verify_rejects_bad_signature() { let auth = test_auth(); - let msg = build_message("localhost", "http://localhost:3001", 324, "test-nonce-1"); + let msg = build_message("localhost", "http://localhost:3001", 324, "testnonce01"); let err = verify_siwe_session(&auth, &msg, "0x00").await.unwrap_err(); assert!( matches!( diff --git a/src/services/siwe/parse.rs b/src/services/siwe/parse.rs index afa58fd..dbc5f9b 100644 --- a/src/services/siwe/parse.rs +++ b/src/services/siwe/parse.rs @@ -12,7 +12,16 @@ use crate::error::AppError; const MIN_NONCE_LEN: usize = 8; -/// Parse an EIP-4361 message string. +fn validate_nonce(nonce: &str) -> Result<(), AppError> { + if nonce.len() < MIN_NONCE_LEN { + return Err(parse_err("nonce must be at least 8 characters")); + } + if !nonce.bytes().all(|b| b.is_ascii_alphanumeric()) { + return Err(parse_err("nonce must be alphanumeric")); + } + Ok(()) +} + pub fn parse_siwe_message(s: &str) -> Result { let s = s.trim(); if s.is_empty() { @@ -57,9 +66,7 @@ pub fn parse_siwe_message(s: &str) -> Result { .map_err(|_| parse_err("invalid chain ID"))?; let nonce = parse_tagged_line(NONCE_TAG, lines.next())?; - if nonce.len() < MIN_NONCE_LEN { - return Err(parse_err("nonce must be at least 8 characters")); - } + validate_nonce(&nonce)?; let issued_at = parse_timestamp_tag(IAT_TAG, lines.next())?; @@ -248,11 +255,20 @@ mod tests { use super::*; #[test] - fn parses_uuid_nonce() { + fn rejects_hyphenated_nonce() { let nonce = "550e8400-e29b-41d4-a716-446655440000"; let msg = format!( "localhost wants you to sign in with your Ethereum account:\n0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\n\n\nURI: http://localhost:3001\nVersion: 1\nChain ID: 324\nNonce: {nonce}\nIssued At: 2021-09-30T16:25:24Z" ); + assert!(parse_siwe_message(&msg).is_err()); + } + + #[test] + fn parses_simple_uuid_nonce() { + let nonce = "550e8400e29b41d4a716446655440000"; + let msg = format!( + "localhost wants you to sign in with your Ethereum account:\n0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\n\n\nURI: http://localhost:3001\nVersion: 1\nChain ID: 324\nNonce: {nonce}\nIssued At: 2021-09-30T16:25:24Z" + ); let parsed = parse_siwe_message(&msg).unwrap(); assert_eq!(parsed.nonce, nonce); } diff --git a/tests/config.rs b/tests/config.rs index ffd0547..e4df9c9 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -2,7 +2,12 @@ use trust_relay::config::Config; #[test] fn load_default_config() { - for key in ["APP_ENV", "APP_SERVER__PORT", "APP_SERVER__HOST"] { + for key in [ + "APP_ENV", + "APP_SERVER__PORT", + "APP_SERVER__HOST", + "APP_TELEMETRY__FORMAT", + ] { std::env::remove_var(key); } diff --git a/tests/nonce.rs b/tests/nonce.rs index 6299931..ea61074 100644 --- a/tests/nonce.rs +++ b/tests/nonce.rs @@ -20,6 +20,11 @@ async fn get_nonce_returns_uuid_and_expiry() { .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["nonce"].as_str().unwrap().len() >= 8); + assert!(json["nonce"] + .as_str() + .unwrap() + .bytes() + .all(|b| b.is_ascii_alphanumeric())); assert!(json["expiresAt"].as_str().is_some()); } diff --git a/tests/siwe_conformance.rs b/tests/siwe_conformance.rs index 17bb058..d79dbd5 100644 --- a/tests/siwe_conformance.rs +++ b/tests/siwe_conformance.rs @@ -15,9 +15,6 @@ const PARSING_NEGATIVE: &str = include_str!("fixtures/siwe/parsing_negative.json const VERIFICATION_POSITIVE: &str = include_str!("fixtures/siwe/verification_positive.json"); const VERIFICATION_NEGATIVE: &str = include_str!("fixtures/siwe/verification_negative.json"); -/// trust-relay intentionally accepts UUID nonces (hyphens) from `GET /nonce`. -const NEGATIVE_ORACLE_EXCEPTIONS: &[&str] = &["nonce with non-alphanumeric characters"]; - #[test] fn parsing_positive_matches_reference() { let tests: Value = serde_json::from_str(PARSING_POSITIVE).unwrap(); @@ -41,15 +38,6 @@ fn parsing_negative_rejects_like_reference() { let ref_err = raw.parse::().is_err(); let our_err = parse_siwe_message(raw).is_err(); - if NEGATIVE_ORACLE_EXCEPTIONS.contains(&name.as_str()) { - assert!(ref_err, "oracle should reject {name}"); - assert!( - !our_err, - "trust-relay allows non-alphanumeric nonces (e.g. UUID) in SIWE" - ); - continue; - } - assert_eq!( ref_err, our_err, "parse mismatch for negative case {name}: ref_err={ref_err} our_err={our_err}" diff --git a/tests/trust_auth_e2e.rs b/tests/trust_auth_e2e.rs new file mode 100644 index 0000000..3b82ddc --- /dev/null +++ b/tests/trust_auth_e2e.rs @@ -0,0 +1,118 @@ +//! Live-server E2E: trust-relay mints a session JWT; trust-auth verifies it via JWKS. + +mod common; + +use std::net::TcpListener; +use std::time::Duration; + +use base64::Engine; +use common::{build_siwe_message, sign_siwe_message, TEST_WALLET}; +use ed25519_dalek::SigningKey; +use rand::rngs::OsRng; +use trust_auth::{TokenVerifier, VerifierConfig}; +use trust_relay::{config::Config, server, state::AppState}; + +fn reserve_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .expect("reserve port") + .local_addr() + .expect("local addr") + .port() +} + +fn stable_server_config(port: u16) -> Config { + let mut config = Config::load().expect("config"); + let key = SigningKey::generate(&mut OsRng); + config.signing.key_seed_b64 = base64::engine::general_purpose::STANDARD.encode(key.to_bytes()); + config.server.host = "127.0.0.1".into(); + config.server.port = port; + let base = format!("http://127.0.0.1:{port}"); + config.signing.issuer = base.clone(); + config.auth.uri = base; + config.auth.domain = "localhost".into(); + config +} + +async fn wait_for_port(port: u16) { + for _ in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("server did not start on port {port}"); +} + +async fn spawn_server(config: Config) -> u16 { + let port = config.server.port; + tokio::spawn(async move { + let listener = server::bind(&config).await.expect("bind"); + let state = AppState::build(config).await.expect("app state"); + server::serve(listener, state).await.expect("serve"); + }); + wait_for_port(port).await; + port +} + +#[tokio::test] +async fn trust_auth_verifies_live_trust_relay_session_jwt() { + let port = reserve_port(); + let config = stable_server_config(port); + let issuer = config.signing.issuer.clone(); + let audience = config.signing.audience.clone(); + spawn_server(config).await; + + let base = format!("http://127.0.0.1:{port}"); + let client = reqwest::Client::new(); + + let nonce: String = client + .get(format!("{base}/v1/auth/nonce")) + .send() + .await + .expect("nonce request") + .error_for_status() + .expect("nonce status") + .json::() + .await + .expect("nonce json")["nonce"] + .as_str() + .expect("nonce field") + .to_string(); + + let message = build_siwe_message("localhost", &base, 324, TEST_WALLET, &nonce); + let signature = sign_siwe_message(&message); + + let session: serde_json::Value = client + .post(format!("{base}/v1/auth/session")) + .json(&serde_json::json!({ "message": message, "signature": signature })) + .send() + .await + .expect("session request") + .error_for_status() + .expect("session status") + .json() + .await + .expect("session json"); + + let access_token = session["accessToken"] + .as_str() + .expect("accessToken") + .to_string(); + assert_eq!(session["tokenType"], "Bearer"); + assert_eq!(session["walletAddress"], TEST_WALLET); + + let verifier = TokenVerifier::new( + VerifierConfig::new(vec![issuer], audience) + .jwks_url(format!("{base}/.well-known/jwks.json")), + ) + .expect("verifier config"); + verifier.warm_jwks().await.expect("jwks warm"); + + let wallet = verifier.verify(&access_token).await.expect("verify jwt"); + assert_eq!(wallet.address, TEST_WALLET); + assert!(wallet.has_scope("scan:submit")); + assert!(wallet.has_scope("ai:invoke")); +}