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