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}