Skip to main content

structured_proxy/auth/
mod.rs

1//! JWT authentication and route-level authorization.
2//!
3//! Validates `Authorization: Bearer` JWTs, enforces per-route policies
4//! (`require_auth` / `required_roles`), and forwards selected claims to the
5//! upstream as request headers. Active only when `auth.mode == "jwt"`.
6//!
7//! Verification itself sits behind [`TokenVerifier`]: by default the built-in
8//! one (keys from `auth.jwt` — an Ed25519 PEM file or a JWKS endpoint, checked
9//! with `jsonwebtoken`), or an embedder-supplied one injected through
10//! [`ProxyServer::with_token_verifier`](crate::ProxyServer::with_token_verifier).
11//! Everything else here — policies, roles, claim headers — is independent of
12//! which one verified the token.
13
14pub mod authz;
15#[cfg(feature = "builtin_jwt")]
16mod cache;
17#[cfg(feature = "builtin_jwt")]
18pub mod crypto;
19pub mod forward;
20#[cfg(feature = "builtin_jwt")]
21pub mod jwks;
22pub mod policy;
23#[cfg(feature = "builtin_jwt")]
24mod verifier;
25
26#[cfg(test)]
27mod tests;
28
29use std::collections::HashMap;
30use std::collections::HashSet;
31use std::sync::Arc;
32
33use axum::extract::State;
34use axum::http::header::{HeaderName, HeaderValue};
35use axum::http::{HeaderMap, StatusCode};
36use axum::middleware::Next;
37use axum::response::{IntoResponse, Response};
38use axum::Json;
39use serde_json::Value;
40
41use crate::config::{default_roles_claim, AuthConfig};
42use crate::hooks::TokenVerifier;
43use policy::Policies;
44
45/// Which implementation checks a token's signature and claims.
46enum Verifier {
47    /// The built-in one, called directly: the default path pays no dynamic
48    /// dispatch and allocates no boxed future per verification. Boxed only to
49    /// keep the enum small — it holds an inline JWKS cache, and this costs one
50    /// pointer hop at build time, not per request. The claims cache
51    /// (`auth.jwt.cache`) sits in front of it, `None` when switched off.
52    #[cfg(feature = "builtin_jwt")]
53    Builtin(Box<verifier::ConfigVerifier>, Option<cache::ClaimsCache>),
54    /// The embedder's, behind the public hook.
55    Injected(Arc<dyn TokenVerifier>),
56}
57
58/// Compiled auth configuration: the verifier, the claims to forward, and the
59/// route policies.
60pub struct Auth {
61    verifier: Verifier,
62    claims_headers: HashMap<String, String>,
63    roles_claim: String,
64    policies: Policies,
65    /// Signature checks the built-in verifier ran, so tests can tell a cache
66    /// hit from a second verification.
67    #[cfg(all(test, feature = "builtin_jwt"))]
68    builtin_verifications: std::sync::atomic::AtomicUsize,
69}
70
71impl Auth {
72    /// Build auth from config, or `None` when `auth.mode` is not `"jwt"`.
73    ///
74    /// `verifier` is the embedder-supplied token verifier, if any. With `None`
75    /// the built-in one is built from `auth.jwt`, which then must name a key
76    /// source. With `Some`, the key source in config is unused (the verifier
77    /// owns its own keys) and `auth.jwt` may be omitted entirely; the rest of
78    /// the block (`claims_headers`, `roles_claim`) still applies.
79    ///
80    /// # Errors
81    /// Returns an error string when the built-in verifier is required but this
82    /// build has no crypto backend, when its key source is missing or unusable,
83    /// or when a policy glob fails to compile.
84    pub fn build(
85        config: &AuthConfig,
86        verifier: Option<Arc<dyn TokenVerifier>>,
87    ) -> Result<Option<Arc<Self>>, String> {
88        if config.mode != "jwt" {
89            return Ok(None);
90        }
91
92        let verifier = match verifier {
93            Some(v) => {
94                if let Some(jwt) = &config.jwt {
95                    if jwt.jwks_uri.is_some() || jwt.public_key_pem_file.is_some() {
96                        tracing::warn!(
97                            "auth.jwt names a key source, but an injected TokenVerifier is in use; \
98                             the configured keys are ignored"
99                        );
100                    }
101                }
102                Verifier::Injected(v)
103            }
104            None => builtin_verifier(config)?,
105        };
106
107        let policies = match &config.forward_auth {
108            Some(fa) => Policies::compile(&fa.policies)?,
109            None => Policies::default(),
110        };
111
112        // With an injected verifier `auth.jwt` is optional, so the claim
113        // forwarding settings fall back to the same defaults the deserializer
114        // would have applied.
115        let (claims_headers, roles_claim) = match &config.jwt {
116            Some(jwt) => (jwt.claims_headers.clone(), jwt.roles_claim.clone()),
117            None => (HashMap::new(), default_roles_claim()),
118        };
119
120        Ok(Some(Arc::new(Self {
121            verifier,
122            claims_headers,
123            roles_claim,
124            policies,
125            #[cfg(all(test, feature = "builtin_jwt"))]
126            builtin_verifications: std::sync::atomic::AtomicUsize::new(0),
127        })))
128    }
129
130    /// Verify a token and return its claims, or `None` if invalid. The
131    /// built-in verifier answers a token it accepted before from its cache.
132    async fn verify(&self, token: &str) -> Option<Arc<Value>> {
133        match &self.verifier {
134            #[cfg(feature = "builtin_jwt")]
135            Verifier::Builtin(v, Some(cache)) if cache.admits(token) => {
136                let key = cache::token_key(token);
137                let now = cache.now();
138                if let Some(claims) = cache.get(&key, now) {
139                    return Some(claims);
140                }
141                let claims = Arc::new(self.verify_builtin(v, token).await?);
142                cache.insert(key, &claims, now);
143                Some(claims)
144            }
145            #[cfg(feature = "builtin_jwt")]
146            Verifier::Builtin(v, _) => self.verify_builtin(v, token).await.map(Arc::new),
147            Verifier::Injected(v) => v.verify(token).await.map(Arc::new),
148        }
149    }
150
151    #[cfg(feature = "builtin_jwt")]
152    async fn verify_builtin(&self, v: &verifier::ConfigVerifier, token: &str) -> Option<Value> {
153        #[cfg(test)]
154        self.builtin_verifications
155            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
156        v.verify(token).await
157    }
158
159    /// Number of cached verifications, for tests.
160    #[cfg(all(test, feature = "builtin_jwt"))]
161    fn cached_tokens(&self) -> usize {
162        match &self.verifier {
163            Verifier::Builtin(_, Some(cache)) => cache.len(),
164            _ => 0,
165        }
166    }
167}
168
169/// The built-in verifier, built from `auth.jwt`.
170#[cfg(feature = "builtin_jwt")]
171fn builtin_verifier(config: &AuthConfig) -> Result<Verifier, String> {
172    let jwt = config
173        .jwt
174        .as_ref()
175        .ok_or("auth.mode is \"jwt\" but auth.jwt is not set")?;
176    Ok(Verifier::Builtin(
177        Box::new(verifier::ConfigVerifier::build(jwt)?),
178        cache::ClaimsCache::build(&jwt.cache)?,
179    ))
180}
181
182/// Without a crypto backend there is no built-in verifier to build: a JWT
183/// deployment must inject one.
184#[cfg(not(feature = "builtin_jwt"))]
185fn builtin_verifier(_config: &AuthConfig) -> Result<Verifier, String> {
186    Err(
187        "auth.mode is \"jwt\" but this build has no JWT crypto backend: enable the \
188         `rust_crypto` or `aws_lc_rs` feature, or inject a verifier with \
189         ProxyServer::with_token_verifier"
190            .to_string(),
191    )
192}
193
194/// The outcome of an auth check for a request.
195pub(crate) enum AuthDecision {
196    /// Allowed; forward these (verified) claim headers to the upstream, and the
197    /// verified claims themselves (`None` for anonymous access) for downstream
198    /// consumers such as per-principal rate limiting.
199    Allow(HeaderMap, Option<Arc<Value>>),
200    /// Rejected: no/invalid credentials (HTTP 401).
201    Unauthenticated(&'static str),
202    /// Rejected: authenticated but lacking a required role (HTTP 403).
203    Forbidden(&'static str),
204}
205
206/// Verified JWT claims attached to the request by the auth middleware. Present
207/// only when a valid token was supplied, and set exclusively from a verified
208/// token (never from client input), so downstream consumers may safely key
209/// security decisions (e.g. rate limits) on it.
210#[derive(Clone)]
211pub(crate) struct ValidatedClaims(pub(crate) std::sync::Arc<Value>);
212
213impl Auth {
214    /// Evaluate auth for a request: validate the bearer token, apply the route
215    /// policy, and render the claim headers to forward. This is the single
216    /// source of truth shared by the middleware and the forward-auth endpoint.
217    pub(crate) async fn decide(
218        &self,
219        headers: &HeaderMap,
220        path: &str,
221        method: &str,
222    ) -> AuthDecision {
223        // A token that is present but invalid is always a 401, regardless of policy.
224        let claims = match bearer_token(headers) {
225            Some(token) => match self.verify(token).await {
226                Some(c) => Some(c),
227                None => return AuthDecision::Unauthenticated("invalid or expired token"),
228            },
229            None => None,
230        };
231
232        if let Some(policy) = self.policies.match_rule(path, method) {
233            if policy.require_auth && claims.is_none() {
234                return AuthDecision::Unauthenticated("authentication required");
235            }
236            if !policy.required_roles.is_empty() {
237                // An unauthenticated caller is told to authenticate (401), not
238                // that they lack a role (403).
239                let Some(claims) = claims.as_ref() else {
240                    return AuthDecision::Unauthenticated("authentication required");
241                };
242                let roles = extract_roles(claims, &self.roles_claim);
243                if !policy.required_roles.iter().all(|r| roles.contains(r)) {
244                    return AuthDecision::Forbidden("insufficient role");
245                }
246            }
247        }
248
249        let mut claim_headers = HeaderMap::new();
250        if let Some(claims) = &claims {
251            inject_claim_headers(&mut claim_headers, claims, &self.claims_headers);
252        }
253        AuthDecision::Allow(claim_headers, claims)
254    }
255}
256
257/// Axum middleware enforcing JWT auth and route policies.
258pub async fn middleware(
259    State(auth): State<Arc<Auth>>,
260    mut request: axum::extract::Request,
261    next: Next,
262) -> Response {
263    let path = request.uri().path().to_string();
264    let method = request.method().as_str().to_ascii_uppercase();
265
266    // Strip any client-supplied values for proxy-controlled claim headers, so a
267    // client can never forge them onto the upstream (only verified claims set
268    // them below).
269    strip_claim_headers(request.headers_mut(), &auth.claims_headers);
270
271    match auth.decide(request.headers(), &path, &method).await {
272        AuthDecision::Unauthenticated(msg) => unauthorized(msg),
273        AuthDecision::Forbidden(msg) => forbidden(msg),
274        AuthDecision::Allow(claim_headers, claims) => {
275            let dst = request.headers_mut();
276            for (name, value) in &claim_headers {
277                dst.insert(name.clone(), value.clone());
278            }
279            // Expose the verified claims to inner layers (e.g. per-principal rate
280            // limiting) as a typed extension a client cannot forge.
281            if let Some(claims) = claims {
282                request.extensions_mut().insert(ValidatedClaims(claims));
283            }
284            next.run(request).await
285        }
286    }
287}
288
289/// Extract the bearer token from the `Authorization` header.
290///
291/// Borrowed from the header, not copied: the token is read and dropped within
292/// the request's own auth decision, so there is nothing to own.
293fn bearer_token(headers: &HeaderMap) -> Option<&str> {
294    let value = headers.get("authorization")?.to_str().ok()?;
295    let token = value
296        .strip_prefix("Bearer ")
297        .or_else(|| value.strip_prefix("bearer "))?;
298    let token = token.trim();
299    (!token.is_empty()).then_some(token)
300}
301
302/// Resolve a (possibly dotted) claim path to a JSON value.
303fn claim_at<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
304    let mut cur = claims;
305    for seg in path.split('.') {
306        cur = cur.get(seg)?;
307    }
308    Some(cur)
309}
310
311/// Collect the caller's roles from the configured claim (an array of strings).
312fn extract_roles(claims: &Value, roles_claim: &str) -> HashSet<String> {
313    claim_at(claims, roles_claim)
314        .and_then(Value::as_array)
315        .map(|arr| {
316            arr.iter()
317                .filter_map(|v| v.as_str().map(str::to_string))
318                .collect()
319        })
320        .unwrap_or_default()
321}
322
323/// Remove any incoming values for the proxy-controlled claim headers, so a
324/// client cannot forge them onto the upstream.
325fn strip_claim_headers(headers: &mut HeaderMap, mapping: &HashMap<String, String>) {
326    for header in mapping.values() {
327        if let Ok(name) = HeaderName::try_from(header.as_str()) {
328            while headers.remove(&name).is_some() {}
329        }
330    }
331}
332
333/// Inject configured claims as request headers forwarded to the upstream.
334fn inject_claim_headers(
335    headers: &mut HeaderMap,
336    claims: &Value,
337    mapping: &HashMap<String, String>,
338) {
339    for (claim, header) in mapping {
340        let Some(value) = claim_at(claims, claim) else {
341            continue;
342        };
343        let rendered = match value {
344            Value::String(s) => s.clone(),
345            Value::Number(n) => n.to_string(),
346            Value::Bool(b) => b.to_string(),
347            // Skip arrays/objects/null: not meaningful as a single header value.
348            _ => continue,
349        };
350        if let (Ok(name), Ok(val)) = (
351            HeaderName::try_from(header.as_str()),
352            HeaderValue::try_from(rendered),
353        ) {
354            headers.insert(name, val);
355        }
356    }
357}
358
359fn unauthorized(message: &str) -> Response {
360    (
361        StatusCode::UNAUTHORIZED,
362        Json(serde_json::json!({ "error": "UNAUTHENTICATED", "message": message })),
363    )
364        .into_response()
365}
366
367fn forbidden(message: &str) -> Response {
368    (
369        StatusCode::FORBIDDEN,
370        Json(serde_json::json!({ "error": "PERMISSION_DENIED", "message": message })),
371    )
372        .into_response()
373}