Skip to main content

featherbit/plugins/native/
multi_auth.rs

1//! Multi-authentication plugin (`multi-auth`).
2//!
3//! Chains several auth plugins and accepts the request as soon as **any** of
4//! them succeeds (first success wins); the request is rejected with a 401 only
5//! when *all* of them fail. This mirrors APISIX's `multi-auth`, which runs each
6//! configured auth plugin's `rewrite` phase in order and short-circuits on the
7//! first that authenticates.
8//!
9//! Sub-plugins are built at config load via [`crate::plugins::create_plugin`],
10//! so every sub-config is validated up front and a bad entry fails fast.
11
12use async_trait::async_trait;
13use bytes::Bytes;
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use crate::context::{Context, GatewayError};
18use crate::plugins::resources::PluginResources;
19use crate::plugins::{create_plugin, Plugin, PluginExecutionError, PluginResult};
20
21/// Authenticates a request by trying a list of auth sub-plugins in order and
22/// accepting the first that succeeds.
23///
24/// Each sub-plugin is a fully-fledged [`Plugin`] instance; on success it has
25/// already mutated the context (e.g. attached a consumer identity), so the
26/// winning sub-plugin's output is returned verbatim. Sub-plugins run in the
27/// listed order; a later sub-plugin sees the context as left by prior *failed*
28/// attempts, except that the response is reset between attempts so a losing
29/// plugin's rejection body never leaks onto a subsequent success. Auth plugins
30/// generally mutate the context only on success (leaving request/message
31/// untouched on failure), so ordering is safe.
32///
33/// Only auth-type plugins are meaningful here, but the set is **not**
34/// hard-restricted — any registered plugin type may be listed, and non-auth
35/// plugins simply run as ordinary nodes whose success ends the chain.
36pub struct MultiAuthPlugin {
37    /// Sub-plugins tried in order; the first `Ok` wins.
38    sub_plugins: Vec<Box<dyn Plugin>>,
39}
40
41impl MultiAuthPlugin {
42    /// Builds the plugin from node config.
43    ///
44    /// Accepted keys:
45    /// - `auth_plugins` (array, required): each element is a **single-key map**
46    ///   `{plugin-type: {that plugin's config}}`. Every entry is instantiated
47    ///   through [`create_plugin`] at load time, so a bad sub-config (or an
48    ///   unknown plugin type) fails fast here rather than at request time.
49    ///   APISIX conventionally requires at least two entries; featherbit only
50    ///   requires the array to be non-empty.
51    ///
52    /// ```yaml
53    /// type: multi-auth
54    /// config:
55    ///   auth_plugins:
56    ///     - key-auth:
57    ///         use_consumers: true
58    ///     - basic-auth:
59    ///         use_consumers: true
60    /// ```
61    pub fn from_config(
62        config: &HashMap<String, serde_json::Value>,
63        resources: &Arc<PluginResources>,
64    ) -> Result<Self, String> {
65        let entries = config
66            .get("auth_plugins")
67            .and_then(|v| v.as_array())
68            .ok_or("multi-auth plugin requires an 'auth_plugins' array")?;
69
70        if entries.is_empty() {
71            return Err("multi-auth 'auth_plugins' must not be empty".to_string());
72        }
73
74        let mut sub_plugins = Vec::with_capacity(entries.len());
75        for (idx, entry) in entries.iter().enumerate() {
76            let obj = entry.as_object().ok_or_else(|| {
77                format!(
78                    "multi-auth 'auth_plugins[{}]' must be a single-key map {{plugin-type: config}}",
79                    idx
80                )
81            })?;
82            if obj.len() != 1 {
83                return Err(format!(
84                    "multi-auth 'auth_plugins[{}]' must have exactly one key (the plugin type)",
85                    idx
86                ));
87            }
88            let (plugin_type, inner) = obj.iter().next().unwrap();
89            let inner_config: HashMap<String, serde_json::Value> = inner
90                .as_object()
91                .map(|m| m.clone().into_iter().collect())
92                .unwrap_or_default();
93            let plugin = create_plugin(plugin_type, &inner_config, resources)
94                .map_err(|e| format!("multi-auth sub-plugin '{}': {}", plugin_type, e))?;
95            sub_plugins.push(plugin);
96        }
97
98        Ok(Self { sub_plugins })
99    }
100
101    /// Builds the 401 rejection (code `MULTI_AUTH_FAILED`) returned when every
102    /// sub-plugin failed, carrying the context so the graph engine routes
103    /// through the error port.
104    fn reject(ctx: Context) -> PluginResult {
105        let mut ctx = ctx;
106        ctx.response.status_code = 401;
107        ctx.response.body =
108            Bytes::from(r#"{"error": "unauthorized", "message": "Authorization Failed"}"#);
109        ctx.response.headers.insert(
110            "content-type".to_string(),
111            vec!["application/json".to_string()],
112        );
113        Err(PluginExecutionError {
114            context: ctx,
115            error: GatewayError {
116                node_id: String::new(),
117                code: "MULTI_AUTH_FAILED".to_string(),
118                message: "all authentication methods failed".to_string(),
119                metadata: HashMap::new(),
120            },
121        })
122    }
123}
124
125#[async_trait]
126impl Plugin for MultiAuthPlugin {
127    fn plugin_type(&self) -> &str {
128        "multi-auth"
129    }
130
131    async fn execute(
132        &self,
133        ctx: Context,
134        named_inputs: &HashMap<String, serde_json::Value>,
135    ) -> PluginResult {
136        // Snapshot the pristine response so a failed attempt's rejection body
137        // never leaks onto the request if a later attempt succeeds.
138        let original_response = ctx.response.clone();
139        let mut ctx = ctx;
140
141        for sub in &self.sub_plugins {
142            match sub.execute(ctx, named_inputs).await {
143                Ok(output) => return Ok(output),
144                Err(err) => {
145                    ctx = err.context;
146                    ctx.response = original_response.clone();
147                }
148            }
149        }
150
151        Self::reject(ctx)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::context::{GatewayRequest, Protocol};
159
160    fn ctx_with_key(key: Option<&str>) -> Context {
161        let mut headers = HashMap::new();
162        if let Some(k) = key {
163            headers.insert("x-api-key".to_string(), vec![k.to_string()]);
164        }
165        Context::new(GatewayRequest {
166            method: "GET".into(),
167            path: "/".into(),
168            host: "h".into(),
169            scheme: "http".into(),
170            headers,
171            query_params: HashMap::new(),
172            body: Bytes::new(),
173            remote_addr: "1.2.3.4:5".into(),
174            protocol: Protocol::Http1,
175        })
176    }
177
178    /// Two key-auth sub-plugins accepting disjoint key sets.
179    fn two_key_auth_config() -> HashMap<String, serde_json::Value> {
180        let mut config = HashMap::new();
181        config.insert(
182            "auth_plugins".to_string(),
183            serde_json::json!([
184                { "key-auth": { "keys": ["alpha"] } },
185                { "key-auth": { "keys": ["beta"] } }
186            ]),
187        );
188        config
189    }
190
191    #[tokio::test]
192    async fn test_second_sub_plugin_succeeds() {
193        let plugin =
194            MultiAuthPlugin::from_config(&two_key_auth_config(), &PluginResources::empty())
195                .unwrap();
196        // "beta" fails the first key-auth but passes the second -> Ok.
197        let out = plugin
198            .execute(ctx_with_key(Some("beta")), &HashMap::new())
199            .await
200            .unwrap();
201        // A prior failed attempt must not leave a 401 body behind.
202        assert_eq!(out.context.response.status_code, 0);
203    }
204
205    #[tokio::test]
206    async fn test_all_fail_rejects_401() {
207        let plugin =
208            MultiAuthPlugin::from_config(&two_key_auth_config(), &PluginResources::empty())
209                .unwrap();
210        let err = plugin
211            .execute(ctx_with_key(Some("gamma")), &HashMap::new())
212            .await
213            .unwrap_err();
214        assert_eq!(err.error.code, "MULTI_AUTH_FAILED");
215        assert_eq!(err.context.response.status_code, 401);
216    }
217
218    #[test]
219    fn test_requires_auth_plugins() {
220        assert!(MultiAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
221    }
222
223    #[test]
224    fn test_rejects_bad_sub_config() {
225        // A sub-plugin whose config is invalid fails fast at load.
226        let mut config = HashMap::new();
227        config.insert(
228            "auth_plugins".to_string(),
229            serde_json::json!([{ "key-auth": {} }]),
230        );
231        assert!(MultiAuthPlugin::from_config(&config, &PluginResources::empty()).is_err());
232    }
233}