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..ea0108a 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,22 @@ 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 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; use structured_proxy::ProxyServer; @@ -1343,12 +1361,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..dd6d5f3 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -8,6 +8,12 @@ //! `axum` type), so `cargo tree -i axum` in an embedder shows axum only under //! `structured-proxy`. //! +//! [`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 //! 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,