featherbit/plugins/native/
basic_auth.rs1use 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
20pub struct BasicAuthPlugin {
35 users: HashMap<String, String>,
37 realm: Template,
41 use_consumers: bool,
43 anonymous_consumer: Option<String>,
45 hide_credentials: bool,
47 resources: Arc<PluginResources>,
48}
49
50impl BasicAuthPlugin {
51 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 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 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 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 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 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 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
230fn 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; }
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 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 #[test]
352 fn test_parse_users_accepts_both_shapes() {
353 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 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 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 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 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 let out = plugin
425 .execute(ctx_with_auth(Some(&basic("alice", "nope"))))
426 .await
427 .unwrap();
428 assert_eq!(out.port, Some("denied"));
429 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 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 assert!(!ctx.request.headers.contains_key("authorization"));
497
498 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}