From 81d9188201fcec200a3e55ae2fb0d2f4e676bbc6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 31 Oct 2026 19:49:18 +0200 Subject: [PATCH 1/2] fix(auth): leave the JWT provider to a host without a verifier A proxy installed a process-wide jsonwebtoken provider while being built, even when it verifies no token, so an application with its own token checks got the proxy's provider or lost the race to install its own. Only building the built-in verifier installs one now, and a provider installed first stands. The docs state which entry points are for an application that leaves authentication to the proxy. CI takes the current taiki-e/install-action v2. Closes #162 --- .github/workflows/ci.yml | 6 +++--- README.md | 26 +++++++++++++++++++++----- src/auth/crypto.rs | 10 +++++----- src/auth/crypto/tests.rs | 34 ++++++++++++++++++++++++++++++++++ src/auth/verifier.rs | 5 ++--- src/hooks.rs | 4 ++++ src/lib.rs | 16 +++++----------- 7 files changed, 74 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6259f04..87ede02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: # Runs each test in its own process, so a hang names the test that hung # instead of timing the whole job out. Third-party action: pinned to a # commit SHA (dependabot keeps it fresh). - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + - uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2 with: tool: cargo-nextest @@ -165,7 +165,7 @@ jobs: with: key: ${{ matrix.target }} # Third-party action: pinned to a commit SHA (dependabot keeps it fresh). - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + - uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2 with: tool: cargo-nextest @@ -204,7 +204,7 @@ jobs: # deny.toml. The released binary rather than the Docker action, whose # image build pulls from Docker Hub and hits its anonymous rate limit # on shared runners. - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + - uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2 with: tool: cargo-deny - name: Check advisories diff --git a/README.md b/README.md index c22136f..a0a2e9d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,9 @@ tonic services. capabilities you ask for ([Building the proxy in code](#building-the-proxy-in-code)) - Requests the proxy does not serve go to your own fallback service - Hooks for auth decisions, token verification, OIDC and extra routes, with no - HTTP framework in your code + HTTP framework in your code. The auth hooks are for applications that leave + authentication to the proxy and an external identity provider + ([Applications with their own authentication](#applications-with-their-own-authentication)) ## Non-goals @@ -973,6 +975,17 @@ returns `application/jwk-set+json` (RFC 7517 §8.5). command-line dependencies come with the `cli` feature. The library runs on your tokio runtime and logs through `tracing` to the subscriber you set up. +### Applications with their own authentication + +The proxy's auth (JWT verification, route policies and roles, claim→header +forwarding, the forward-auth endpoint, ext_authz and the `with_auth`, +`with_token_verifier` and `with_auth_decider` hooks) is for an application +that leaves authentication and authorization to the proxy and an external +identity provider. An application that verifies tokens and authorizes requests +itself embeds the proxy with none of it: no `auth:` section and none of those +hooks. Its own checks are the only ones on the request path, and the proxy +installs no `jsonwebtoken` provider in its process. + ```rust,no_run use std::path::Path; use structured_proxy::ProxyServer; @@ -1343,12 +1356,15 @@ from `auth.jwt` (an Ed25519 PEM file or a JWKS endpoint), verified with | `aws_lc_rs` | aws-lc | Constant-time / FIPS-capable, advisory-free, links aws-lc through C FFI. | Both can be linked at once, as with any pair of Cargo features. `jsonwebtoken` -cannot infer a provider then, so `ProxyServer::from_config` installs `aws_lc_rs` -for the process: it is constant-time and carries no advisory. A build that wants -RustCrypto regardless drops the `aws_lc_rs` feature. +cannot infer a provider then, so building the built-in verifier installs +`aws_lc_rs` for the process, unless the process installed a provider first: it +is constant-time and carries no advisory. A build that wants RustCrypto +regardless drops the `aws_lc_rs` feature. A proxy without `auth.mode: "jwt"`, or +with an injected verifier, installs no `jsonwebtoken` provider: that choice +stays with your application. If another crate in your process uses `jsonwebtoken` too, it may reach it before -any proxy server is built, and would hit the same ambiguity. It can also turn on +the built-in verifier is built, and would hit the same ambiguity. It can also turn on the other backend for `jsonwebtoken` directly, which leaves this crate looking single-backend while `jsonwebtoken` sees two. Settle it once at the top of `main`: diff --git a/src/auth/crypto.rs b/src/auth/crypto.rs index 016b782..4d3ac0b 100644 --- a/src/auth/crypto.rs +++ b/src/auth/crypto.rs @@ -35,11 +35,11 @@ pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvi /// JWT. It is idempotent, and installs the backend this crate was built with, /// so `jsonwebtoken` never has to infer one. /// -/// A plain [`ProxyServer`](crate::ProxyServer) deployment needs no call: the -/// server does this while it is being built. It is public for the case the -/// server cannot cover, which is also how the ambiguity arises in the first -/// place: another crate in the graph uses `jsonwebtoken` too and may reach it -/// first. Call this at the top of `main` and every consumer is covered, +/// A plain [`ProxyServer`](crate::ProxyServer) deployment needs no call: building +/// the built-in verifier does this, and a proxy without one installs nothing. It +/// is public for the case the proxy cannot cover, which is also how the ambiguity +/// arises in the first place: another crate in the graph uses `jsonwebtoken` too +/// and may reach it first. Call this at the top of `main` and every consumer is covered, /// whichever runs first. /// /// With both backends linked the choice is `aws_lc_rs`: constant-time, and free diff --git a/src/auth/crypto/tests.rs b/src/auth/crypto/tests.rs index 43640b2..2cb3e81 100644 --- a/src/auth/crypto/tests.rs +++ b/src/auth/crypto/tests.rs @@ -48,3 +48,37 @@ fn installing_twice_is_harmless() { super::install_default_crypto_provider(); super::install_default_crypto_provider(); } + +/// A proxy without `auth:` uses no JWT and leaves the process-wide jsonwebtoken +/// provider to the host: an application with its own token checks must keep +/// its own choice. The probe install succeeds only if nothing installed one +/// first (each test runs in its own process). +#[test] +fn a_proxy_without_auth_installs_no_jwt_provider() { + let service = crate::ProxyServer::from_yaml_str("service:\n name: demo\n") + .unwrap() + .service(tonic::service::Routes::default()) + .unwrap(); + drop(service); + assert!( + super::preferred_provider().install_default().is_ok(), + "building a proxy without auth must not install a jsonwebtoken provider" + ); +} + +/// The built-in verifier, once built, has the provider it verifies with. +#[test] +fn building_the_builtin_verifier_installs_the_provider() { + let jwt = crate::config::JwtConfig { + jwks_uri: Some("https://issuer.example/jwks".into()), + jwks_max_age_secs: 300, + issuer: None, + audience: None, + public_key_pem_file: None, + claims_headers: Default::default(), + roles_claim: "roles".into(), + cache: Default::default(), + }; + super::super::verifier::ConfigVerifier::build(&jwt).unwrap(); + assert!(super::preferred_provider().install_default().is_err()); +} diff --git a/src/auth/verifier.rs b/src/auth/verifier.rs index 61294d7..7ffe798 100644 --- a/src/auth/verifier.rs +++ b/src/auth/verifier.rs @@ -43,9 +43,8 @@ impl ConfigVerifier { /// max age is below the refresh interval, the PEM file cannot be read, or /// it is not a valid Ed25519 public key. pub(crate) fn build(jwt: &JwtConfig) -> Result { - // Normally settled in `ProxyServer::from_config` already; repeated here - // because a verifier can also be built without going through the - // server, and the call costs one atomic once the provider is in place. + // The only place the proxy settles the jsonwebtoken provider: a proxy + // that builds no built-in verifier leaves that choice to its host. super::crypto::install_default_crypto_provider(); let keys = if let Some(uri) = &jwt.jwks_uri { diff --git a/src/hooks.rs b/src/hooks.rs index 63ed7c2..fcbfd2e 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -8,6 +8,10 @@ //! `axum` type), so `cargo tree -i axum` in an embedder shows axum only under //! `structured-proxy`. //! +//! [`AuthDecider`] and [`TokenVerifier`] are for an application that leaves +//! authentication and authorization to the proxy; one with its own authn/authz +//! implements neither. +//! //! Stateful concerns (BFF sessions, OIDC `authorize`/`token`) are deliberately //! absent: the default build is a stateless data plane (see the crate README //! Non-goals). They are planned behind an opt-in `bff` feature. diff --git a/src/lib.rs b/src/lib.rs index 06b6aa7..ebc2d61 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,10 +21,11 @@ //! compiled in at once — Cargo features are additive, so a dependency graph //! with two dependents asking for different backends unifies into exactly that //! build. `aws_lc_rs` then wins: it is constant-time and free of the `rsa` -//! advisory `rust_crypto` carries. [`ProxyServer::from_config`] settles that -//! choice for the process; a process where another crate may reach -//! `jsonwebtoken` before any server exists calls -//! [`install_default_crypto_provider`] from `main` instead. +//! advisory `rust_crypto` carries. Building this verifier settles that choice +//! for the process; a process where another crate may reach `jsonwebtoken` +//! first calls [`install_default_crypto_provider`] from `main` instead. A +//! proxy with no built-in verifier installs nothing: that choice stays with +//! its host. //! - an **injected** one, supplied by the embedder through //! [`ProxyServer::with_token_verifier`]. Since Cargo unifies features across a //! whole dependency graph, a backend feature cannot be chosen per binary — @@ -163,13 +164,6 @@ impl Default for ProxyServer { impl ProxyServer { /// Create from YAML config file. pub fn from_config(config: ProxyConfig) -> Self { - // Earliest point this crate owns: settle the JWT crypto provider here, - // long before the first token arrives. A process whose other crates - // reach jsonwebtoken before any server exists calls - // `install_default_crypto_provider` from `main` instead. - #[cfg(feature = "builtin_jwt")] - auth::crypto::install_default_crypto_provider(); - Self { config, descriptor_pool: None, From 57831bfa296ebef1ebb7dddd257042710f76587a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 31 Oct 2026 19:55:35 +0200 Subject: [PATCH 2/2] docs(auth): scope own authentication to what reaches the service An application's checks never see the proxy's own routes, so they need a guard of their own or must stay off. Part of #162 --- README.md | 11 ++++++++--- src/hooks.rs | 8 +++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a0a2e9d..ea0108a 100644 --- a/README.md +++ b/README.md @@ -982,9 +982,14 @@ forwarding, the forward-auth endpoint, ext_authz and the `with_auth`, `with_token_verifier` and `with_auth_decider` hooks) is for an application that leaves authentication and authorization to the proxy and an external identity provider. An application that verifies tokens and authorizes requests -itself embeds the proxy with none of it: no `auth:` section and none of those -hooks. Its own checks are the only ones on the request path, and the proxy -installs no `jsonwebtoken` provider in its process. +itself needs none of it for the requests that reach its service: no `auth:` +section, and the proxy installs no `jsonwebtoken` provider in its process. + +Those checks cover only what reaches the service. The proxy's own routes +(health, metrics, OpenAPI, OIDC, extra routes) never do, so each needs a guard +of its own or must stay off: `with_auth_decider` with a +`with_auth_decider_scope` that takes in `endpoints` is the proxy-side option +(see [Guards and scopes](#guards-and-scopes)). ```rust,no_run use std::path::Path; diff --git a/src/hooks.rs b/src/hooks.rs index fcbfd2e..dd6d5f3 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -8,9 +8,11 @@ //! `axum` type), so `cargo tree -i axum` in an embedder shows axum only under //! `structured-proxy`. //! -//! [`AuthDecider`] and [`TokenVerifier`] are for an application that leaves -//! authentication and authorization to the proxy; one with its own authn/authz -//! implements neither. +//! [`AuthDecider`] and [`TokenVerifier`] are for traffic whose authentication +//! and authorization are left to the proxy. An application with its own +//! authn/authz needs neither for requests that reach its service; its checks +//! never see the proxy's own routes, which take an [`AuthDecider`] scoped to +//! them or stay off. //! //! Stateful concerns (BFF sessions, OIDC `authorize`/`token`) are deliberately //! absent: the default build is a stateless data plane (see the crate README