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,
5//! exiting on the `denied` port, only when *all* of them fail (or exit on a
6//! sub-plugin's own alternate outcome port). This mirrors APISIX's
7//! `multi-auth`, which runs each configured auth plugin's `rewrite` phase in
8//! order and short-circuits on the first that authenticates.
9//!
10//! Sub-plugins are built at config load via [`crate::plugins::create_plugin`],
11//! so every sub-config is validated up front and a bad entry fails fast.
12
13use async_trait::async_trait;
14use bytes::Bytes;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use crate::context::Context;
19use crate::plugins::resources::PluginResources;
20use crate::plugins::{create_plugin, Plugin, PluginOutput, PluginResult};
21
22/// Authenticates a request by trying a list of auth sub-plugins in order and
23/// accepting the first that succeeds.
24///
25/// Each sub-plugin is a fully-fledged [`Plugin`] instance; on a **clean
26/// success** (no named exit port) it has already mutated the context (e.g.
27/// attached a consumer identity), so the winning sub-plugin's output is
28/// returned verbatim. Anything else a sub-plugin returns — a raw `Err`, or an
29/// `Ok` on an alternate outcome port such as a credential-auth plugin's
30/// `denied` — is treated as a failed attempt, not a match: `multi-auth` has
31/// no way to fan a single request out to more than one downstream route, so
32/// only a plain success can end the chain early. Sub-plugins run in the
33/// listed order; a later sub-plugin sees the context as left by prior
34/// *failed* attempts, except that the response is reset between attempts so
35/// a losing plugin's rejection body never leaks onto a subsequent success.
36/// Auth plugins generally mutate the context only on success (leaving
37/// request/message untouched on failure), so ordering is safe.
38///
39/// Only auth-type plugins are meaningful here, but the set is **not**
40/// hard-restricted — any registered plugin type may be listed, and non-auth
41/// plugins simply run as ordinary nodes whose success ends the chain.
42pub struct MultiAuthPlugin {
43    /// Sub-plugins tried in order; the first `Ok` wins.
44    sub_plugins: Vec<Box<dyn Plugin>>,
45}
46
47impl MultiAuthPlugin {
48    /// Builds the plugin from node config.
49    ///
50    /// Accepted keys:
51    /// - `auth_plugins` (array, required): each element is a **single-key map**
52    ///   `{plugin-type: {that plugin's config}}`. Every entry is instantiated
53    ///   through [`create_plugin`] at load time, so a bad sub-config (or an
54    ///   unknown plugin type) fails fast here rather than at request time.
55    ///   APISIX conventionally requires at least two entries; featherbit only
56    ///   requires the array to be non-empty.
57    ///
58    /// ```yaml
59    /// type: multi-auth
60    /// config:
61    ///   auth_plugins:
62    ///     - key-auth:
63    ///         use_consumers: true
64    ///     - basic-auth:
65    ///         use_consumers: true
66    /// ```
67    pub fn from_config(
68        config: &HashMap<String, serde_json::Value>,
69        resources: &Arc<PluginResources>,
70    ) -> Result<Self, String> {
71        let entries = config
72            .get("auth_plugins")
73            .and_then(|v| v.as_array())
74            .ok_or("multi-auth plugin requires an 'auth_plugins' array")?;
75
76        if entries.is_empty() {
77            return Err("multi-auth 'auth_plugins' must not be empty".to_string());
78        }
79
80        let mut sub_plugins = Vec::with_capacity(entries.len());
81        for (idx, entry) in entries.iter().enumerate() {
82            let obj = entry.as_object().ok_or_else(|| {
83                format!(
84                    "multi-auth 'auth_plugins[{}]' must be a single-key map {{plugin-type: config}}",
85                    idx
86                )
87            })?;
88            if obj.len() != 1 {
89                return Err(format!(
90                    "multi-auth 'auth_plugins[{}]' must have exactly one key (the plugin type)",
91                    idx
92                ));
93            }
94            let (plugin_type, inner) = obj.iter().next().unwrap();
95            let inner_config: HashMap<String, serde_json::Value> = inner
96                .as_object()
97                .map(|m| m.clone().into_iter().collect())
98                .unwrap_or_default();
99            let plugin = create_plugin(plugin_type, &inner_config, resources)
100                .map_err(|e| format!("multi-auth sub-plugin '{}': {}", plugin_type, e))?;
101            sub_plugins.push(plugin);
102        }
103
104        Ok(Self { sub_plugins })
105    }
106
107    /// Builds the 401 rejection returned when every sub-plugin failed, and
108    /// exits on the `denied` port.
109    fn reject(ctx: Context) -> PluginResult {
110        let mut ctx = ctx;
111        ctx.response.status_code = 401;
112        ctx.response.body =
113            Bytes::from(r#"{"error": "unauthorized", "message": "Authorization Failed"}"#);
114        ctx.response.headers.insert(
115            "content-type".to_string(),
116            vec!["application/json".to_string()],
117        );
118        Ok(PluginOutput::on_port(ctx, "denied"))
119    }
120}
121
122#[async_trait]
123impl Plugin for MultiAuthPlugin {
124    fn plugin_type(&self) -> &str {
125        "multi-auth"
126    }
127
128    async fn execute(&self, ctx: Context) -> PluginResult {
129        // Snapshot the pristine response so a failed attempt's rejection body
130        // never leaks onto the request if a later attempt succeeds.
131        // `GatewayResponse::clone()` always drops any `stream` (sets it to
132        // `None`), so restoring from this snapshot below discards a stream a
133        // sub-plugin may have set — safe only because `multi-auth` does not
134        // opt out of `Plugin::reads_response_body()` (defaults to `true`),
135        // so the policy compiler never marks an upstream stream-capable when
136        // a `multi-auth` node sits downstream — a stream can never reach here.
137        let original_response = ctx.response.clone();
138        let mut ctx = ctx;
139
140        for sub in &self.sub_plugins {
141            match sub.execute(ctx).await {
142                // A clean success ends the chain.
143                Ok(output) if output.port.is_none() => return Ok(output),
144                // Anything else — a deliberate alternate outcome (e.g. a
145                // credential-auth sub-plugin's `denied`) or a raw `Err` — is
146                // treated as a failed attempt: reset the response (so a
147                // losing rejection body never leaks onto a later success)
148                // and try the next sub-plugin.
149                Ok(output) => {
150                    ctx = output.context;
151                    ctx.response = original_response.clone();
152                }
153                Err(err) => {
154                    ctx = err.context;
155                    ctx.response = original_response.clone();
156                }
157            }
158        }
159
160        Self::reject(ctx)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::consumers::{ConsumerConfig, ConsumerStore};
168    use crate::context::{GatewayRequest, Protocol};
169    use base64::Engine;
170
171    fn ctx_with_key(key: Option<&str>) -> Context {
172        let mut headers = HashMap::new();
173        if let Some(k) = key {
174            headers.insert("x-api-key".to_string(), vec![k.to_string()]);
175        }
176        Context::new(GatewayRequest {
177            method: "GET".into(),
178            path: "/".into(),
179            host: "h".into(),
180            scheme: "http".into(),
181            headers,
182            query_params: HashMap::new(),
183            body: Bytes::new(),
184            remote_addr: "1.2.3.4:5".into(),
185            protocol: Protocol::Http1,
186        })
187    }
188
189    /// Two key-auth sub-plugins accepting disjoint key sets.
190    fn two_key_auth_config() -> HashMap<String, serde_json::Value> {
191        let mut config = HashMap::new();
192        config.insert(
193            "auth_plugins".to_string(),
194            serde_json::json!([
195                { "key-auth": { "keys": ["alpha"] } },
196                { "key-auth": { "keys": ["beta"] } }
197            ]),
198        );
199        config
200    }
201
202    #[tokio::test]
203    async fn test_second_sub_plugin_succeeds() {
204        let plugin =
205            MultiAuthPlugin::from_config(&two_key_auth_config(), &PluginResources::empty())
206                .unwrap();
207        // "beta" fails the first key-auth (which now exits Ok on the `denied`
208        // port, not Err) but passes the second -> a clean success.
209        let out = plugin.execute(ctx_with_key(Some("beta"))).await.unwrap();
210        assert_eq!(out.port, None);
211        // A prior failed attempt must not leave a 401 body behind.
212        assert_eq!(out.context.response.status_code, 0);
213    }
214
215    /// Pins the short-circuit: once the first sub-plugin returns a clean
216    /// success, the loop must return immediately and never even invoke later
217    /// sub-plugins. Proven by giving the second sub-plugin a mutation the
218    /// first cannot produce (attaching a consumer identity via the consumer
219    /// store) and asserting it never lands on the context.
220    #[tokio::test]
221    async fn test_first_sub_plugin_short_circuits_the_chain() {
222        let resources = PluginResources::empty();
223        let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
224            {
225                "name": "eve",
226                "credentials": { "key-auth": { "key": "alpha" } }
227            }
228        ]))
229        .unwrap();
230        resources
231            .consumers
232            .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
233
234        let mut config = HashMap::new();
235        config.insert(
236            "auth_plugins".to_string(),
237            serde_json::json!([
238                // Matches "alpha" via its inline key list -- no consumer attach.
239                { "key-auth": { "keys": ["alpha"] } },
240                // Would ALSO match "alpha" (via the consumer store above) and
241                // attach the "eve" identity, if it ever ran.
242                { "key-auth": { "use_consumers": true } },
243            ]),
244        );
245        let plugin = MultiAuthPlugin::from_config(&config, &resources).unwrap();
246
247        let out = plugin.execute(ctx_with_key(Some("alpha"))).await.unwrap();
248        assert_eq!(out.port, None);
249        // If the second sub-plugin had run, this would be `Some("eve")`.
250        assert_eq!(out.context.message.get("consumer.name"), None);
251    }
252
253    /// A sub-plugin's genuine infrastructure failure (not a deliberate denial)
254    /// must be swallowed as a failed attempt, same as a deliberate `denied`,
255    /// so the chain continues to the next sub-plugin instead of aborting the
256    /// whole node with an `Err`. Uses a real `ldap-auth` sub-plugin pointed at
257    /// a closed port so its bind attempt genuinely errors (connection
258    /// refused) rather than being rejected up front for a missing/malformed
259    /// credential.
260    #[tokio::test]
261    async fn test_mid_chain_infra_failure_is_absorbed_not_propagated() {
262        let mut config = HashMap::new();
263        config.insert(
264            "auth_plugins".to_string(),
265            serde_json::json!([
266                {
267                    "ldap-auth": {
268                        "base_dn": "dc=example,dc=org",
269                        "ldap_uri": "ldap://127.0.0.1:1",
270                        "timeout_ms": 200,
271                    }
272                },
273                { "key-auth": { "keys": ["nomatch"] } },
274            ]),
275        );
276        let plugin = MultiAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
277
278        // A well-formed Basic credential so ldap-auth gets past its own
279        // up-front validation and actually attempts the (failing) network
280        // bind, instead of rejecting before ever touching the network.
281        let mut headers = HashMap::new();
282        headers.insert(
283            "authorization".to_string(),
284            vec![format!(
285                "Basic {}",
286                base64::engine::general_purpose::STANDARD.encode("alice:secret")
287            )],
288        );
289        let ctx = Context::new(GatewayRequest {
290            method: "GET".into(),
291            path: "/".into(),
292            host: "h".into(),
293            scheme: "http".into(),
294            headers,
295            query_params: HashMap::new(),
296            body: Bytes::new(),
297            remote_addr: "1.2.3.4:5".into(),
298            protocol: Protocol::Http1,
299        });
300
301        // The ldap-auth sub-plugin's connection error is absorbed as a failed
302        // attempt (not propagated as multi-auth's own `Err`); key-auth then
303        // also denies (no matching key); every sub-plugin is exhausted, so
304        // the result is multi-auth's own `denied` 401 -- not an `Err`.
305        let out = plugin.execute(ctx).await.unwrap();
306        assert_eq!(out.port, Some("denied"));
307        assert_eq!(out.context.response.status_code, 401);
308    }
309
310    #[tokio::test]
311    async fn test_all_fail_rejects_401() {
312        let plugin =
313            MultiAuthPlugin::from_config(&two_key_auth_config(), &PluginResources::empty())
314                .unwrap();
315        let out = plugin.execute(ctx_with_key(Some("gamma"))).await.unwrap();
316        assert_eq!(out.port, Some("denied"));
317        assert_eq!(out.context.response.status_code, 401);
318    }
319
320    #[test]
321    fn test_requires_auth_plugins() {
322        assert!(MultiAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
323    }
324
325    #[test]
326    fn test_rejects_bad_sub_config() {
327        // A sub-plugin whose config is invalid fails fast at load.
328        let mut config = HashMap::new();
329        config.insert(
330            "auth_plugins".to_string(),
331            serde_json::json!([{ "key-auth": {} }]),
332        );
333        assert!(MultiAuthPlugin::from_config(&config, &PluginResources::empty()).is_err());
334    }
335}