Skip to main content

featherbit/plugins/native/
jwt_auth.rs

1//! JWT authentication plugin (`jwt-auth`).
2//!
3//! Validates HMAC-signed JWTs (HS256/HS384/HS512) taken from a configurable
4//! header, enforcing expiry, and exposes the verified claims to downstream
5//! nodes via `context.message`. Invalid or missing tokens are rejected with a
6//! 401 routed through the node's `denied` port.
7//!
8//! Two modes, usable together:
9//! - **inline secret** (`secret` set): every token is verified with one
10//!   shared secret and algorithm, exactly as before.
11//! - **consumer mode** (`use_consumers: true`): the token's `key` claim
12//!   identifies a consumer; the token is then verified with *that consumer's*
13//!   `jwt-auth: {key, secret, algorithm}` credential and, on success, the
14//!   consumer's identity is attached to the request.
15
16use async_trait::async_trait;
17use base64::engine::general_purpose::URL_SAFE_NO_PAD;
18use base64::Engine;
19use bytes::Bytes;
20use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use crate::consumers::attach_consumer;
25use crate::context::Context;
26use crate::plugins::resources::PluginResources;
27use crate::plugins::{Plugin, PluginOutput, PluginResult};
28
29/// Parses a supported HMAC algorithm name, defaulting to HS256 when absent.
30///
31/// Returns an error for any unsupported value — an auth plugin must not
32/// silently verify with a different algorithm than the one requested.
33fn parse_algorithm(name: Option<&str>) -> Result<Algorithm, String> {
34    match name {
35        None | Some("HS256") => Ok(Algorithm::HS256),
36        Some("HS384") => Ok(Algorithm::HS384),
37        Some("HS512") => Ok(Algorithm::HS512),
38        Some(other) => Err(format!(
39            "Unknown jwt-auth algorithm '{}' — supported: HS256, HS384, HS512",
40            other
41        )),
42    }
43}
44
45/// Authenticates requests by verifying a JWT signature and expiry (`exp`).
46///
47/// The token is read from the configured header (with an optional
48/// `Bearer ` prefix stripped). On success the plugin writes into
49/// `context.message`:
50/// - `"jwt_claims"`: the full decoded claims object
51/// - `"user_id"`: the `sub` claim, when present (convenience copy)
52///
53/// In consumer mode the matched consumer's identity is also attached
54/// (`consumer.*` message keys + `X-Consumer-*` headers).
55///
56/// On failure the request is rejected with a 401 JSON response routed
57/// through the node's `denied` port.
58pub struct JwtAuthPlugin {
59    /// Shared HMAC secret used to verify token signatures (inline mode).
60    /// `None` when only consumer mode is configured.
61    secret: Option<String>,
62    /// HMAC algorithm the token must be signed with in inline mode.
63    algorithm: Algorithm,
64    /// Lowercased name of the request header carrying the token.
65    header_name: String,
66    /// When true, tokens are also resolved against the consumer store via
67    /// their `key` claim and verified with the consumer's own secret.
68    use_consumers: bool,
69    resources: Arc<PluginResources>,
70}
71
72impl JwtAuthPlugin {
73    /// Builds the plugin from node config.
74    ///
75    /// Accepted keys:
76    /// - `secret` (string, optional): HMAC secret for inline signature
77    ///   verification. When set, every token is verified with it.
78    /// - `use_consumers` (bool, default `false`): resolve the token's `key`
79    ///   claim against the gateway's `consumers:` section
80    ///   (`jwt-auth: {key, secret, algorithm}`) and verify with the matched
81    ///   consumer's secret/algorithm, attaching the consumer on success.
82    /// - At least one of `secret` / `use_consumers` must be provided.
83    /// - `algorithm` (string, default `"HS256"`): one of `HS256`, `HS384`,
84    ///   `HS512` used for inline verification; any other value is a config
85    ///   error — an auth plugin must not silently verify with a different
86    ///   algorithm than the one requested.
87    /// - `header_name` (string, default `"authorization"`): header to read
88    ///   the token from (compared case-insensitively via lowercasing).
89    ///
90    /// ```yaml
91    /// type: jwt-auth
92    /// config:
93    ///   use_consumers: true
94    ///   header_name: authorization
95    /// ```
96    pub fn from_config(
97        config: &HashMap<String, serde_json::Value>,
98        resources: &Arc<PluginResources>,
99    ) -> Result<Self, String> {
100        let secret = config
101            .get("secret")
102            .and_then(|v| v.as_str())
103            .map(String::from);
104
105        let use_consumers = config
106            .get("use_consumers")
107            .and_then(|v| v.as_bool())
108            .unwrap_or(false);
109
110        if secret.is_none() && !use_consumers {
111            return Err("jwt-auth plugin requires 'secret' or 'use_consumers: true'".to_string());
112        }
113
114        let algorithm = parse_algorithm(config.get("algorithm").and_then(|v| v.as_str()))?;
115
116        let header_name = config
117            .get("header_name")
118            .and_then(|v| v.as_str())
119            .unwrap_or("authorization")
120            .to_lowercase();
121
122        Ok(Self {
123            secret,
124            algorithm,
125            header_name,
126            use_consumers,
127            resources: resources.clone(),
128        })
129    }
130
131    /// Builds the 401 rejection with a JSON error body and exits on the
132    /// `denied` port.
133    fn reject(ctx: Context, message: &str) -> PluginResult {
134        let mut ctx = ctx;
135        ctx.response.status_code = 401;
136        ctx.response.body = Bytes::from(format!(
137            r#"{{"error": "unauthorized", "message": "{}"}}"#,
138            message
139        ));
140        ctx.response.headers.insert(
141            "content-type".to_string(),
142            vec!["application/json".to_string()],
143        );
144        Ok(PluginOutput::on_port(ctx, "denied"))
145    }
146
147    /// Verifies `token` with `secret`/`algorithm` and, on success, writes the
148    /// claims into `context.message`. Returns the decoded claims on success.
149    fn verify(
150        token: &str,
151        secret: &str,
152        algorithm: Algorithm,
153        ctx: &mut Context,
154    ) -> Result<HashMap<String, serde_json::Value>, String> {
155        let key = DecodingKey::from_secret(secret.as_bytes());
156        let mut validation = Validation::new(algorithm);
157        validation.validate_exp = true;
158
159        match decode::<HashMap<String, serde_json::Value>>(token, &key, &validation) {
160            Ok(token_data) => {
161                ctx.message.insert(
162                    "jwt_claims".to_string(),
163                    serde_json::to_value(&token_data.claims).unwrap_or_default(),
164                );
165                if let Some(sub) = token_data.claims.get("sub") {
166                    ctx.message.insert("user_id".to_string(), sub.clone());
167                }
168                Ok(token_data.claims)
169            }
170            Err(e) => Err(format!("Invalid JWT: {}", e)),
171        }
172    }
173
174    /// Reads the `key` claim from an *unverified* token payload.
175    ///
176    /// The payload segment is base64url-decoded and parsed as JSON purely to
177    /// discover which consumer to look up; the signature is verified only
178    /// afterwards with that consumer's secret, so no trust is placed in this
179    /// value.
180    fn peek_key_claim(token: &str) -> Option<String> {
181        let payload = token.split('.').nth(1)?;
182        let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
183        let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
184        claims.get("key")?.as_str().map(String::from)
185    }
186}
187
188#[async_trait]
189impl Plugin for JwtAuthPlugin {
190    fn plugin_type(&self) -> &str {
191        "jwt-auth"
192    }
193
194    async fn execute(&self, mut ctx: Context) -> PluginResult {
195        let token = ctx
196            .request
197            .headers
198            .get(&self.header_name)
199            .and_then(|v| v.first())
200            .map(|v| {
201                v.strip_prefix("Bearer ")
202                    .map(String::from)
203                    .unwrap_or_else(|| v.clone())
204            });
205
206        let token = match token {
207            Some(t) => t,
208            None => return Self::reject(ctx, "Missing authorization token"),
209        };
210
211        // Inline secret mode first.
212        if let Some(ref secret) = self.secret {
213            match Self::verify(&token, secret, self.algorithm, &mut ctx) {
214                Ok(_) => return Ok(PluginOutput::success(ctx)),
215                // With consumers also enabled, fall through and try them;
216                // otherwise reject now.
217                Err(e) if !self.use_consumers => return Self::reject(ctx, &e),
218                Err(_) => {}
219            }
220        }
221
222        // Consumer mode: the `key` claim selects the consumer, whose stored
223        // secret/algorithm then verifies the signature.
224        if self.use_consumers {
225            let key_claim = match Self::peek_key_claim(&token) {
226                Some(k) => k,
227                None => return Self::reject(ctx, "JWT missing 'key' claim"),
228            };
229            let store = self.resources.consumers.load();
230            if let Some(consumer) = store.find_by_credential("jwt-auth", &key_claim) {
231                let cred = consumer.credentials.get("jwt-auth");
232                let secret = cred.and_then(|c| c.get("secret")).and_then(|v| v.as_str());
233                let algorithm = parse_algorithm(
234                    cred.and_then(|c| c.get("algorithm"))
235                        .and_then(|v| v.as_str()),
236                );
237                match (secret, algorithm) {
238                    (Some(secret), Ok(algorithm)) => {
239                        match Self::verify(&token, secret, algorithm, &mut ctx) {
240                            Ok(_) => {
241                                attach_consumer(&mut ctx, &consumer, "jwt-auth");
242                                return Ok(PluginOutput::success(ctx));
243                            }
244                            Err(e) => return Self::reject(ctx, &e),
245                        }
246                    }
247                    _ => return Self::reject(ctx, "Consumer has no valid jwt-auth secret"),
248                }
249            }
250            return Self::reject(ctx, "Unknown JWT key");
251        }
252
253        Self::reject(ctx, "Invalid JWT")
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::consumers::{ConsumerConfig, ConsumerStore};
261    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
262    use jsonwebtoken::{encode, EncodingKey, Header};
263
264    fn config(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
265        pairs
266            .iter()
267            .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
268            .collect()
269    }
270
271    fn ctx_with_token(token: Option<&str>) -> Context {
272        let mut headers = HashMap::new();
273        if let Some(t) = token {
274            headers.insert("authorization".to_string(), vec![format!("Bearer {}", t)]);
275        }
276        Context {
277            request: GatewayRequest {
278                method: "GET".to_string(),
279                path: "/".to_string(),
280                host: "h".to_string(),
281                scheme: "http".to_string(),
282                headers,
283                query_params: HashMap::new(),
284                body: Bytes::new(),
285                remote_addr: "1.2.3.4:5".to_string(),
286                protocol: Protocol::Http1,
287            },
288            response: GatewayResponse {
289                status_code: 0,
290                headers: HashMap::new(),
291                body: Bytes::new(),
292                stream: None,
293            },
294            message: HashMap::new(),
295            errors: Vec::new(),
296        }
297    }
298
299    /// Signs `claims` with `secret` and `alg`.
300    fn make_token(claims: serde_json::Value, secret: &str, alg: Algorithm) -> String {
301        encode(
302            &Header::new(alg),
303            &claims,
304            &EncodingKey::from_secret(secret.as_bytes()),
305        )
306        .unwrap()
307    }
308
309    #[test]
310    fn test_requires_secret_or_consumers() {
311        assert!(JwtAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
312    }
313
314    #[test]
315    fn test_accepts_supported_algorithms() {
316        for alg in ["HS256", "HS384", "HS512"] {
317            let cfg = config(&[("secret", "s3cret"), ("algorithm", alg)]);
318            assert!(
319                JwtAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_ok(),
320                "{alg} should be accepted"
321            );
322        }
323    }
324
325    #[test]
326    fn test_rejects_unknown_algorithm() {
327        // An unsupported algorithm must fail at config load, not silently
328        // verify with HS256.
329        for alg in ["RS256", "ES256", "none"] {
330            let cfg = config(&[("secret", "s3cret"), ("algorithm", alg)]);
331            assert!(
332                JwtAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err(),
333                "{alg} should be rejected"
334            );
335        }
336    }
337
338    #[tokio::test]
339    async fn test_inline_secret_still_works() {
340        let cfg = config(&[("secret", "s3cret")]);
341        let plugin = JwtAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
342
343        let token = make_token(
344            serde_json::json!({ "sub": "u1", "exp": 9999999999u64 }),
345            "s3cret",
346            Algorithm::HS256,
347        );
348        let out = plugin.execute(ctx_with_token(Some(&token))).await.unwrap();
349        assert_eq!(
350            out.context.message.get("user_id"),
351            Some(&serde_json::json!("u1"))
352        );
353
354        // wrong secret rejected
355        let forged = make_token(
356            serde_json::json!({ "sub": "u1", "exp": 9999999999u64 }),
357            "other",
358            Algorithm::HS256,
359        );
360        let out = plugin.execute(ctx_with_token(Some(&forged))).await.unwrap();
361        assert_eq!(out.port, Some("denied"));
362        assert_eq!(out.context.response.status_code, 401);
363    }
364
365    fn resources_with_consumers() -> Arc<PluginResources> {
366        let resources = PluginResources::empty();
367        let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
368            {
369                "name": "alice",
370                "credentials": {
371                    "jwt-auth": { "key": "alice-key", "secret": "alice-secret", "algorithm": "HS256" }
372                }
373            }
374        ]))
375        .unwrap();
376        resources
377            .consumers
378            .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
379        resources
380    }
381
382    #[tokio::test]
383    async fn test_consumer_mode_verifies_and_attaches() {
384        let resources = resources_with_consumers();
385        let mut cfg = HashMap::new();
386        cfg.insert("use_consumers".to_string(), serde_json::json!(true));
387        let plugin = JwtAuthPlugin::from_config(&cfg, &resources).unwrap();
388
389        let token = make_token(
390            serde_json::json!({ "key": "alice-key", "sub": "alice", "exp": 9999999999u64 }),
391            "alice-secret",
392            Algorithm::HS256,
393        );
394        let out = plugin.execute(ctx_with_token(Some(&token))).await.unwrap();
395        assert_eq!(
396            out.context.message.get("consumer.name"),
397            Some(&serde_json::json!("alice"))
398        );
399
400        // right key claim but token signed with the wrong secret is rejected
401        let forged = make_token(
402            serde_json::json!({ "key": "alice-key", "exp": 9999999999u64 }),
403            "wrong-secret",
404            Algorithm::HS256,
405        );
406        let out = plugin.execute(ctx_with_token(Some(&forged))).await.unwrap();
407        assert_eq!(out.port, Some("denied"));
408
409        // unknown key claim is rejected
410        let unknown = make_token(
411            serde_json::json!({ "key": "nobody", "exp": 9999999999u64 }),
412            "x",
413            Algorithm::HS256,
414        );
415        let out = plugin
416            .execute(ctx_with_token(Some(&unknown)))
417            .await
418            .unwrap();
419        assert_eq!(out.port, Some("denied"));
420    }
421}