Skip to main content

structured_proxy/auth/
mod.rs

1//! JWT authentication and route-level authorization.
2//!
3//! Validates `Authorization: Bearer` JWTs against a configured key source (an
4//! Ed25519 PEM file or a JWKS endpoint), enforces per-route policies
5//! (`require_auth` / `required_roles`), and forwards selected claims to the
6//! upstream as request headers. Active only when `auth.mode == "jwt"`.
7
8pub mod authz;
9pub mod forward;
10pub mod jwks;
11pub mod policy;
12
13use std::collections::HashMap;
14use std::collections::HashSet;
15use std::sync::Arc;
16
17use axum::extract::State;
18use axum::http::header::{HeaderName, HeaderValue};
19use axum::http::{HeaderMap, StatusCode};
20use axum::middleware::Next;
21use axum::response::{IntoResponse, Response};
22use axum::Json;
23use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
24use serde_json::Value;
25
26use crate::config::AuthConfig;
27use jwks::JwksCache;
28use policy::Policies;
29
30/// Where verifying keys come from.
31enum KeySource {
32    /// A single Ed25519 public key (EdDSA).
33    Pem(Arc<DecodingKey>),
34    /// Keys discovered from a JWKS endpoint, selected by `kid`.
35    Jwks(JwksCache),
36}
37
38/// Compiled auth configuration: keys, expected claims, and route policies.
39pub struct Auth {
40    keys: KeySource,
41    issuer: Option<String>,
42    audience: Option<String>,
43    claims_headers: HashMap<String, String>,
44    roles_claim: String,
45    policies: Policies,
46}
47
48impl Auth {
49    /// Build auth from config, or `None` when `auth.mode` is not `"jwt"`.
50    ///
51    /// # Errors
52    /// Returns an error string when the JWT config is missing a key source, the
53    /// PEM file cannot be read, or a policy glob fails to compile.
54    pub fn build(config: &AuthConfig) -> Result<Option<Arc<Self>>, String> {
55        if config.mode != "jwt" {
56            return Ok(None);
57        }
58        let jwt = config
59            .jwt
60            .as_ref()
61            .ok_or("auth.mode is \"jwt\" but auth.jwt is not set")?;
62
63        let keys = if let Some(uri) = &jwt.jwks_uri {
64            KeySource::Jwks(JwksCache::new(uri.clone()))
65        } else if let Some(pem_path) = &jwt.public_key_pem_file {
66            let pem = std::fs::read(pem_path)
67                .map_err(|e| format!("failed to read auth.jwt.public_key_pem_file: {e}"))?;
68            let key = DecodingKey::from_ed_pem(&pem)
69                .map_err(|e| format!("invalid Ed25519 public key PEM: {e}"))?;
70            KeySource::Pem(Arc::new(key))
71        } else {
72            return Err("auth.jwt requires either jwks_uri or public_key_pem_file".to_string());
73        };
74
75        let policies = match &config.forward_auth {
76            Some(fa) => Policies::compile(&fa.policies)?,
77            None => Policies::default(),
78        };
79
80        Ok(Some(Arc::new(Self {
81            keys,
82            issuer: jwt.issuer.clone(),
83            audience: jwt.audience.clone(),
84            claims_headers: jwt.claims_headers.clone(),
85            roles_claim: jwt.roles_claim.clone(),
86            policies,
87        })))
88    }
89
90    /// Verify a token and return its claims, or `None` if invalid.
91    async fn verify(&self, token: &str) -> Option<Value> {
92        let header = decode_header(token).ok()?;
93        let (key, algorithm) = match &self.keys {
94            KeySource::Pem(k) => (k.clone(), Algorithm::EdDSA),
95            KeySource::Jwks(cache) => {
96                let kid = header.kid.as_deref()?;
97                let vk = cache.key_for(kid).await?;
98                (vk.key, vk.algorithm)
99            }
100        };
101        // Reject algorithm confusion: the token must use the key's algorithm.
102        if header.alg != algorithm {
103            return None;
104        }
105
106        let mut validation = Validation::new(algorithm);
107        if let Some(iss) = &self.issuer {
108            validation.set_issuer(&[iss]);
109        }
110        match &self.audience {
111            Some(aud) => validation.set_audience(&[aud]),
112            None => validation.validate_aud = false,
113        }
114
115        decode::<Value>(token, &key, &validation)
116            .ok()
117            .map(|data| data.claims)
118    }
119}
120
121/// The outcome of an auth check for a request.
122pub(crate) enum AuthDecision {
123    /// Allowed; forward these (verified) claim headers to the upstream, and the
124    /// verified claims themselves (`None` for anonymous access) for downstream
125    /// consumers such as per-principal rate limiting.
126    Allow(HeaderMap, Option<Value>),
127    /// Rejected: no/invalid credentials (HTTP 401).
128    Unauthenticated(&'static str),
129    /// Rejected: authenticated but lacking a required role (HTTP 403).
130    Forbidden(&'static str),
131}
132
133/// Verified JWT claims attached to the request by the auth middleware. Present
134/// only when a valid token was supplied, and set exclusively from a verified
135/// token (never from client input), so downstream consumers may safely key
136/// security decisions (e.g. rate limits) on it.
137#[derive(Clone)]
138pub(crate) struct ValidatedClaims(pub(crate) std::sync::Arc<Value>);
139
140impl Auth {
141    /// Evaluate auth for a request: validate the bearer token, apply the route
142    /// policy, and render the claim headers to forward. This is the single
143    /// source of truth shared by the middleware and the forward-auth endpoint.
144    pub(crate) async fn decide(
145        &self,
146        headers: &HeaderMap,
147        path: &str,
148        method: &str,
149    ) -> AuthDecision {
150        // A token that is present but invalid is always a 401, regardless of policy.
151        let claims = match bearer_token(headers) {
152            Some(token) => match self.verify(&token).await {
153                Some(c) => Some(c),
154                None => return AuthDecision::Unauthenticated("invalid or expired token"),
155            },
156            None => None,
157        };
158
159        if let Some(policy) = self.policies.match_rule(path, method) {
160            if policy.require_auth && claims.is_none() {
161                return AuthDecision::Unauthenticated("authentication required");
162            }
163            if !policy.required_roles.is_empty() {
164                // An unauthenticated caller is told to authenticate (401), not
165                // that they lack a role (403).
166                let Some(claims) = claims.as_ref() else {
167                    return AuthDecision::Unauthenticated("authentication required");
168                };
169                let roles = extract_roles(claims, &self.roles_claim);
170                if !policy.required_roles.iter().all(|r| roles.contains(r)) {
171                    return AuthDecision::Forbidden("insufficient role");
172                }
173            }
174        }
175
176        let mut claim_headers = HeaderMap::new();
177        if let Some(claims) = &claims {
178            inject_claim_headers(&mut claim_headers, claims, &self.claims_headers);
179        }
180        AuthDecision::Allow(claim_headers, claims)
181    }
182}
183
184/// Axum middleware enforcing JWT auth and route policies.
185pub async fn middleware(
186    State(auth): State<Arc<Auth>>,
187    mut request: axum::extract::Request,
188    next: Next,
189) -> Response {
190    let path = request.uri().path().to_string();
191    let method = request.method().as_str().to_ascii_uppercase();
192
193    // Strip any client-supplied values for proxy-controlled claim headers, so a
194    // client can never forge them onto the upstream (only verified claims set
195    // them below).
196    strip_claim_headers(request.headers_mut(), &auth.claims_headers);
197
198    match auth.decide(request.headers(), &path, &method).await {
199        AuthDecision::Unauthenticated(msg) => unauthorized(msg),
200        AuthDecision::Forbidden(msg) => forbidden(msg),
201        AuthDecision::Allow(claim_headers, claims) => {
202            let dst = request.headers_mut();
203            for (name, value) in &claim_headers {
204                dst.insert(name.clone(), value.clone());
205            }
206            // Expose the verified claims to inner layers (e.g. per-principal rate
207            // limiting) as a typed extension a client cannot forge.
208            if let Some(claims) = claims {
209                request
210                    .extensions_mut()
211                    .insert(ValidatedClaims(std::sync::Arc::new(claims)));
212            }
213            next.run(request).await
214        }
215    }
216}
217
218/// Extract the bearer token from the `Authorization` header.
219fn bearer_token(headers: &HeaderMap) -> Option<String> {
220    let value = headers.get("authorization")?.to_str().ok()?;
221    let token = value
222        .strip_prefix("Bearer ")
223        .or_else(|| value.strip_prefix("bearer "))?;
224    let token = token.trim();
225    (!token.is_empty()).then(|| token.to_string())
226}
227
228/// Resolve a (possibly dotted) claim path to a JSON value.
229fn claim_at<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
230    let mut cur = claims;
231    for seg in path.split('.') {
232        cur = cur.get(seg)?;
233    }
234    Some(cur)
235}
236
237/// Collect the caller's roles from the configured claim (an array of strings).
238fn extract_roles(claims: &Value, roles_claim: &str) -> HashSet<String> {
239    claim_at(claims, roles_claim)
240        .and_then(Value::as_array)
241        .map(|arr| {
242            arr.iter()
243                .filter_map(|v| v.as_str().map(str::to_string))
244                .collect()
245        })
246        .unwrap_or_default()
247}
248
249/// Remove any incoming values for the proxy-controlled claim headers, so a
250/// client cannot forge them onto the upstream.
251fn strip_claim_headers(headers: &mut HeaderMap, mapping: &HashMap<String, String>) {
252    for header in mapping.values() {
253        if let Ok(name) = HeaderName::try_from(header.as_str()) {
254            while headers.remove(&name).is_some() {}
255        }
256    }
257}
258
259/// Inject configured claims as request headers forwarded to the upstream.
260fn inject_claim_headers(
261    headers: &mut HeaderMap,
262    claims: &Value,
263    mapping: &HashMap<String, String>,
264) {
265    for (claim, header) in mapping {
266        let Some(value) = claim_at(claims, claim) else {
267            continue;
268        };
269        let rendered = match value {
270            Value::String(s) => s.clone(),
271            Value::Number(n) => n.to_string(),
272            Value::Bool(b) => b.to_string(),
273            // Skip arrays/objects/null: not meaningful as a single header value.
274            _ => continue,
275        };
276        if let (Ok(name), Ok(val)) = (
277            HeaderName::try_from(header.as_str()),
278            HeaderValue::try_from(rendered),
279        ) {
280            headers.insert(name, val);
281        }
282    }
283}
284
285fn unauthorized(message: &str) -> Response {
286    (
287        StatusCode::UNAUTHORIZED,
288        Json(serde_json::json!({ "error": "UNAUTHENTICATED", "message": message })),
289    )
290        .into_response()
291}
292
293fn forbidden(message: &str) -> Response {
294    (
295        StatusCode::FORBIDDEN,
296        Json(serde_json::json!({ "error": "PERMISSION_DENIED", "message": message })),
297    )
298        .into_response()
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn bearer_token_parsing() {
307        let mut h = HeaderMap::new();
308        h.insert("authorization", "Bearer abc.def.ghi".parse().unwrap());
309        assert_eq!(bearer_token(&h).as_deref(), Some("abc.def.ghi"));
310
311        let mut h2 = HeaderMap::new();
312        h2.insert("authorization", "Basic xyz".parse().unwrap());
313        assert_eq!(bearer_token(&h2), None);
314        assert_eq!(bearer_token(&HeaderMap::new()), None);
315    }
316
317    #[test]
318    fn extract_roles_reads_array_and_dotted_path() {
319        let claims = serde_json::json!({
320            "roles": ["admin", "billing"],
321            "realm_access": { "roles": ["nested"] }
322        });
323        assert!(extract_roles(&claims, "roles").contains("admin"));
324        assert!(extract_roles(&claims, "realm_access.roles").contains("nested"));
325        assert!(extract_roles(&claims, "missing").is_empty());
326    }
327
328    #[test]
329    fn inject_claim_headers_renders_scalars() {
330        let claims = serde_json::json!({ "sub": "u-1", "n": 7, "obj": {"x": 1} });
331        let mapping = HashMap::from([
332            ("sub".to_string(), "x-user-id".to_string()),
333            ("n".to_string(), "x-n".to_string()),
334            ("obj".to_string(), "x-obj".to_string()),
335        ]);
336        let mut headers = HeaderMap::new();
337        inject_claim_headers(&mut headers, &claims, &mapping);
338        assert_eq!(headers["x-user-id"], "u-1");
339        assert_eq!(headers["x-n"], "7");
340        // Object claim is skipped (not a scalar).
341        assert!(!headers.contains_key("x-obj"));
342    }
343
344    // --- end-to-end JWT validation + policy enforcement ---
345
346    use crate::config::{AuthConfig, ForwardAuthConfig, JwtConfig, RoutePolicyConfig};
347    use axum::http::Request as HttpRequest;
348    use jsonwebtoken::{encode, EncodingKey, Header};
349    use std::sync::atomic::{AtomicU32, Ordering};
350    use tower::ServiceExt;
351
352    // Ed25519 test keypair (generated for tests only; not a secret).
353    const TEST_PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
354        MC4CAQAwBQYDK2VwBCIEIEVVO7H+T5tERRn/dzukOc8i9iYEKKtPh//qcrES+dCt\n\
355        -----END PRIVATE KEY-----\n";
356    const TEST_PUB_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
357        MCowBQYDK2VwAyEARCMxEnaM2/dblLuPNgBZpTvSUXO5ir+XQ1nyzJm4CFw=\n\
358        -----END PUBLIC KEY-----\n";
359
360    fn temp_pub_pem() -> std::path::PathBuf {
361        static N: AtomicU32 = AtomicU32::new(0);
362        let path = std::env::temp_dir().join(format!(
363            "sp_auth_{}_{}.pem",
364            std::process::id(),
365            N.fetch_add(1, Ordering::Relaxed)
366        ));
367        std::fs::write(&path, TEST_PUB_PEM).unwrap();
368        path
369    }
370
371    fn sign(claims: serde_json::Value) -> String {
372        let key = EncodingKey::from_ed_pem(TEST_PRIV_PEM.as_bytes()).unwrap();
373        encode(&Header::new(Algorithm::EdDSA), &claims, &key).unwrap()
374    }
375
376    fn future_exp() -> i64 {
377        let now = std::time::SystemTime::now()
378            .duration_since(std::time::UNIX_EPOCH)
379            .unwrap()
380            .as_secs() as i64;
381        now + 3600
382    }
383
384    fn auth_with_policy(roles: &[&str]) -> Arc<Auth> {
385        let cfg = AuthConfig {
386            mode: "jwt".into(),
387            jwt: Some(JwtConfig {
388                jwks_uri: None,
389                issuer: Some("test-iss".into()),
390                audience: Some("test-aud".into()),
391                public_key_pem_file: Some(temp_pub_pem()),
392                claims_headers: HashMap::from([("sub".to_string(), "x-user".to_string())]),
393                roles_claim: "roles".into(),
394            }),
395            forward_auth: Some(ForwardAuthConfig {
396                enabled: true,
397                path: "/auth/verify".into(),
398                policies: vec![RoutePolicyConfig {
399                    path: "/secure".into(),
400                    methods: vec!["*".into()],
401                    require_auth: true,
402                    required_roles: roles.iter().map(|s| s.to_string()).collect(),
403                }],
404                login_url: None,
405                applications_path: None,
406            }),
407            authz: None,
408        };
409        Auth::build(&cfg).unwrap().unwrap()
410    }
411
412    fn app(auth: Arc<Auth>) -> axum::Router {
413        // Both routes echo the x-user header the upstream would receive.
414        let echo = |headers: HeaderMap| async move {
415            headers
416                .get("x-user")
417                .and_then(|v| v.to_str().ok())
418                .unwrap_or("")
419                .to_string()
420        };
421        axum::Router::new()
422            .route("/secure", axum::routing::get(echo))
423            .route("/open", axum::routing::get(echo))
424            .layer(axum::middleware::from_fn_with_state(auth, middleware))
425    }
426
427    async fn body_string(resp: axum::response::Response) -> String {
428        let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
429        String::from_utf8(bytes.to_vec()).unwrap()
430    }
431
432    #[tokio::test]
433    async fn strips_client_supplied_claim_headers() {
434        // A client forges x-user on an unprotected route with no token; the
435        // proxy must not forward the forged value to the upstream.
436        let app = app(auth_with_policy(&[]));
437        let resp = app
438            .oneshot(
439                HttpRequest::get("/open")
440                    .header("x-user", "forged-admin")
441                    .body(axum::body::Body::empty())
442                    .unwrap(),
443            )
444            .await
445            .unwrap();
446        assert_eq!(resp.status(), 200);
447        assert_eq!(body_string(resp).await, "");
448    }
449
450    #[tokio::test]
451    async fn unauthenticated_role_check_is_401_not_403() {
452        // Policy requires a role but not auth; a request with no token is
453        // unauthenticated, so it must get 401, not 403.
454        let cfg = AuthConfig {
455            mode: "jwt".into(),
456            jwt: Some(JwtConfig {
457                jwks_uri: None,
458                issuer: None,
459                audience: None,
460                public_key_pem_file: Some(temp_pub_pem()),
461                claims_headers: HashMap::new(),
462                roles_claim: "roles".into(),
463            }),
464            forward_auth: Some(ForwardAuthConfig {
465                enabled: true,
466                path: "/auth/verify".into(),
467                policies: vec![RoutePolicyConfig {
468                    path: "/secure".into(),
469                    methods: vec!["*".into()],
470                    require_auth: false,
471                    required_roles: vec!["admin".into()],
472                }],
473                login_url: None,
474                applications_path: None,
475            }),
476            authz: None,
477        };
478        let auth = Auth::build(&cfg).unwrap().unwrap();
479        let resp = app(auth)
480            .oneshot(
481                HttpRequest::get("/secure")
482                    .body(axum::body::Body::empty())
483                    .unwrap(),
484            )
485            .await
486            .unwrap();
487        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
488    }
489
490    #[tokio::test]
491    async fn rejects_missing_token_on_protected_route() {
492        let app = app(auth_with_policy(&[]));
493        let resp = app
494            .oneshot(
495                HttpRequest::get("/secure")
496                    .body(axum::body::Body::empty())
497                    .unwrap(),
498            )
499            .await
500            .unwrap();
501        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
502    }
503
504    #[tokio::test]
505    async fn accepts_valid_token_and_injects_claim_header() {
506        let app = app(auth_with_policy(&["admin"]));
507        let token = sign(serde_json::json!({
508            "iss": "test-iss", "aud": "test-aud", "exp": future_exp(),
509            "sub": "user-42", "roles": ["admin"]
510        }));
511        let resp = app
512            .oneshot(
513                HttpRequest::get("/secure")
514                    .header("authorization", format!("Bearer {token}"))
515                    .body(axum::body::Body::empty())
516                    .unwrap(),
517            )
518            .await
519            .unwrap();
520        assert_eq!(resp.status(), 200);
521        let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
522        // The sub claim was forwarded to the handler as x-user.
523        assert_eq!(&body[..], b"user-42");
524    }
525
526    #[tokio::test]
527    async fn forbids_when_required_role_missing() {
528        let app = app(auth_with_policy(&["admin"]));
529        let token = sign(serde_json::json!({
530            "iss": "test-iss", "aud": "test-aud", "exp": future_exp(),
531            "sub": "user-42", "roles": ["viewer"]
532        }));
533        let resp = app
534            .oneshot(
535                HttpRequest::get("/secure")
536                    .header("authorization", format!("Bearer {token}"))
537                    .body(axum::body::Body::empty())
538                    .unwrap(),
539            )
540            .await
541            .unwrap();
542        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
543    }
544
545    #[tokio::test]
546    async fn rejects_expired_and_wrong_issuer() {
547        let app = app(auth_with_policy(&[]));
548        let expired = sign(serde_json::json!({
549            "iss": "test-iss", "aud": "test-aud", "exp": 1, "sub": "u", "roles": ["admin"]
550        }));
551        let resp = app
552            .clone()
553            .oneshot(
554                HttpRequest::get("/secure")
555                    .header("authorization", format!("Bearer {expired}"))
556                    .body(axum::body::Body::empty())
557                    .unwrap(),
558            )
559            .await
560            .unwrap();
561        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
562
563        let wrong_iss = sign(serde_json::json!({
564            "iss": "evil", "aud": "test-aud", "exp": future_exp(), "sub": "u", "roles": ["admin"]
565        }));
566        let resp = app
567            .oneshot(
568                HttpRequest::get("/secure")
569                    .header("authorization", format!("Bearer {wrong_iss}"))
570                    .body(axum::body::Body::empty())
571                    .unwrap(),
572            )
573            .await
574            .unwrap();
575        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
576    }
577}