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