1pub 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
30enum KeySource {
32 Pem(Arc<DecodingKey>),
34 Jwks(JwksCache),
36}
37
38pub 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 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 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 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
121pub(crate) enum AuthDecision {
123 Allow(HeaderMap, Option<Value>),
127 Unauthenticated(&'static str),
129 Forbidden(&'static str),
131}
132
133#[derive(Clone)]
138pub(crate) struct ValidatedClaims(pub(crate) std::sync::Arc<Value>);
139
140impl Auth {
141 pub(crate) async fn decide(
145 &self,
146 headers: &HeaderMap,
147 path: &str,
148 method: &str,
149 ) -> AuthDecision {
150 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 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
184pub 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_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 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
218fn 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
228fn 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
237fn 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
249fn 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
259fn 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 _ => 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 assert!(!headers.contains_key("x-obj"));
342 }
343
344 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 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 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 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 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 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}