1use std::sync::Arc;
9
10use axum::extract::Request;
11use axum::http::header::{HeaderValue, LOCATION};
12use axum::http::{HeaderMap, StatusCode};
13use axum::response::{IntoResponse, Response};
14use axum::routing::any;
15use axum::Router;
16
17use super::{forbidden, unauthorized, Auth, AuthDecision};
18use crate::config::AuthConfig;
19
20pub struct ForwardAuth {
22 auth: Arc<Auth>,
23 path: String,
24 login_url: Option<String>,
25}
26
27impl ForwardAuth {
28 pub fn build(config: &AuthConfig, auth: Arc<Auth>) -> Option<Arc<Self>> {
33 let fa = config.forward_auth.as_ref()?;
34 if !fa.enabled {
35 return None;
36 }
37 Some(Arc::new(Self {
38 auth,
39 path: fa.path.clone(),
40 login_url: fa.login_url.clone(),
41 }))
42 }
43
44 pub fn routes<S>(self: &Arc<Self>) -> Router<S>
46 where
47 S: Clone + Send + Sync + 'static,
48 {
49 let fa = self.clone();
50 Router::new().route(
53 &self.path,
54 any(move |req: Request| {
55 let fa = fa.clone();
56 async move { fa.verify(req).await }
57 }),
58 )
59 }
60
61 async fn verify(&self, request: Request) -> Response {
62 let headers = request.headers();
63 let method = original_method(headers)
64 .unwrap_or_else(|| request.method().as_str().to_ascii_uppercase());
65 let path = original_path(headers).unwrap_or_else(|| request.uri().path().to_string());
66
67 match self.auth.decide(headers, &path, &method).await {
68 AuthDecision::Allow(claim_headers, _) => {
71 (StatusCode::OK, claim_headers).into_response()
72 }
73 AuthDecision::Unauthenticated(msg) => self.deny(msg),
74 AuthDecision::Forbidden(msg) => forbidden(msg),
75 }
76 }
77
78 fn deny(&self, msg: &'static str) -> Response {
81 let mut response = unauthorized(msg);
82 if let Some(url) = &self.login_url {
83 if let Ok(value) = HeaderValue::try_from(url.as_str()) {
84 response.headers_mut().insert(LOCATION, value);
85 }
86 }
87 response
88 }
89}
90
91fn original_method(headers: &HeaderMap) -> Option<String> {
93 forwarded(headers, &["x-forwarded-method", "x-original-method"]).map(|m| m.to_ascii_uppercase())
94}
95
96fn original_path(headers: &HeaderMap) -> Option<String> {
98 let raw = forwarded(headers, &["x-forwarded-uri", "x-original-uri"])?;
99 let path = raw.split_once('?').map_or(raw.as_str(), |(p, _)| p);
100 Some(path.to_string())
101}
102
103fn forwarded(headers: &HeaderMap, names: &[&str]) -> Option<String> {
105 names
106 .iter()
107 .filter_map(|n| headers.get(*n).and_then(|v| v.to_str().ok()))
108 .find(|v| !v.is_empty())
109 .map(str::to_string)
110}
111
112#[cfg(all(test, feature = "builtin_jwt"))]
115mod tests {
116 use super::*;
117 use crate::config::{AuthConfig, ForwardAuthConfig, JwtConfig, RoutePolicyConfig};
118 use axum::body::Body;
119 use axum::http::Request as HttpRequest;
120 use ed25519_dalek::{Signer, SigningKey};
121 use std::collections::HashMap;
122 use tower::ServiceExt;
123
124 fn keypair() -> (SigningKey, String) {
126 let sk = SigningKey::from_bytes(&[7u8; 32]);
127 let spki_prefix: [u8; 12] = [
128 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
129 ];
130 let mut der = spki_prefix.to_vec();
131 der.extend_from_slice(sk.verifying_key().as_bytes());
132 use base64::Engine;
133 let b64 = base64::engine::general_purpose::STANDARD.encode(&der);
134 let pem = format!("-----BEGIN PUBLIC KEY-----\n{b64}\n-----END PUBLIC KEY-----\n");
135 (sk, pem)
136 }
137
138 fn sign(sk: &SigningKey, claims: &serde_json::Value) -> String {
139 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
140 use base64::Engine;
141 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
142 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap());
143 let signing_input = format!("{header}.{payload}");
144 let sig = sk.sign(signing_input.as_bytes());
145 format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes()))
146 }
147
148 fn write_pem(pem: &str) -> std::path::PathBuf {
149 use std::sync::atomic::{AtomicU32, Ordering};
150 static N: AtomicU32 = AtomicU32::new(0);
151 let p = std::env::temp_dir().join(format!(
152 "sp_fa_{}_{}.pem",
153 std::process::id(),
154 N.fetch_add(1, Ordering::Relaxed)
155 ));
156 std::fs::write(&p, pem).unwrap();
157 p
158 }
159
160 fn forward_auth(pem_path: std::path::PathBuf, login_url: Option<String>) -> Arc<ForwardAuth> {
161 let mut claims_headers = HashMap::new();
162 claims_headers.insert("sub".to_string(), "x-forwarded-user".to_string());
163 let config = AuthConfig {
164 mode: "jwt".into(),
165 jwt: Some(JwtConfig {
166 issuer: None,
167 audience: None,
168 jwks_uri: None,
169 public_key_pem_file: Some(pem_path),
170 claims_headers,
171 roles_claim: "roles".into(),
172 }),
173 forward_auth: Some(ForwardAuthConfig {
174 enabled: true,
175 path: "/auth/verify".into(),
176 policies: vec![RoutePolicyConfig {
177 path: "/v1/admin/**".into(),
178 methods: vec!["*".into()],
179 require_auth: true,
180 required_roles: vec!["admin".into()],
181 }],
182 login_url,
183 applications_path: None,
184 }),
185 authz: None,
186 };
187 let auth = Auth::build(&config, None).unwrap().unwrap();
188 ForwardAuth::build(&config, auth).unwrap()
189 }
190
191 async fn call(fa: &Arc<ForwardAuth>, req: HttpRequest<Body>) -> Response {
192 let app: Router = fa.routes();
193 app.oneshot(req).await.unwrap()
194 }
195
196 fn verify_request(method: &str, uri: &str, token: Option<&str>) -> HttpRequest<Body> {
197 let mut b = HttpRequest::get("/auth/verify")
198 .header("x-forwarded-method", method)
199 .header("x-forwarded-uri", uri);
200 if let Some(t) = token {
201 b = b.header("authorization", format!("Bearer {t}"));
202 }
203 b.body(Body::empty()).unwrap()
204 }
205
206 #[tokio::test]
207 async fn allows_and_echoes_claim_header() {
208 let (sk, pem) = keypair();
209 let fa = forward_auth(write_pem(&pem), None);
210 let token = sign(
211 &sk,
212 &serde_json::json!({ "sub": "alice", "roles": ["admin"], "exp": 9999999999u64 }),
213 );
214 let resp = call(&fa, verify_request("GET", "/v1/admin/things", Some(&token))).await;
215 assert_eq!(resp.status(), StatusCode::OK);
216 assert_eq!(resp.headers()["x-forwarded-user"], "alice");
218 }
219
220 #[tokio::test]
221 async fn denies_without_token_and_sets_login_location() {
222 let (_sk, pem) = keypair();
223 let fa = forward_auth(write_pem(&pem), Some("https://login.example.com".into()));
224 let resp = call(&fa, verify_request("GET", "/v1/admin/things", None)).await;
225 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
226 assert_eq!(resp.headers()[LOCATION], "https://login.example.com");
227 }
228
229 #[tokio::test]
230 async fn forbids_when_role_missing() {
231 let (sk, pem) = keypair();
232 let fa = forward_auth(write_pem(&pem), None);
233 let token = sign(
234 &sk,
235 &serde_json::json!({ "sub": "bob", "roles": ["user"], "exp": 9999999999u64 }),
236 );
237 let resp = call(&fa, verify_request("GET", "/v1/admin/things", Some(&token))).await;
238 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
239 }
240
241 #[tokio::test]
242 async fn denies_invalid_token() {
243 let (_sk, pem) = keypair();
246 let fa = forward_auth(write_pem(&pem), None);
247 let wrong_key = SigningKey::from_bytes(&[9u8; 32]);
248 let token = sign(
249 &wrong_key,
250 &serde_json::json!({ "sub": "mallory", "roles": ["admin"], "exp": 9999999999u64 }),
251 );
252 let resp = call(&fa, verify_request("GET", "/v1/admin/things", Some(&token))).await;
253 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
254 }
255
256 #[tokio::test]
257 async fn allows_unprotected_original_path() {
258 let (_sk, pem) = keypair();
261 let fa = forward_auth(write_pem(&pem), None);
262 let resp = call(&fa, verify_request("GET", "/v1/public/info", None)).await;
263 assert_eq!(resp.status(), StatusCode::OK);
264 }
265}