1use async_trait::async_trait;
18use bytes::Bytes;
19use std::collections::HashMap;
20use std::sync::Arc;
21use std::time::Duration;
22
23use crate::context::{Context, GatewayError};
24use crate::outbound::{OutboundClient, OutboundRequest, OutboundResponse};
25use crate::plugins::resources::PluginResources;
26use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
27use crate::vars::template::Template;
28
29pub struct ForwardAuthPlugin {
34 uri: String,
36 method: http::Method,
38 is_post: bool,
40 request_headers: Vec<Template>,
43 upstream_headers: Vec<Template>,
47 client_headers: Vec<String>,
50 extra_headers: Vec<(String, Template)>,
54 ssl_verify: bool,
56 timeout: Duration,
58 status_on_error: u16,
60 allow_degradation: bool,
63 client: Arc<OutboundClient>,
65}
66
67impl ForwardAuthPlugin {
68 pub fn from_config(
111 config: &HashMap<String, serde_json::Value>,
112 resources: &Arc<PluginResources>,
113 ) -> Result<Self, String> {
114 let uri = config
115 .get("uri")
116 .and_then(|v| v.as_str())
117 .filter(|s| !s.is_empty())
118 .ok_or_else(|| "forward-auth plugin requires 'uri'".to_string())?
119 .to_string();
120
121 let method_str = config
122 .get("request_method")
123 .and_then(|v| v.as_str())
124 .unwrap_or("GET")
125 .to_uppercase();
126 let (method, is_post) = match method_str.as_str() {
127 "GET" => (http::Method::GET, false),
128 "POST" => (http::Method::POST, true),
129 other => {
130 return Err(format!(
131 "forward-auth request_method must be GET or POST, got '{}'",
132 other
133 ))
134 }
135 };
136
137 let string_list = |key: &str| -> Vec<String> {
138 config
139 .get(key)
140 .and_then(|v| v.as_array())
141 .map(|seq| {
142 seq.iter()
143 .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
144 .collect()
145 })
146 .unwrap_or_default()
147 };
148
149 let template_list = |key: &str| -> Vec<Template> {
154 config
155 .get(key)
156 .and_then(|v| v.as_array())
157 .map(|seq| {
158 seq.iter()
159 .filter_map(|v| v.as_str().map(|s| Template::parse(s).0))
160 .collect()
161 })
162 .unwrap_or_default()
163 };
164
165 let extra_headers: Vec<(String, Template)> = config
168 .get("extra_headers")
169 .and_then(|v| v.as_object())
170 .map(|obj| {
171 obj.iter()
172 .filter_map(|(k, v)| {
173 let value = match v {
174 serde_json::Value::String(s) => s.clone(),
175 serde_json::Value::Number(n) => n.to_string(),
176 serde_json::Value::Bool(b) => b.to_string(),
177 _ => return None,
178 };
179 Some((k.clone(), Template::parse(&value).0))
180 })
181 .collect()
182 })
183 .unwrap_or_default();
184
185 let ssl_verify = config
186 .get("ssl_verify")
187 .and_then(|v| v.as_bool())
188 .unwrap_or(true);
189
190 let timeout = Duration::from_millis(
191 config
192 .get("timeout")
193 .and_then(|v| v.as_u64())
194 .unwrap_or(3000),
195 );
196
197 let status_on_error = config
198 .get("status_on_error")
199 .and_then(|v| v.as_u64())
200 .unwrap_or(403) as u16;
201
202 let allow_degradation = config
203 .get("allow_degradation")
204 .and_then(|v| v.as_bool())
205 .unwrap_or(false);
206
207 Ok(Self {
208 uri,
209 method,
210 is_post,
211 request_headers: template_list("request_headers"),
212 upstream_headers: template_list("upstream_headers"),
213 client_headers: string_list("client_headers"),
214 extra_headers,
215 ssl_verify,
216 timeout,
217 status_on_error,
218 allow_degradation,
219 client: resources.outbound.clone(),
220 })
221 }
222
223 fn build_callout_headers(&self, ctx: &Context) -> Vec<(String, String)> {
228 let request_uri = crate::vars::resolve(ctx, "request_uri")
229 .map(|c| c.into_owned())
230 .unwrap_or_else(|| ctx.request.path.clone());
231 let remote_ip = crate::vars::resolve(ctx, "remote_addr")
232 .map(|c| c.into_owned())
233 .unwrap_or_default();
234
235 let mut headers: Vec<(String, String)> = vec![
236 ("X-Forwarded-Proto".to_string(), ctx.request.scheme.clone()),
237 ("X-Forwarded-Method".to_string(), ctx.request.method.clone()),
238 ("X-Forwarded-Host".to_string(), ctx.request.host.clone()),
239 ("X-Forwarded-Uri".to_string(), request_uri),
240 ("X-Forwarded-For".to_string(), remote_ip),
241 ];
242
243 if self.is_post {
244 if let Some(enc) = ctx
245 .request
246 .headers
247 .get("content-encoding")
248 .and_then(|v| v.first())
249 {
250 headers.push(("Content-Encoding".to_string(), enc.clone()));
251 }
252 }
253
254 for (name, template) in &self.extra_headers {
255 headers.push((name.clone(), template.render_with_legacy(ctx)));
256 }
257
258 for name_tpl in &self.request_headers {
260 let name = name_tpl.render(ctx).to_lowercase();
261 let already = headers
262 .iter()
263 .any(|(existing, _)| existing.eq_ignore_ascii_case(&name));
264 if already {
265 continue;
266 }
267 if let Some(value) = ctx.request.headers.get(&name).and_then(|v| v.first()) {
268 headers.push((name, value.clone()));
269 }
270 }
271
272 headers
273 }
274
275 fn apply_allow(&self, ctx: &mut Context, resp_headers: &HashMap<String, Vec<String>>) {
279 for name_tpl in &self.upstream_headers {
280 let name = name_tpl.render(ctx).to_lowercase();
281 match resp_headers.get(&name) {
282 Some(values) => {
283 ctx.request.headers.insert(name, values.clone());
284 }
285 None => {
286 ctx.request.headers.remove(&name);
287 }
288 }
289 }
290 }
291
292 fn build_deny(
296 &self,
297 mut ctx: Context,
298 status: u16,
299 body: Bytes,
300 resp_headers: &HashMap<String, Vec<String>>,
301 ) -> PluginOutput {
302 ctx.response.status_code = status;
303 ctx.response.body = body;
304 for name in &self.client_headers {
305 if let Some(values) = resp_headers.get(name) {
306 ctx.response.headers.insert(name.clone(), values.clone());
307 }
308 }
309 PluginOutput::on_port(ctx, "denied")
310 }
311
312 fn build_error(&self, mut ctx: Context, message: String) -> PluginExecutionError {
315 ctx.response.status_code = self.status_on_error;
316 PluginExecutionError {
317 context: ctx,
318 error: GatewayError {
319 node_id: String::new(),
320 code: "FORWARD_AUTH_ERROR".to_string(),
321 message,
322 metadata: HashMap::new(),
323 },
324 }
325 }
326}
327
328#[async_trait]
329impl Plugin for ForwardAuthPlugin {
330 fn plugin_type(&self) -> &str {
331 "forward-auth"
332 }
333
334 async fn execute(&self, mut ctx: Context) -> PluginResult {
335 let headers = self.build_callout_headers(&ctx);
336 let body = if self.is_post {
337 ctx.request.body.clone()
338 } else {
339 Bytes::new()
340 };
341
342 let request = OutboundRequest {
343 method: self.method.clone(),
344 url: self.uri.clone(),
345 headers,
346 body,
347 timeout: self.timeout,
348 ssl_verify: self.ssl_verify,
349 tls: None,
350 };
351
352 let response: OutboundResponse = match self.client.request(request).await {
353 Ok(resp) => resp,
354 Err(e) => {
355 if self.allow_degradation {
356 return Ok(PluginOutput::success(ctx));
358 }
359 return Err(self.build_error(ctx, format!("forward-auth callout failed: {}", e)));
360 }
361 };
362
363 if response.status >= 300 {
364 return Ok(self.build_deny(ctx, response.status, response.body, &response.headers));
365 }
366
367 self.apply_allow(&mut ctx, &response.headers);
368 Ok(PluginOutput::success(ctx))
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
376
377 fn test_ctx() -> Context {
378 let mut headers = HashMap::new();
379 headers.insert("authorization".to_string(), vec!["Bearer tok".to_string()]);
380 headers.insert("content-encoding".to_string(), vec!["gzip".to_string()]);
381 let mut query = HashMap::new();
382 query.insert("q".to_string(), vec!["1".to_string()]);
383 Context {
384 request: GatewayRequest {
385 method: "POST".to_string(),
386 path: "/api/x".to_string(),
387 host: "example.com".to_string(),
388 scheme: "https".to_string(),
389 headers,
390 query_params: query,
391 body: Bytes::from_static(b"payload"),
392 remote_addr: "10.0.0.7:5555".to_string(),
393 protocol: Protocol::Http1,
394 },
395 response: GatewayResponse {
396 status_code: 0,
397 headers: HashMap::new(),
398 body: Bytes::new(),
399 stream: None,
400 },
401 message: HashMap::new(),
402 errors: Vec::new(),
403 }
404 }
405
406 fn plugin(config: serde_json::Value) -> ForwardAuthPlugin {
407 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
408 ForwardAuthPlugin::from_config(&map, &PluginResources::empty()).unwrap()
409 }
410
411 #[test]
412 fn test_requires_uri() {
413 assert!(
414 ForwardAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
415 );
416 let mut map = HashMap::new();
418 map.insert("uri".to_string(), serde_json::json!(""));
419 assert!(ForwardAuthPlugin::from_config(&map, &PluginResources::empty()).is_err());
420 }
421
422 #[test]
423 fn test_rejects_bad_method() {
424 let mut map = HashMap::new();
425 map.insert("uri".to_string(), serde_json::json!("http://a"));
426 map.insert("request_method".to_string(), serde_json::json!("PUT"));
427 assert!(ForwardAuthPlugin::from_config(&map, &PluginResources::empty()).is_err());
428 }
429
430 #[test]
431 fn test_defaults() {
432 let p = plugin(serde_json::json!({ "uri": "http://auth" }));
433 assert_eq!(p.method, http::Method::GET);
434 assert!(!p.is_post);
435 assert!(p.ssl_verify);
436 assert_eq!(p.status_on_error, 403);
437 assert!(!p.allow_degradation);
438 assert_eq!(p.timeout, Duration::from_millis(3000));
439 }
440
441 #[test]
442 fn test_build_callout_headers_forwarded_and_client() {
443 let p = plugin(serde_json::json!({
444 "uri": "http://auth",
445 "request_method": "POST",
446 "request_headers": ["Authorization"],
447 "extra_headers": { "X-Src": "$remote_addr" }
448 }));
449 let headers = p.build_callout_headers(&test_ctx());
450 let get = |name: &str| {
451 headers
452 .iter()
453 .find(|(k, _)| k.eq_ignore_ascii_case(name))
454 .map(|(_, v)| v.as_str())
455 };
456 assert_eq!(get("X-Forwarded-Proto"), Some("https"));
457 assert_eq!(get("X-Forwarded-Method"), Some("POST"));
458 assert_eq!(get("X-Forwarded-Host"), Some("example.com"));
459 assert_eq!(get("X-Forwarded-Uri"), Some("/api/x?q=1"));
460 assert_eq!(get("X-Forwarded-For"), Some("10.0.0.7"));
461 assert_eq!(get("Content-Encoding"), Some("gzip"));
463 assert_eq!(get("authorization"), Some("Bearer tok"));
465 assert_eq!(get("X-Src"), Some("10.0.0.7"));
467 }
468
469 #[test]
470 fn test_request_headers_name_renders_template() {
471 let p = plugin(serde_json::json!({
472 "uri": "http://auth",
473 "request_headers": ["{{request.headers.x-forward-header}}"]
474 }));
475 let mut ctx = test_ctx();
476 ctx.request.headers.insert(
477 "x-forward-header".to_string(),
478 vec!["authorization".to_string()],
479 );
480 let headers = p.build_callout_headers(&ctx);
481 assert!(headers
482 .iter()
483 .any(|(k, v)| k.eq_ignore_ascii_case("authorization") && v == "Bearer tok"));
484 }
485
486 #[test]
487 fn test_upstream_headers_name_renders_template() {
488 let p = plugin(serde_json::json!({
489 "uri": "http://auth",
490 "upstream_headers": ["X-{{request.headers.x-suffix}}"]
491 }));
492 let mut ctx = test_ctx();
493 ctx.request
494 .headers
495 .insert("x-suffix".to_string(), vec!["User-Id".to_string()]);
496 let mut resp_headers = HashMap::new();
497 resp_headers.insert("x-user-id".to_string(), vec!["u42".to_string()]);
498 p.apply_allow(&mut ctx, &resp_headers);
499 assert_eq!(
500 ctx.request.headers.get("x-user-id"),
501 Some(&vec!["u42".to_string()])
502 );
503 }
504
505 #[test]
506 fn test_get_callout_omits_body_headers() {
507 let p = plugin(serde_json::json!({ "uri": "http://auth" }));
508 let headers = p.build_callout_headers(&test_ctx());
509 assert!(!headers
511 .iter()
512 .any(|(k, _)| k.eq_ignore_ascii_case("content-encoding")));
513 }
514
515 #[test]
516 fn test_apply_allow_sets_and_removes() {
517 let p = plugin(serde_json::json!({
518 "uri": "http://auth",
519 "upstream_headers": ["X-User-Id", "X-Absent"]
520 }));
521 let mut ctx = test_ctx();
522 ctx.request
523 .headers
524 .insert("x-absent".to_string(), vec!["stale".to_string()]);
525 let mut resp_headers = HashMap::new();
526 resp_headers.insert("x-user-id".to_string(), vec!["u42".to_string()]);
527 p.apply_allow(&mut ctx, &resp_headers);
528 assert_eq!(
529 ctx.request.headers.get("x-user-id"),
530 Some(&vec!["u42".to_string()])
531 );
532 assert!(!ctx.request.headers.contains_key("x-absent"));
534 }
535
536 #[test]
537 fn test_build_deny_mirrors_status_body_and_headers() {
538 let p = plugin(serde_json::json!({
539 "uri": "http://auth",
540 "client_headers": ["WWW-Authenticate"]
541 }));
542 let mut resp_headers = HashMap::new();
543 resp_headers.insert("www-authenticate".to_string(), vec!["Bearer".to_string()]);
544 let out = p.build_deny(
545 test_ctx(),
546 401,
547 Bytes::from_static(b"denied"),
548 &resp_headers,
549 );
550 assert_eq!(out.port, Some("denied"));
551 assert_eq!(out.context.response.status_code, 401);
552 assert_eq!(out.context.response.body, Bytes::from_static(b"denied"));
553 assert_eq!(
554 out.context.response.headers.get("www-authenticate"),
555 Some(&vec!["Bearer".to_string()])
556 );
557 }
558}