Skip to main content

featherbit/plugins/native/
forward_auth.rs

1//! Forward-authentication plugin (`forward-auth`).
2//!
3//! Delegates the access decision for each request to an external HTTP
4//! authorization service. The plugin issues a callout carrying the original
5//! request's forwarding metadata (`X-Forwarded-*`) plus any configured client
6//! headers; a 2xx reply lets the request continue (optionally copying selected
7//! auth-response headers onto the request forwarded upstream), while a non-2xx
8//! reply denies the request (exits on the `denied` port), mirroring the auth
9//! service's status/body/headers back to the client. A callout failure either
10//! degrades open (`success`) or exits on the `error` port with a configurable
11//! status, depending on `allow_degradation` — it is a genuine infrastructure
12//! failure, not a deliberate denial.
13//!
14//! Ports the APISIX `forward-auth` plugin onto featherbit's shared outbound
15//! HTTP client.
16
17use 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
29/// Sends each request to an external authorization endpoint and routes on its
30/// verdict: 2xx continues (`success` port), non-2xx denies (`denied` port),
31/// and a (non-degrading) callout failure is a genuine infra failure (`error`
32/// port).
33pub struct ForwardAuthPlugin {
34    /// External authorization endpoint.
35    uri: String,
36    /// Callout method (`GET` or `POST`); `POST` forwards the client body.
37    method: http::Method,
38    /// Whether the callout is a `POST` (forwards the client body).
39    is_post: bool,
40    /// Client request header names copied onto the callout (rendered per
41    /// request, then lowercased). Supports `{{namespace.path}}` references.
42    request_headers: Vec<Template>,
43    /// Auth-response header names copied onto the request forwarded upstream
44    /// on success (rendered per request, then lowercased). Supports
45    /// `{{namespace.path}}` references.
46    upstream_headers: Vec<Template>,
47    /// Auth-response header names copied onto the client-facing response on
48    /// failure (lowercased).
49    client_headers: Vec<String>,
50    /// Extra callout headers; values support `{{namespace.path}}` references
51    /// and legacy `$var` interpolation (see
52    /// [`Template::render_with_legacy`]).
53    extra_headers: Vec<(String, Template)>,
54    /// TLS certificate verification for `https` callouts.
55    ssl_verify: bool,
56    /// Whole-call callout deadline.
57    timeout: Duration,
58    /// Status returned when the callout fails and degradation is disabled.
59    status_on_error: u16,
60    /// When true, a callout failure lets the request through instead of
61    /// rejecting it.
62    allow_degradation: bool,
63    /// Shared pooled HTTP client (from [`PluginResources`]).
64    client: Arc<OutboundClient>,
65}
66
67impl ForwardAuthPlugin {
68    /// Builds the plugin from node config.
69    ///
70    /// Accepted keys:
71    /// - `uri` (string, **required**): external authorization endpoint. A
72    ///   missing `uri` is a config-load error.
73    /// - `request_method` (string, default `GET`): callout method; `GET` or
74    ///   `POST`. When `POST`, the buffered client body is forwarded and the
75    ///   client's `Content-Encoding` header is preserved on the callout.
76    /// - `request_headers` (array of strings, default `[]`): client header
77    ///   names forwarded to the authorization service (looked up
78    ///   case-insensitively).
79    /// - `upstream_headers` (array of strings, default `[]`): auth-response
80    ///   header names copied onto the request forwarded upstream on success.
81    ///   A configured name absent from the auth response removes any
82    ///   client-supplied value.
83    /// - `client_headers` (array of strings, default `[]`): auth-response
84    ///   header names copied onto the client-facing response on failure.
85    /// - `extra_headers` (object of string→string, optional): additional
86    ///   callout headers; values support `{{namespace.path}}` references plus
87    ///   legacy `$var` / `${var}` interpolation (e.g. `$remote_addr`,
88    ///   `$request_uri`).
89    /// - `ssl_verify` (bool, default `true`): verify TLS certificates for
90    ///   `https` callouts.
91    /// - `timeout` (integer ms, default `3000`): whole-call callout deadline.
92    /// - `status_on_error` (integer, default `403`): status returned when the
93    ///   callout fails and `allow_degradation` is false.
94    /// - `allow_degradation` (bool, default `false`): when true, a callout
95    ///   failure lets the request continue instead of rejecting it.
96    ///
97    /// ```yaml
98    /// type: forward-auth
99    /// config:
100    ///   uri: http://auth-service:8080/verify
101    ///   request_method: GET
102    ///   request_headers: [authorization, cookie]
103    ///   upstream_headers: [x-user-id]
104    ///   client_headers: [www-authenticate]
105    ///   ssl_verify: true
106    ///   timeout: 3000
107    ///   status_on_error: 403
108    ///   allow_degradation: false
109    /// ```
110    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        // Header-name lists that support `{{namespace.path}}` references,
150        // rendered (then lowercased) per request. Discard warnings here — the
151        // compile-time walk (a later task) reports well-formed-but-unknown
152        // references; execution must not.
153        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        // Discard warnings here — the compile-time walk (a later task)
166        // reports well-formed-but-unknown references; execution must not.
167        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    /// Builds the header list sent on the callout to the authorization
224    /// service: the `X-Forwarded-*` forwarding set, `Content-Encoding` for
225    /// `POST`, any interpolated `extra_headers`, and the configured
226    /// `request_headers` copied from the client request.
227    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        // Copy configured client headers unless already set above.
259        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    /// On a 2xx auth reply, copies the configured `upstream_headers` from the
276    /// auth response onto the request forwarded upstream. A configured header
277    /// absent from the auth response removes any client-supplied value.
278    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    /// On a non-2xx auth reply, mirrors the auth service's status and body onto
293    /// the client-facing response, copies the configured `client_headers`, and
294    /// exits on the `denied` port.
295    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    /// Builds the `FORWARD_AUTH_ERROR` rejection used when the callout fails
313    /// and degradation is disabled.
314    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                    // Degrade open: let the request continue unchanged.
357                    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        // present but empty is also rejected
417        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        // POST preserves content-encoding
462        assert_eq!(get("Content-Encoding"), Some("gzip"));
463        // configured client header copied
464        assert_eq!(get("authorization"), Some("Bearer tok"));
465        // extra header interpolated
466        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        // GET does not forward Content-Encoding
510        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        // configured header absent from auth response -> client value removed
533        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}