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, GatewayError};
16use crate::plugins::resources::PluginResources;
17use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
18
19pub struct BasicAuthPlugin {
34 users: HashMap<String, String>,
36 realm: String,
38 use_consumers: bool,
40 anonymous_consumer: Option<String>,
42 hide_credentials: bool,
44 resources: Arc<PluginResources>,
45}
46
47impl BasicAuthPlugin {
48 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 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 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 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 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 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
244fn 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; }
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 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 #[test]
365 fn test_parse_users_accepts_both_shapes() {
366 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 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 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 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 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 assert!(plugin
446 .execute(
447 ctx_with_auth(Some(&basic("alice", "nope"))),
448 &HashMap::new()
449 )
450 .await
451 .is_err());
452 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 assert!(!ctx.request.headers.contains_key("authorization"));
501
502 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}