Skip to main content

featherbit/plugins/native/
basic_auth.rs

1//! HTTP Basic authentication plugin (`basic-auth`).
2//!
3//! Validates the `Authorization: Basic ...` header against a static user map
4//! and/or the shared consumer store, and rejects unauthenticated requests with
5//! a 401 challenge so the graph engine routes through the node's error port.
6
7use async_trait::async_trait;
8use base64::engine::general_purpose::STANDARD;
9use base64::Engine;
10use bytes::Bytes;
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use crate::consumers::attach_consumer;
15use crate::context::{Context, GatewayError};
16use crate::plugins::resources::PluginResources;
17use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
18
19/// Authenticates requests using HTTP Basic credentials checked against a
20/// configured username/password map and/or the consumer store.
21///
22/// With inline `users`, a matching `username:password` pair simply lets the
23/// request continue. With `use_consumers: true`, the username is resolved
24/// against the gateway's `consumers:` section (their
25/// `basic-auth: {username, password}` credentials) and the presented password
26/// is checked against the matched consumer's stored password; on success the
27/// consumer's identity is attached to the request (`consumer.*` keys in
28/// `context.message` plus `X-Consumer-*` headers) for downstream nodes. Both
29/// sources may be enabled together — inline users are checked first. For
30/// back-compat the authenticated username is always written to
31/// `context.message["user"]`. On failure the request is rejected with a 401
32/// response carrying a `WWW-Authenticate: Basic` challenge.
33pub struct BasicAuthPlugin {
34    /// Username -> plaintext password map the credentials are checked against.
35    users: HashMap<String, String>,
36    /// Realm advertised in the `WWW-Authenticate` challenge header.
37    realm: String,
38    /// When true, credentials are also resolved against the consumer store.
39    use_consumers: bool,
40    /// Consumer attached when no credential matches (instead of rejecting).
41    anonymous_consumer: Option<String>,
42    /// When true, the `Authorization` header is removed before proxying.
43    hide_credentials: bool,
44    resources: Arc<PluginResources>,
45}
46
47impl BasicAuthPlugin {
48    /// Builds the plugin from node config.
49    ///
50    /// Accepted keys:
51    /// - `users` (object, optional): map of username to plaintext password.
52    /// - `use_consumers` (bool, default `false`): also resolve credentials
53    ///   against the gateway's `consumers:` section and attach the matched
54    ///   consumer.
55    /// - At least one of `users` / `use_consumers` must be provided.
56    /// - `realm` (string, default `"gateway"`): realm used in the
57    ///   `WWW-Authenticate` challenge.
58    /// - `anonymous_consumer` (string, optional): consumer name attached when
59    ///   no credential matches, instead of rejecting (APISIX semantics).
60    /// - `hide_credentials` (bool, default `false`): strip the `Authorization`
61    ///   header before proxying upstream.
62    ///
63    /// ```yaml
64    /// type: basic-auth
65    /// config:
66    ///   use_consumers: true
67    ///   realm: internal-api
68    ///   hide_credentials: true
69    /// ```
70    pub fn from_config(
71        config: &HashMap<String, serde_json::Value>,
72        resources: &Arc<PluginResources>,
73    ) -> Result<Self, String> {
74        let users = parse_users(config.get("users"))?;
75
76        let use_consumers = config
77            .get("use_consumers")
78            .and_then(|v| v.as_bool())
79            .unwrap_or(false);
80
81        if users.is_empty() && !use_consumers {
82            return Err("basic-auth plugin requires 'users' or 'use_consumers: true'".to_string());
83        }
84
85        let realm = config
86            .get("realm")
87            .and_then(|v| v.as_str())
88            .unwrap_or("gateway")
89            .to_string();
90
91        let anonymous_consumer = config
92            .get("anonymous_consumer")
93            .and_then(|v| v.as_str())
94            .map(String::from);
95
96        let hide_credentials = config
97            .get("hide_credentials")
98            .and_then(|v| v.as_bool())
99            .unwrap_or(false);
100
101        Ok(Self {
102            users,
103            realm,
104            use_consumers,
105            anonymous_consumer,
106            hide_credentials,
107            resources: resources.clone(),
108        })
109    }
110
111    /// Builds the 401 rejection: sets a JSON error body plus the
112    /// `WWW-Authenticate` challenge on the response and returns a
113    /// `PluginExecutionError` (code `UNAUTHORIZED`) carrying the context so
114    /// the graph engine routes through the error port.
115    fn reject(&self, ctx: Context) -> PluginResult {
116        let mut ctx = ctx;
117        ctx.response.status_code = 401;
118        ctx.response.body =
119            Bytes::from(r#"{"error": "unauthorized", "message": "Invalid credentials"}"#);
120        ctx.response.headers.insert(
121            "content-type".to_string(),
122            vec!["application/json".to_string()],
123        );
124        ctx.response.headers.insert(
125            "www-authenticate".to_string(),
126            vec![format!("Basic realm=\"{}\"", self.realm)],
127        );
128        Err(PluginExecutionError {
129            context: ctx,
130            error: GatewayError {
131                node_id: String::new(),
132                code: "UNAUTHORIZED".to_string(),
133                message: "Invalid credentials".to_string(),
134                metadata: HashMap::new(),
135            },
136        })
137    }
138
139    /// Removes the `Authorization` header (per `hide_credentials`).
140    fn strip_credential(&self, ctx: &mut Context) {
141        ctx.request.headers.remove("authorization");
142    }
143}
144
145#[async_trait]
146impl Plugin for BasicAuthPlugin {
147    fn plugin_type(&self) -> &str {
148        "basic-auth"
149    }
150
151    async fn execute(
152        &self,
153        mut ctx: Context,
154        _named_inputs: &HashMap<String, serde_json::Value>,
155    ) -> PluginResult {
156        let auth_header = ctx
157            .request
158            .headers
159            .get("authorization")
160            .and_then(|v| v.first())
161            .cloned();
162
163        let credentials = match auth_header {
164            Some(h) if h.starts_with("Basic ") => STANDARD
165                .decode(&h[6..])
166                .ok()
167                .and_then(|b| String::from_utf8(b).ok()),
168            _ => None,
169        };
170
171        let parsed = credentials
172            .as_deref()
173            .and_then(|c| c.split_once(':'))
174            .map(|(u, p)| (u.to_string(), p.to_string()));
175
176        // Inline users first.
177        if let Some((ref username, ref password)) = parsed {
178            if let Some(expected) = self.users.get(username) {
179                if expected == password {
180                    if self.hide_credentials {
181                        self.strip_credential(&mut ctx);
182                    }
183                    ctx.message.insert(
184                        "user".to_string(),
185                        serde_json::Value::String(username.clone()),
186                    );
187                    return Ok(PluginOutput {
188                        context: ctx,
189                        named_outputs: HashMap::new(),
190                    });
191                }
192            }
193        }
194
195        // Consumer store: resolve by username, then verify the password
196        // against the matched consumer's stored basic-auth credential.
197        if self.use_consumers {
198            if let Some((ref username, ref password)) = parsed {
199                let store = self.resources.consumers.load();
200                if let Some(consumer) = store.find_by_credential("basic-auth", username) {
201                    let expected = consumer
202                        .credentials
203                        .get("basic-auth")
204                        .and_then(|c| c.get("password"))
205                        .and_then(|v| v.as_str());
206                    if expected == Some(password.as_str()) {
207                        if self.hide_credentials {
208                            self.strip_credential(&mut ctx);
209                        }
210                        attach_consumer(&mut ctx, &consumer, "basic-auth");
211                        ctx.message.insert(
212                            "user".to_string(),
213                            serde_json::Value::String(username.clone()),
214                        );
215                        return Ok(PluginOutput {
216                            context: ctx,
217                            named_outputs: HashMap::new(),
218                        });
219                    }
220                }
221            }
222        }
223
224        // Anonymous fallback.
225        if let Some(ref name) = self.anonymous_consumer {
226            let store = self.resources.consumers.load();
227            if let Some(consumer) = store.get(name) {
228                attach_consumer(&mut ctx, &consumer, "basic-auth");
229                ctx.message.insert(
230                    "user".to_string(),
231                    serde_json::Value::String(consumer.name.clone()),
232                );
233                return Ok(PluginOutput {
234                    context: ctx,
235                    named_outputs: HashMap::new(),
236                });
237            }
238        }
239
240        self.reject(ctx)
241    }
242}
243
244/// Parses the `users` config into a `username -> password` map, accepting either
245/// shape:
246///
247/// - a **map** (hand-written YAML): `users: { alice: s3cret, bob: hunter2 }`
248/// - an **array of objects** (what the web UI's node editor emits for this
249///   field): `users: [{ username: alice, password: s3cret }, ...]`
250///
251/// The web UI serializes repeated-object fields as arrays, so accepting only the
252/// map shape means a `basic-auth` node configured with users in the editor is
253/// rejected at save time. `proxy-rewrite`'s `add_headers` already handles both
254/// shapes for the same reason; this mirrors it. Blank rows left in the editor
255/// (empty username) are skipped.
256fn parse_users(raw: Option<&serde_json::Value>) -> Result<HashMap<String, String>, String> {
257    let Some(raw) = raw else {
258        return Ok(HashMap::new());
259    };
260
261    match raw {
262        serde_json::Value::Object(m) => Ok(m
263            .iter()
264            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
265            .collect()),
266        serde_json::Value::Array(items) => {
267            let mut users = HashMap::new();
268            for item in items {
269                let obj = item.as_object().ok_or(
270                    "basic-auth: 'users' entries must be objects with 'username' and 'password'",
271                )?;
272                let username = obj.get("username").and_then(|v| v.as_str()).unwrap_or("");
273                if username.trim().is_empty() {
274                    continue; // blank row left in the UI editor
275                }
276                let password = obj
277                    .get("password")
278                    .and_then(|v| v.as_str())
279                    .unwrap_or("")
280                    .to_string();
281                users.insert(username.to_string(), password);
282            }
283            Ok(users)
284        }
285        _ => Err(
286            "basic-auth: 'users' must be a map or an array of {username, password} objects"
287                .to_string(),
288        ),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::consumers::{ConsumerConfig, ConsumerStore};
296    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
297
298    fn ctx_with_auth(header: Option<&str>) -> Context {
299        let mut headers = HashMap::new();
300        if let Some(h) = header {
301            headers.insert("authorization".to_string(), vec![h.to_string()]);
302        }
303        Context {
304            request: GatewayRequest {
305                method: "GET".to_string(),
306                path: "/".to_string(),
307                host: "h".to_string(),
308                scheme: "http".to_string(),
309                headers,
310                query_params: HashMap::new(),
311                body: Bytes::new(),
312                remote_addr: "1.2.3.4:5".to_string(),
313                protocol: Protocol::Http1,
314            },
315            response: GatewayResponse {
316                status_code: 0,
317                headers: HashMap::new(),
318                body: Bytes::new(),
319            },
320            message: HashMap::new(),
321            errors: Vec::new(),
322        }
323    }
324
325    /// `Authorization: Basic <base64(user:pass)>` header value.
326    fn basic(user: &str, pass: &str) -> String {
327        format!("Basic {}", STANDARD.encode(format!("{}:{}", user, pass)))
328    }
329
330    fn resources_with_consumers() -> Arc<PluginResources> {
331        let resources = PluginResources::empty();
332        let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
333            {
334                "name": "alice",
335                "credentials": { "basic-auth": { "username": "alice", "password": "pw" } }
336            },
337            { "name": "guest" }
338        ]))
339        .unwrap();
340        resources
341            .consumers
342            .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
343        resources
344    }
345
346    fn inline_config() -> HashMap<String, serde_json::Value> {
347        let mut config = HashMap::new();
348        config.insert(
349            "users".to_string(),
350            serde_json::json!({ "alice": "s3cret", "bob": "hunter2" }),
351        );
352        config
353    }
354
355    #[test]
356    fn test_requires_users_or_consumers() {
357        assert!(BasicAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
358    }
359
360    /// The web UI's node editor serializes `users` as an array of
361    /// `{username, password}` objects. Regression guard: this used to be rejected
362    /// as empty (the parser only accepted a map), so a basic-auth node configured
363    /// with users in the UI could not be saved.
364    #[test]
365    fn test_parse_users_accepts_both_shapes() {
366        // Map shape (hand-written YAML).
367        let map = parse_users(Some(
368            &serde_json::json!({ "alice": "s3cret", "bob": "hunter2" }),
369        ))
370        .unwrap();
371        assert_eq!(map.get("alice"), Some(&"s3cret".to_string()));
372        assert_eq!(map.get("bob"), Some(&"hunter2".to_string()));
373
374        // Array shape (the web UI editor).
375        let arr = parse_users(Some(&serde_json::json!([
376            { "username": "alice", "password": "s3cret" },
377            { "username": "bob", "password": "hunter2" },
378        ])))
379        .unwrap();
380        assert_eq!(arr, map);
381
382        // Blank rows left in the editor are skipped, not treated as a user.
383        let with_blank = parse_users(Some(&serde_json::json!([
384            { "username": "alice", "password": "s3cret" },
385            { "username": "", "password": "" },
386        ])))
387        .unwrap();
388        assert_eq!(with_blank.len(), 1);
389        assert!(with_blank.contains_key("alice"));
390
391        // A wrong scalar shape is a clear error, not a silent empty map.
392        assert!(parse_users(Some(&serde_json::json!("nope"))).is_err());
393        assert!(parse_users(None).unwrap().is_empty());
394    }
395
396    #[tokio::test]
397    async fn test_ui_array_users_authenticate() {
398        // The exact shape the UI saves must produce a working plugin.
399        let mut config = HashMap::new();
400        config.insert(
401            "users".to_string(),
402            serde_json::json!([{ "username": "alice", "password": "s3cret" }]),
403        );
404        let plugin = BasicAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
405
406        let ok = plugin
407            .execute(
408                ctx_with_auth(Some(&basic("alice", "s3cret"))),
409                &HashMap::new(),
410            )
411            .await
412            .unwrap();
413        assert_eq!(
414            ok.context.message.get("user"),
415            Some(&serde_json::json!("alice"))
416        );
417
418        assert!(plugin
419            .execute(
420                ctx_with_auth(Some(&basic("alice", "wrong"))),
421                &HashMap::new()
422            )
423            .await
424            .is_err());
425    }
426
427    #[tokio::test]
428    async fn test_inline_users_still_work() {
429        let plugin =
430            BasicAuthPlugin::from_config(&inline_config(), &PluginResources::empty()).unwrap();
431
432        let ok = plugin
433            .execute(
434                ctx_with_auth(Some(&basic("alice", "s3cret"))),
435                &HashMap::new(),
436            )
437            .await
438            .unwrap();
439        assert_eq!(
440            ok.context.message.get("user"),
441            Some(&serde_json::json!("alice"))
442        );
443
444        // wrong password
445        assert!(plugin
446            .execute(
447                ctx_with_auth(Some(&basic("alice", "nope"))),
448                &HashMap::new()
449            )
450            .await
451            .is_err());
452        // missing header
453        assert!(plugin
454            .execute(ctx_with_auth(None), &HashMap::new())
455            .await
456            .is_err());
457    }
458
459    #[tokio::test]
460    async fn test_reject_sets_challenge() {
461        let mut config = inline_config();
462        config.insert("realm".to_string(), serde_json::json!("internal-api"));
463        let plugin = BasicAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
464
465        let err = plugin
466            .execute(ctx_with_auth(None), &HashMap::new())
467            .await
468            .unwrap_err();
469        assert_eq!(err.error.code, "UNAUTHORIZED");
470        assert_eq!(err.context.response.status_code, 401);
471        assert_eq!(
472            err.context.response.headers.get("www-authenticate"),
473            Some(&vec!["Basic realm=\"internal-api\"".to_string()])
474        );
475    }
476
477    #[tokio::test]
478    async fn test_consumer_credentials_attach_identity() {
479        let resources = resources_with_consumers();
480        let mut config = HashMap::new();
481        config.insert("use_consumers".to_string(), serde_json::json!(true));
482        config.insert("hide_credentials".to_string(), serde_json::json!(true));
483        let plugin = BasicAuthPlugin::from_config(&config, &resources).unwrap();
484
485        let out = plugin
486            .execute(ctx_with_auth(Some(&basic("alice", "pw"))), &HashMap::new())
487            .await
488            .unwrap();
489        let ctx = out.context;
490        assert_eq!(
491            ctx.message.get("consumer.name"),
492            Some(&serde_json::json!("alice"))
493        );
494        assert_eq!(ctx.message.get("user"), Some(&serde_json::json!("alice")));
495        assert_eq!(
496            ctx.request.headers.get("x-consumer-username"),
497            Some(&vec!["alice".to_string()])
498        );
499        // hide_credentials stripped the Authorization header
500        assert!(!ctx.request.headers.contains_key("authorization"));
501
502        // wrong password against a known consumer is rejected
503        assert!(plugin
504            .execute(
505                ctx_with_auth(Some(&basic("alice", "wrong"))),
506                &HashMap::new()
507            )
508            .await
509            .is_err());
510    }
511
512    #[tokio::test]
513    async fn test_anonymous_consumer_fallback() {
514        let resources = resources_with_consumers();
515        let mut config = HashMap::new();
516        config.insert("use_consumers".to_string(), serde_json::json!(true));
517        config.insert("anonymous_consumer".to_string(), serde_json::json!("guest"));
518        let plugin = BasicAuthPlugin::from_config(&config, &resources).unwrap();
519
520        let out = plugin
521            .execute(ctx_with_auth(None), &HashMap::new())
522            .await
523            .unwrap();
524        assert_eq!(
525            out.context.message.get("consumer.name"),
526            Some(&serde_json::json!("guest"))
527        );
528    }
529}