featherbit/plugins/native/
key_auth.rs1use async_trait::async_trait;
9use bytes::Bytes;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use crate::consumers::attach_consumer;
14use crate::context::Context;
15use crate::plugins::resources::PluginResources;
16use crate::plugins::{Plugin, PluginOutput, PluginResult};
17
18pub struct KeyAuthPlugin {
28 valid_keys: Vec<String>,
30 header_name: String,
32 query_param: Option<String>,
34 use_consumers: bool,
36 anonymous_consumer: Option<String>,
38 hide_credentials: bool,
41 resources: Arc<PluginResources>,
42}
43
44impl KeyAuthPlugin {
45 pub fn from_config(
70 config: &HashMap<String, serde_json::Value>,
71 resources: &Arc<PluginResources>,
72 ) -> Result<Self, String> {
73 let valid_keys: Vec<String> = config
74 .get("keys")
75 .and_then(|v| v.as_array())
76 .map(|seq| {
77 seq.iter()
78 .filter_map(|v| v.as_str().map(String::from))
79 .collect()
80 })
81 .unwrap_or_default();
82
83 let use_consumers = config
84 .get("use_consumers")
85 .and_then(|v| v.as_bool())
86 .unwrap_or(false);
87
88 if valid_keys.is_empty() && !use_consumers {
89 return Err("key-auth plugin requires 'keys' or 'use_consumers: true'".to_string());
90 }
91
92 let header_name = config
93 .get("header_name")
94 .and_then(|v| v.as_str())
95 .unwrap_or("x-api-key")
96 .to_lowercase();
97
98 let query_param = config
99 .get("query_param")
100 .and_then(|v| v.as_str())
101 .map(String::from);
102
103 let anonymous_consumer = config
104 .get("anonymous_consumer")
105 .and_then(|v| v.as_str())
106 .map(String::from);
107
108 let hide_credentials = config
109 .get("hide_credentials")
110 .and_then(|v| v.as_bool())
111 .unwrap_or(false);
112
113 Ok(Self {
114 valid_keys,
115 header_name,
116 query_param,
117 use_consumers,
118 anonymous_consumer,
119 hide_credentials,
120 resources: resources.clone(),
121 })
122 }
123
124 fn reject(ctx: Context) -> PluginResult {
126 let mut ctx = ctx;
127 ctx.response.status_code = 401;
128 ctx.response.body =
129 Bytes::from(r#"{"error": "unauthorized", "message": "Invalid or missing API key"}"#);
130 ctx.response.headers.insert(
131 "content-type".to_string(),
132 vec!["application/json".to_string()],
133 );
134 Ok(PluginOutput::on_port(ctx, "denied"))
135 }
136
137 fn strip_credential(&self, ctx: &mut Context) {
139 ctx.request.headers.remove(&self.header_name);
140 if let Some(ref param) = self.query_param {
141 ctx.request.query_params.remove(param);
142 }
143 }
144}
145
146#[async_trait]
147impl Plugin for KeyAuthPlugin {
148 fn plugin_type(&self) -> &str {
149 "key-auth"
150 }
151
152 async fn execute(&self, mut ctx: Context) -> PluginResult {
153 let key = ctx
155 .request
156 .headers
157 .get(&self.header_name)
158 .and_then(|v| v.first())
159 .cloned();
160
161 let key = key.or_else(|| {
163 self.query_param.as_ref().and_then(|param| {
164 ctx.request
165 .query_params
166 .get(param)
167 .and_then(|v| v.first())
168 .cloned()
169 })
170 });
171
172 if let Some(ref k) = key {
174 if self.valid_keys.contains(k) {
175 if self.hide_credentials {
176 self.strip_credential(&mut ctx);
177 }
178 return Ok(PluginOutput::success(ctx));
179 }
180 }
181
182 if self.use_consumers {
184 if let Some(ref k) = key {
185 let store = self.resources.consumers.load();
186 if let Some(consumer) = store.find_by_credential("key-auth", k) {
187 if self.hide_credentials {
188 self.strip_credential(&mut ctx);
189 }
190 attach_consumer(&mut ctx, &consumer, "key-auth");
191 return Ok(PluginOutput::success(ctx));
192 }
193 }
194 }
195
196 if let Some(ref name) = self.anonymous_consumer {
198 let store = self.resources.consumers.load();
199 if let Some(consumer) = store.get(name) {
200 attach_consumer(&mut ctx, &consumer, "key-auth");
201 return Ok(PluginOutput::success(ctx));
202 }
203 }
204
205 Self::reject(ctx)
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::consumers::{ConsumerConfig, ConsumerStore};
213 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
214
215 fn ctx_with_key(key: Option<&str>) -> Context {
216 let mut headers = HashMap::new();
217 if let Some(k) = key {
218 headers.insert("x-api-key".to_string(), vec![k.to_string()]);
219 }
220 Context {
221 request: GatewayRequest {
222 method: "GET".to_string(),
223 path: "/".to_string(),
224 host: "h".to_string(),
225 scheme: "http".to_string(),
226 headers,
227 query_params: HashMap::new(),
228 body: Bytes::new(),
229 remote_addr: "1.2.3.4:5".to_string(),
230 protocol: Protocol::Http1,
231 },
232 response: GatewayResponse {
233 status_code: 0,
234 headers: HashMap::new(),
235 body: Bytes::new(),
236 stream: None,
237 },
238 message: HashMap::new(),
239 errors: Vec::new(),
240 }
241 }
242
243 fn resources_with_consumers() -> Arc<PluginResources> {
244 let resources = PluginResources::empty();
245 let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
246 {
247 "name": "alice",
248 "credentials": { "key-auth": { "key": "alice-key" } }
249 },
250 { "name": "guest" }
251 ]))
252 .unwrap();
253 resources
254 .consumers
255 .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
256 resources
257 }
258
259 #[tokio::test]
260 async fn test_consumer_key_attaches_identity() {
261 let resources = resources_with_consumers();
262 let mut config = HashMap::new();
263 config.insert("use_consumers".to_string(), serde_json::json!(true));
264 config.insert("hide_credentials".to_string(), serde_json::json!(true));
265 let plugin = KeyAuthPlugin::from_config(&config, &resources).unwrap();
266
267 let result = plugin
268 .execute(ctx_with_key(Some("alice-key")))
269 .await
270 .unwrap();
271 let ctx = result.context;
272 assert_eq!(
273 ctx.message.get("consumer.name"),
274 Some(&serde_json::json!("alice"))
275 );
276 assert_eq!(
277 ctx.request.headers.get("x-consumer-username"),
278 Some(&vec!["alice".to_string()])
279 );
280 assert!(!ctx.request.headers.contains_key("x-api-key"));
282 }
283
284 #[tokio::test]
285 async fn test_unknown_key_rejected_or_anonymous() {
286 let resources = resources_with_consumers();
287 let mut config = HashMap::new();
288 config.insert("use_consumers".to_string(), serde_json::json!(true));
289 let plugin = KeyAuthPlugin::from_config(&config, &resources).unwrap();
290 let out = plugin.execute(ctx_with_key(Some("wrong"))).await.unwrap();
291 assert_eq!(out.port, Some("denied"));
292 assert_eq!(out.context.response.status_code, 401);
293 assert_eq!(
294 out.context.response.body,
295 Bytes::from_static(
296 br#"{"error": "unauthorized", "message": "Invalid or missing API key"}"#
297 )
298 );
299
300 let mut config = HashMap::new();
301 config.insert("use_consumers".to_string(), serde_json::json!(true));
302 config.insert("anonymous_consumer".to_string(), serde_json::json!("guest"));
303 let plugin = KeyAuthPlugin::from_config(&config, &resources).unwrap();
304 let result = plugin.execute(ctx_with_key(None)).await.unwrap();
305 assert_eq!(
306 result.context.message.get("consumer.name"),
307 Some(&serde_json::json!("guest"))
308 );
309 }
310
311 #[tokio::test]
312 async fn test_inline_keys_still_work() {
313 let mut config = HashMap::new();
314 config.insert("keys".to_string(), serde_json::json!(["k1"]));
315 let plugin = KeyAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
316 let ok = plugin.execute(ctx_with_key(Some("k1"))).await.unwrap();
317 assert_eq!(ok.port, None);
318 let out = plugin.execute(ctx_with_key(Some("k2"))).await.unwrap();
319 assert_eq!(out.port, Some("denied"));
320 }
321
322 #[test]
323 fn test_requires_keys_or_consumers() {
324 assert!(KeyAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
325 }
326}