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 routed through the node's
6//! `denied` 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;
15use crate::plugins::resources::PluginResources;
16use crate::plugins::{Plugin, 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 and exits on the `denied` port.
125    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    /// Removes the credential from the request (per `hide_credentials`).
138    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        // Try header first
154        let key = ctx
155            .request
156            .headers
157            .get(&self.header_name)
158            .and_then(|v| v.first())
159            .cloned();
160
161        // Fall back to query param
162        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        // Inline keys first.
173        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        // Consumer store.
183        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        // Anonymous fallback.
197        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        // hide_credentials stripped the key header
281        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}