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