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 rejects the request, mirroring the auth service's status/body/headers
9//! back to the client. A callout failure either degrades open or rejects with
10//! a configurable status, depending on `allow_degradation`.
11//!
12//! Ports the APISIX `forward-auth` plugin onto featherbit's shared outbound
13//! HTTP client.
14
15use 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
26/// Sends each request to an external authorization endpoint and routes on its
27/// verdict: 2xx continues (success port), non-2xx or (non-degrading) callout
28/// failure rejects (error port).
29pub struct ForwardAuthPlugin {
30    /// External authorization endpoint.
31    uri: String,
32    /// Callout method (`GET` or `POST`); `POST` forwards the client body.
33    method: http::Method,
34    /// Whether the callout is a `POST` (forwards the client body).
35    is_post: bool,
36    /// Client request header names copied onto the callout (lowercased).
37    request_headers: Vec<String>,
38    /// Auth-response header names copied onto the request forwarded upstream
39    /// on success (lowercased).
40    upstream_headers: Vec<String>,
41    /// Auth-response header names copied onto the client-facing response on
42    /// failure (lowercased).
43    client_headers: Vec<String>,
44    /// Extra callout headers whose values may reference `$var` templates.
45    extra_headers: Vec<(String, String)>,
46    /// TLS certificate verification for `https` callouts.
47    ssl_verify: bool,
48    /// Whole-call callout deadline.
49    timeout: Duration,
50    /// Status returned when the callout fails and degradation is disabled.
51    status_on_error: u16,
52    /// When true, a callout failure lets the request through instead of
53    /// rejecting it.
54    allow_degradation: bool,
55    /// Shared pooled HTTP client (from [`PluginResources`]).
56    client: Arc<OutboundClient>,
57}
58
59impl ForwardAuthPlugin {
60    /// Builds the plugin from node config.
61    ///
62    /// Accepted keys:
63    /// - `uri` (string, **required**): external authorization endpoint. A
64    ///   missing `uri` is a config-load error.
65    /// - `request_method` (string, default `GET`): callout method; `GET` or
66    ///   `POST`. When `POST`, the buffered client body is forwarded and the
67    ///   client's `Content-Encoding` header is preserved on the callout.
68    /// - `request_headers` (array of strings, default `[]`): client header
69    ///   names forwarded to the authorization service (looked up
70    ///   case-insensitively).
71    /// - `upstream_headers` (array of strings, default `[]`): auth-response
72    ///   header names copied onto the request forwarded upstream on success.
73    ///   A configured name absent from the auth response removes any
74    ///   client-supplied value.
75    /// - `client_headers` (array of strings, default `[]`): auth-response
76    ///   header names copied onto the client-facing response on failure.
77    /// - `extra_headers` (object of string→string, optional): additional
78    ///   callout headers; values support `$var` / `${var}` interpolation
79    ///   (e.g. `$remote_addr`, `$request_uri`).
80    /// - `ssl_verify` (bool, default `true`): verify TLS certificates for
81    ///   `https` callouts.
82    /// - `timeout` (integer ms, default `3000`): whole-call callout deadline.
83    /// - `status_on_error` (integer, default `403`): status returned when the
84    ///   callout fails and `allow_degradation` is false.
85    /// - `allow_degradation` (bool, default `false`): when true, a callout
86    ///   failure lets the request continue instead of rejecting it.
87    ///
88    /// ```yaml
89    /// type: forward-auth
90    /// config:
91    ///   uri: http://auth-service:8080/verify
92    ///   request_method: GET
93    ///   request_headers: [authorization, cookie]
94    ///   upstream_headers: [x-user-id]
95    ///   client_headers: [www-authenticate]
96    ///   ssl_verify: true
97    ///   timeout: 3000
98    ///   status_on_error: 403
99    ///   allow_degradation: false
100    /// ```
101    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    /// Builds the header list sent on the callout to the authorization
197    /// service: the `X-Forwarded-*` forwarding set, `Content-Encoding` for
198    /// `POST`, any interpolated `extra_headers`, and the configured
199    /// `request_headers` copied from the client request.
200    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        // Copy configured client headers unless already set above.
232        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    /// On a 2xx auth reply, copies the configured `upstream_headers` from the
248    /// auth response onto the request forwarded upstream. A configured header
249    /// absent from the auth response removes any client-supplied value.
250    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    /// On a non-2xx auth reply, mirrors the auth service's status and body onto
264    /// the client-facing response, copies the configured `client_headers`, and
265    /// returns the `FORWARD_AUTH_DENIED` rejection carrying the context.
266    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    /// Builds the `FORWARD_AUTH_ERROR` rejection used when the callout fails
292    /// and degradation is disabled.
293    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                    // Degrade open: let the request continue unchanged.
340                    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        // present but empty is also rejected
405        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        // POST preserves content-encoding
450        assert_eq!(get("Content-Encoding"), Some("gzip"));
451        // configured client header copied
452        assert_eq!(get("authorization"), Some("Bearer tok"));
453        // extra header interpolated
454        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        // GET does not forward Content-Encoding
462        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        // configured header absent from auth response -> client value removed
485        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}