Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ jobs:
- name: aws_lc_rs
# Opt-in constant-time backend (aws-lc, C FFI); disables the default.
flags: "--no-default-features --features aws_lc_rs,redis"
- name: injected_verifier
# No built-in verifier at all: the build a consumer takes when it
# injects its own `hooks::TokenVerifier`. Links no JWT crypto (and
# so no `rsa`), which is only true as long as nothing outside the
# `builtin_jwt` gate reaches for jsonwebtoken — this leg is what
# keeps that honest.
flags: "--no-default-features --features redis"
steps:
- uses: actions/checkout@v7
with:
Expand Down
31 changes: 22 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ ipnet = "2"
redis = { version = "1.2", features = ["tokio-comp", "connection-manager"], optional = true }

# Auth (JWT validation, route policies).
# Crypto backend is selected via this crate's `rust_crypto` / `aws_lc_rs`
# features (see [features] below); `use_pem` is always required for PEM key
# parsing and is therefore enabled unconditionally.
jsonwebtoken = { version = "11", default-features = false, features = ["use_pem"] }
# Optional: it backs the BUILT-IN verifier only. A consumer that injects its own
# `hooks::TokenVerifier` builds with `default-features = false` and links no JWT
# crypto at all. Crypto backend is selected via this crate's `rust_crypto` /
# `aws_lc_rs` features (see [features] below); `use_pem` is always required for
# PEM key parsing and is therefore enabled unconditionally.
jsonwebtoken = { version = "11", default-features = false, features = [
"use_pem",
], optional = true }
# reqwest 0.13's `rustls` feature hardwires the aws-lc-rs provider (C FFI, awkward
# for the musl static build). We keep the pure-Rust `ring` provider instead:
# `rustls-no-provider` builds rustls without a bundled provider, and auth/jwks.rs
Expand All @@ -103,18 +107,27 @@ envoy-types = "0.7"
[features]
default = ["rust_crypto"]

# JWT crypto backend. Mutually exclusive: enable exactly one. jsonwebtoken
# picks its provider from these features and panics at runtime if both (or
# neither) are on, so do NOT build with `--all-features`.
# The built-in JWT verifier (config-driven keys: PEM file or JWKS endpoint),
# implemented with `jsonwebtoken`. Implied by both crypto backends below, so it
# is never enabled on its own. With neither backend the crate links no JWT
# crypto: `auth.mode: "jwt"` then requires a verifier injected through
# `ProxyServer::with_token_verifier`.
builtin_jwt = ["dep:jsonwebtoken"]

# Crypto backend for the BUILT-IN verifier. Mutually exclusive: enable at most
# one. jsonwebtoken picks its provider from these features and panics at runtime
# if both are on, so do NOT build with `--all-features`. They no longer decide
# for the whole dependency graph: a consumer that cannot accept this crate's
# choice supplies its own verifier instead (see `builtin_jwt` above).
#
# `rust_crypto` (default): pure-Rust RustCrypto backend. Pulls in `rsa`, which
# carries RUSTSEC-2023-0071 (Marvin Attack). Not exploitable on our verify-only
# path (public key, no private-key ops); see deny.toml / issue #48.
rust_crypto = ["jsonwebtoken/rust_crypto"]
rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto"]
# `aws_lc_rs`: constant-time / FIPS-capable backend via aws-lc (C FFI),
# advisory-free. Opt-in for consumers that allow FFI; enable with
# `default-features = false, features = ["aws_lc_rs"]`.
aws_lc_rs = ["jsonwebtoken/aws_lc_rs"]
aws_lc_rs = ["builtin_jwt", "jsonwebtoken/aws_lc_rs"]

# Shared Redis-backed rate-limit store for multi-instance deployments.
redis = ["dep:redis"]
Expand Down
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Works with **any** gRPC service via proto descriptor files. No code generation,
- **Prometheus metrics** at `/metrics`
- **CORS** with a configurable origin allow-list
- **Rate limiting (Shield)**: local GCRA shaper (no blocking latency) keyed by client IP, header, or validated JWT claim; named limit tiers as config data; optional async cross-instance reconciliation for an approximate fleet-wide limit (requires both the `redis` feature and a configured `sync` block)
- **JWT auth**: validate `Bearer` tokens via an Ed25519 PEM key or JWKS auto-discovery, enforce per-route `require_auth` / `required_roles`, and forward claims as headers
- **JWT auth**: validate `Bearer` tokens via an Ed25519 PEM key or JWKS auto-discovery, enforce per-route `require_auth` / `required_roles`, and forward claims as headers — or hand the signature check to your own verifier (a validated / FIPS module, an HSM) without changing anything else
- **OIDC discovery**: serve `/.well-known/openid-configuration` and a JWKS endpoint (Ed25519) built from config, to front an identity provider
- **Forward-auth**: a verification endpoint (`/auth/verify`) for a fronting proxy (nginx `auth_request`, Traefik `forwardAuth`) to delegate auth, returning the verified identity as headers
- **External AuthZ**: gate proxied requests through an Envoy ext_authz gRPC server (`envoy.service.auth.v3.Authorization/Check`), interoperating with OPA and any ext_authz server, with fail-open/closed control
Expand Down Expand Up @@ -316,12 +316,81 @@ The hooks are:
- **`with_auth_decider`** — an in-process forward-auth / PDP decision, run inline
on every proxied request and exposed at `/verify` (path configurable via
`with_verify_path`).
- **`with_token_verifier`** — replaces the built-in JWT signature check
(see [JWT verification](#jwt-verification)) while keeping the route policies,
the roles claim, and the claim→header forwarding.
- **`with_oidc_backend`** — backs the stateless OIDC surface (discovery, JWKS,
userinfo) with your key/client metadata; supersedes the config-driven static
discovery.
- **`with_extra_routes`** — registers extra stateless routes through a
framework-agnostic adapter (request parts in, response parts out).

## JWT verification

The bearer-token check sits behind the `TokenVerifier` hook. A build gets one of
two implementations.

**The built-in verifier** is what a plain config-driven deployment uses: keys
from `auth.jwt` (an Ed25519 PEM file or a JWKS endpoint), verified with
`jsonwebtoken`. Its crypto backend is a Cargo feature:

| Feature | Backend | Notes |
|---------|---------|-------|
| `rust_crypto` (default) | RustCrypto | Pure Rust. Pulls in `rsa`, which carries [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071); the Marvin attack targets private-key timing, and this path only verifies with public keys (see `deny.toml`). |
| `aws_lc_rs` | aws-lc | Constant-time / FIPS-capable, advisory-free, links aws-lc through C FFI. |

They are mutually exclusive, and enabling both is a compile error rather than a
runtime panic. Do **not** build with `--all-features`.

**An injected verifier** is what you supply when neither of those is the right
answer for your binary: a validated / FIPS crypto module, an HSM, or a verifier
your service already owns.

```rust
use std::sync::Arc;
use structured_proxy::hooks::TokenVerifier;
use structured_proxy::{config::ProxyConfig, ProxyServer};

struct MyVerifier; // your signature + claim check

#[async_trait::async_trait]
impl TokenVerifier for MyVerifier {
async fn verify(&self, token: &str) -> Option<serde_json::Value> {
// claims out, or None to reject the request with 401
# let _ = token;
None
}
}

# async fn run(config: ProxyConfig) -> anyhow::Result<()> {
ProxyServer::from_config(config)
.with_token_verifier(Arc::new(MyVerifier))
.serve()
.await
# }
```

Injection also resolves a problem the features cannot: Cargo unifies features
across the whole dependency graph, so `rust_crypto` / `aws_lc_rs` is a property
of the *resolution*, not of a binary. Two crates in one workspace that link this
one and want different backends cannot both get their way — and if both features
end up enabled, `jsonwebtoken` refuses the combination. A consumer that injects
its own verifier is not in that argument at all: it takes

```toml
[dependencies]
structured-proxy = { version = "3", default-features = false }
# What the verifier above is written with: the trait is `#[async_trait]`, and
# claims cross it as `serde_json::Value`. Neither is re-exported.
async-trait = "0.1"
serde_json = "1"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

which links no JWT crypto (and therefore no `rsa`), and supplies the backend
from its own binary. With no verifier injected and no backend feature, an
`auth.mode: "jwt"` config is rejected at startup with that instruction, rather
than silently accepting tokens.

## How It Works

1. Load the proto descriptor from a pre-compiled descriptor file
Expand Down
15 changes: 12 additions & 3 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@
# Chain: rsa -> jsonwebtoken (rust_crypto backend) -> structured-proxy.
#
# Ignored deliberately. See https://github.com/structured-world/structured-proxy/issues/48
# 1. No fix exists: latest `rsa` is 0.10.0-rc and the advisory has patched:[] /
# unaffected:[] in the RustSec DB. Nothing to upgrade to.
# 1. No fix exists: the advisory has patched:[] / unaffected:[] in the RustSec
# DB, and it names both the latest stable `rsa` (0.9.10) and the latest
# pre-release (0.10.0-rc.18) as affected. There is nothing to upgrade to,
# release candidates included.
# 2. We keep jsonwebtoken's pure-Rust `rust_crypto` backend over `aws_lc_rs`
# (C FFI), required by no-FFI consumers (e.g. CoordiNode ADR-013).
# 3. RSA here is used for JWT *verification* (public key) only. Marvin targets
# private-key timing (decrypt/sign), which never runs on the verify path.
# Revisit when RustCrypto ships a constant-time `rsa` stable release.
#
# The ignore covers the DEFAULT build only. `rsa` arrives with jsonwebtoken's
# whole `rust_crypto` bundle (it has no per-algorithm features), so it cannot be
# dropped by a feature of ours while that backend is in use. A deployment that
# will not carry the advisory at all builds with `default-features = false` and
# injects its own `hooks::TokenVerifier`: no jsonwebtoken, no `rsa`, nothing to
# ignore. The `injected_verifier` CI leg builds exactly that.
ignore = [
{ id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists (latest 0.10.0-rc; advisory patched:[]). We deliberately keep jsonwebtoken's pure-Rust `rust_crypto` backend over `aws_lc_rs` (C FFI). RSA is used for JWT verification (public key) only; Marvin targets private-key timing, not exploitable on the verify path. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" },
{ id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists: the advisory is patched:[] and names both the latest stable (0.9.10) and the latest pre-release (0.10.0-rc.18) as affected. We deliberately keep jsonwebtoken's pure-Rust `rust_crypto` backend over `aws_lc_rs` (C FFI). RSA is used for JWT verification (public key) only; Marvin targets private-key timing, not exploitable on the verify path. A build that must not link `rsa` at all takes default-features = false and injects its own TokenVerifier. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" },
]
6 changes: 4 additions & 2 deletions src/auth/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ fn forwarded(headers: &HeaderMap, names: &[&str]) -> Option<String> {
.map(str::to_string)
}

#[cfg(test)]
// The endpoint is exercised end to end against the built-in verifier, so these
// tests need a crypto backend; the `Auth` seam itself is covered in `tests.rs`.
#[cfg(all(test, feature = "builtin_jwt"))]
mod tests {
use super::*;
use crate::config::{AuthConfig, ForwardAuthConfig, JwtConfig, RoutePolicyConfig};
Expand Down Expand Up @@ -182,7 +184,7 @@ mod tests {
}),
authz: None,
};
let auth = Auth::build(&config).unwrap().unwrap();
let auth = Auth::build(&config, None).unwrap().unwrap();
ForwardAuth::build(&config, auth).unwrap()
}

Expand Down
17 changes: 1 addition & 16 deletions src/auth/jwks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,6 @@ const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
/// Bound the worst-case latency of a slow/stalled JWKS endpoint.
const JWKS_HTTP_TIMEOUT: Duration = Duration::from_secs(5);

/// Build the rustls client config for the JWKS HTTPS client.
///
/// Uses the pure-Rust `ring` provider (installed per-config, not process-global)
/// and bundles Mozilla's root store via `webpki-roots`, so the binary needs no
/// system CA bundle and works in musl / scratch / distroless images.
pub(crate) fn build_tls_config() -> rustls::ClientConfig {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.expect("ring provider supports the default TLS protocol versions")
.with_root_certificates(roots)
.with_no_client_auth()
}

impl JwksCache {
/// Create a cache for `uri` (keys are loaded lazily on first lookup).
pub fn new(uri: String) -> Self {
Expand All @@ -56,7 +41,7 @@ impl JwksCache {
// Hand reqwest a fully preconfigured rustls backend rather than
// relying on a process-global default provider: no install ordering
// constraint, no global side effect, safe for library/test callers.
.tls_backend_preconfigured(build_tls_config())
.tls_backend_preconfigured(crate::tls::client_config())
.build()
.unwrap_or_default();
Self {
Expand Down
Loading
Loading