Skip to main content

featherbit/plugins/native/
key_auth.rs

1//! API-key authentication plugin (`key-auth`).
2//!
3//! Checks a request header (and optionally a query parameter as fallback)
4//! against a static list of valid keys and/or the shared consumer store;
5//! unmatched requests are rejected with a 401 error routed through the
6//! node's error port.
7
8use 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
18/// Authenticates requests by matching an API key against a configured list
19/// and/or the consumer store.
20///
21/// With inline `keys`, a valid key simply lets the request continue. With
22/// `use_consumers: true`, the key is resolved against the gateway's
23/// `consumers:` section (their `key-auth: {key}` credentials); on a match the
24/// consumer's identity is attached to the request (`consumer.*` keys in
25/// `context.message` plus `X-Consumer-*` headers) for downstream nodes. Both
26/// sources may be enabled together — inline keys are checked first.
27pub struct KeyAuthPlugin {
28    /// Exact-match list of accepted API keys.
29    valid_keys: Vec<String>,
30    /// Lowercased name of the header the key is read from.
31    header_name: String,
32    /// Optional query parameter checked when the header is absent.
33    query_param: Option<String>,
34    /// When true, keys are also resolved against the consumer store.
35    use_consumers: bool,
36    /// Consumer attached when no credential matches (instead of rejecting).
37    anonymous_consumer: Option<String>,
38    /// When true, the matched header/query parameter is removed before the
39    /// request is forwarded upstream.
40    hide_credentials: bool,
41    resources: Arc<PluginResources>,
42}
43
44impl KeyAuthPlugin {
45    /// Builds the plugin from node config.
46    ///
47    /// Accepted keys:
48    /// - `keys` (array of strings): inline valid API keys.
49    /// - `use_consumers` (bool, default `false`): also resolve keys against
50    ///   the gateway's `consumers:` section and attach the matched consumer.
51    /// - At least one of `keys` / `use_consumers` must be provided.
52    /// - `header_name` (string, default `"x-api-key"`): header to read the
53    ///   key from (lowercased).
54    /// - `query_param` (string, optional): query parameter used as a fallback
55    ///   when the header is missing; no fallback if unset.
56    /// - `anonymous_consumer` (string, optional): consumer name attached when
57    ///   no credential matches, instead of rejecting (APISIX semantics).
58    /// - `hide_credentials` (bool, default `false`): strip the key
59    ///   header/query parameter before proxying upstream.
60    ///
61    /// ```yaml
62    /// type: key-auth
63    /// config:
64    ///   use_consumers: true
65    ///   header_name: x-api-key
66    ///   query_param: api_key
67    ///   hide_credentials: true
68    /// ```
69    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    /// Builds the 401 rejection with a JSON error body and returns a
125    /// `PluginExecutionError` (code `UNAUTHORIZED`) carrying the context so
126    /// the graph engine routes through the error port.
127    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    /// Removes the credential from the request (per `hide_credentials`).
148    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        // Try header first
168        let key = ctx
169            .request
170            .headers
171            .get(&self.header_name)
172            .and_then(|v| v.first())
173            .cloned();
174
175        // Fall back to query param
176        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        // Inline keys first.
187        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        // Consumer store.
200        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        // Anonymous fallback.
217        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        // hide_credentials stripped the key header
303        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}