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, GatewayError};
15use crate::plugins::resources::PluginResources;
16use crate::plugins::{Plugin, PluginExecutionError, 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 {
128 let mut ctx = ctx;
129 ctx.response.status_code = 401;
130 ctx.response.body =
131 Bytes::from(r#"{"error": "unauthorized", "message": "Invalid or missing API key"}"#);
132 ctx.response.headers.insert(
133 "content-type".to_string(),
134 vec!["application/json".to_string()],
135 );
136 Err(PluginExecutionError {
137 context: ctx,
138 error: GatewayError {
139 node_id: String::new(),
140 code: "UNAUTHORIZED".to_string(),
141 message: "Invalid or missing API key".to_string(),
142 metadata: HashMap::new(),
143 },
144 })
145 }
146
147 fn strip_credential(&self, ctx: &mut Context) {
149 ctx.request.headers.remove(&self.header_name);
150 if let Some(ref param) = self.query_param {
151 ctx.request.query_params.remove(param);
152 }
153 }
154}
155
156#[async_trait]
157impl Plugin for KeyAuthPlugin {
158 fn plugin_type(&self) -> &str {
159 "key-auth"
160 }
161
162 async fn execute(
163 &self,
164 mut ctx: Context,
165 _named_inputs: &HashMap<String, serde_json::Value>,
166 ) -> PluginResult {
167 let key = ctx
169 .request
170 .headers
171 .get(&self.header_name)
172 .and_then(|v| v.first())
173 .cloned();
174
175 let key = key.or_else(|| {
177 self.query_param.as_ref().and_then(|param| {
178 ctx.request
179 .query_params
180 .get(param)
181 .and_then(|v| v.first())
182 .cloned()
183 })
184 });
185
186 if let Some(ref k) = key {
188 if self.valid_keys.contains(k) {
189 if self.hide_credentials {
190 self.strip_credential(&mut ctx);
191 }
192 return Ok(PluginOutput {
193 context: ctx,
194 named_outputs: HashMap::new(),
195 });
196 }
197 }
198
199 if self.use_consumers {
201 if let Some(ref k) = key {
202 let store = self.resources.consumers.load();
203 if let Some(consumer) = store.find_by_credential("key-auth", k) {
204 if self.hide_credentials {
205 self.strip_credential(&mut ctx);
206 }
207 attach_consumer(&mut ctx, &consumer, "key-auth");
208 return Ok(PluginOutput {
209 context: ctx,
210 named_outputs: HashMap::new(),
211 });
212 }
213 }
214 }
215
216 if let Some(ref name) = self.anonymous_consumer {
218 let store = self.resources.consumers.load();
219 if let Some(consumer) = store.get(name) {
220 attach_consumer(&mut ctx, &consumer, "key-auth");
221 return Ok(PluginOutput {
222 context: ctx,
223 named_outputs: HashMap::new(),
224 });
225 }
226 }
227
228 Self::reject(ctx)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::consumers::{ConsumerConfig, ConsumerStore};
236 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
237
238 fn ctx_with_key(key: Option<&str>) -> Context {
239 let mut headers = HashMap::new();
240 if let Some(k) = key {
241 headers.insert("x-api-key".to_string(), vec![k.to_string()]);
242 }
243 Context {
244 request: GatewayRequest {
245 method: "GET".to_string(),
246 path: "/".to_string(),
247 host: "h".to_string(),
248 scheme: "http".to_string(),
249 headers,
250 query_params: HashMap::new(),
251 body: Bytes::new(),
252 remote_addr: "1.2.3.4:5".to_string(),
253 protocol: Protocol::Http1,
254 },
255 response: GatewayResponse {
256 status_code: 0,
257 headers: HashMap::new(),
258 body: Bytes::new(),
259 },
260 message: HashMap::new(),
261 errors: Vec::new(),
262 }
263 }
264
265 fn resources_with_consumers() -> Arc<PluginResources> {
266 let resources = PluginResources::empty();
267 let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
268 {
269 "name": "alice",
270 "credentials": { "key-auth": { "key": "alice-key" } }
271 },
272 { "name": "guest" }
273 ]))
274 .unwrap();
275 resources
276 .consumers
277 .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
278 resources
279 }
280
281 #[tokio::test]
282 async fn test_consumer_key_attaches_identity() {
283 let resources = resources_with_consumers();
284 let mut config = HashMap::new();
285 config.insert("use_consumers".to_string(), serde_json::json!(true));
286 config.insert("hide_credentials".to_string(), serde_json::json!(true));
287 let plugin = KeyAuthPlugin::from_config(&config, &resources).unwrap();
288
289 let result = plugin
290 .execute(ctx_with_key(Some("alice-key")), &HashMap::new())
291 .await
292 .unwrap();
293 let ctx = result.context;
294 assert_eq!(
295 ctx.message.get("consumer.name"),
296 Some(&serde_json::json!("alice"))
297 );
298 assert_eq!(
299 ctx.request.headers.get("x-consumer-username"),
300 Some(&vec!["alice".to_string()])
301 );
302 assert!(!ctx.request.headers.contains_key("x-api-key"));
304 }
305
306 #[tokio::test]
307 async fn test_unknown_key_rejected_or_anonymous() {
308 let resources = resources_with_consumers();
309 let mut config = HashMap::new();
310 config.insert("use_consumers".to_string(), serde_json::json!(true));
311 let plugin = KeyAuthPlugin::from_config(&config, &resources).unwrap();
312 assert!(plugin
313 .execute(ctx_with_key(Some("wrong")), &HashMap::new())
314 .await
315 .is_err());
316
317 let mut config = HashMap::new();
318 config.insert("use_consumers".to_string(), serde_json::json!(true));
319 config.insert("anonymous_consumer".to_string(), serde_json::json!("guest"));
320 let plugin = KeyAuthPlugin::from_config(&config, &resources).unwrap();
321 let result = plugin
322 .execute(ctx_with_key(None), &HashMap::new())
323 .await
324 .unwrap();
325 assert_eq!(
326 result.context.message.get("consumer.name"),
327 Some(&serde_json::json!("guest"))
328 );
329 }
330
331 #[tokio::test]
332 async fn test_inline_keys_still_work() {
333 let mut config = HashMap::new();
334 config.insert("keys".to_string(), serde_json::json!(["k1"]));
335 let plugin = KeyAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
336 assert!(plugin
337 .execute(ctx_with_key(Some("k1")), &HashMap::new())
338 .await
339 .is_ok());
340 assert!(plugin
341 .execute(ctx_with_key(Some("k2")), &HashMap::new())
342 .await
343 .is_err());
344 }
345
346 #[test]
347 fn test_requires_keys_or_consumers() {
348 assert!(KeyAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
349 }
350}