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 error routed through the node's error 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, GatewayError};
26use crate::plugins::resources::PluginResources;
27use crate::plugins::{Plugin, PluginExecutionError, 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 and a
57/// `JWT_INVALID` error routed through the error 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 returns a
132    /// `PluginExecutionError` (code `JWT_INVALID`) carrying the context so the
133    /// graph engine routes through the error port.
134    fn reject(ctx: Context, message: &str) -> PluginResult {
135        let mut ctx = ctx;
136        ctx.response.status_code = 401;
137        ctx.response.body = Bytes::from(format!(
138            r#"{{"error": "unauthorized", "message": "{}"}}"#,
139            message
140        ));
141        ctx.response.headers.insert(
142            "content-type".to_string(),
143            vec!["application/json".to_string()],
144        );
145        Err(PluginExecutionError {
146            context: ctx,
147            error: GatewayError {
148                node_id: String::new(),
149                code: "JWT_INVALID".to_string(),
150                message: message.to_string(),
151                metadata: HashMap::new(),
152            },
153        })
154    }
155
156    /// Verifies `token` with `secret`/`algorithm` and, on success, writes the
157    /// claims into `context.message`. Returns the decoded claims on success.
158    fn verify(
159        token: &str,
160        secret: &str,
161        algorithm: Algorithm,
162        ctx: &mut Context,
163    ) -> Result<HashMap<String, serde_json::Value>, String> {
164        let key = DecodingKey::from_secret(secret.as_bytes());
165        let mut validation = Validation::new(algorithm);
166        validation.validate_exp = true;
167
168        match decode::<HashMap<String, serde_json::Value>>(token, &key, &validation) {
169            Ok(token_data) => {
170                ctx.message.insert(
171                    "jwt_claims".to_string(),
172                    serde_json::to_value(&token_data.claims).unwrap_or_default(),
173                );
174                if let Some(sub) = token_data.claims.get("sub") {
175                    ctx.message.insert("user_id".to_string(), sub.clone());
176                }
177                Ok(token_data.claims)
178            }
179            Err(e) => Err(format!("Invalid JWT: {}", e)),
180        }
181    }
182
183    /// Reads the `key` claim from an *unverified* token payload.
184    ///
185    /// The payload segment is base64url-decoded and parsed as JSON purely to
186    /// discover which consumer to look up; the signature is verified only
187    /// afterwards with that consumer's secret, so no trust is placed in this
188    /// value.
189    fn peek_key_claim(token: &str) -> Option<String> {
190        let payload = token.split('.').nth(1)?;
191        let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
192        let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
193        claims.get("key")?.as_str().map(String::from)
194    }
195}
196
197#[async_trait]
198impl Plugin for JwtAuthPlugin {
199    fn plugin_type(&self) -> &str {
200        "jwt-auth"
201    }
202
203    async fn execute(
204        &self,
205        mut ctx: Context,
206        _named_inputs: &HashMap<String, serde_json::Value>,
207    ) -> PluginResult {
208        let token = ctx
209            .request
210            .headers
211            .get(&self.header_name)
212            .and_then(|v| v.first())
213            .map(|v| {
214                v.strip_prefix("Bearer ")
215                    .map(String::from)
216                    .unwrap_or_else(|| v.clone())
217            });
218
219        let token = match token {
220            Some(t) => t,
221            None => return Self::reject(ctx, "Missing authorization token"),
222        };
223
224        // Inline secret mode first.
225        if let Some(ref secret) = self.secret {
226            match Self::verify(&token, secret, self.algorithm, &mut ctx) {
227                Ok(_) => {
228                    return Ok(PluginOutput {
229                        context: ctx,
230                        named_outputs: HashMap::new(),
231                    })
232                }
233                // With consumers also enabled, fall through and try them;
234                // otherwise reject now.
235                Err(e) if !self.use_consumers => return Self::reject(ctx, &e),
236                Err(_) => {}
237            }
238        }
239
240        // Consumer mode: the `key` claim selects the consumer, whose stored
241        // secret/algorithm then verifies the signature.
242        if self.use_consumers {
243            let key_claim = match Self::peek_key_claim(&token) {
244                Some(k) => k,
245                None => return Self::reject(ctx, "JWT missing 'key' claim"),
246            };
247            let store = self.resources.consumers.load();
248            if let Some(consumer) = store.find_by_credential("jwt-auth", &key_claim) {
249                let cred = consumer.credentials.get("jwt-auth");
250                let secret = cred.and_then(|c| c.get("secret")).and_then(|v| v.as_str());
251                let algorithm = parse_algorithm(
252                    cred.and_then(|c| c.get("algorithm"))
253                        .and_then(|v| v.as_str()),
254                );
255                match (secret, algorithm) {
256                    (Some(secret), Ok(algorithm)) => {
257                        match Self::verify(&token, secret, algorithm, &mut ctx) {
258                            Ok(_) => {
259                                attach_consumer(&mut ctx, &consumer, "jwt-auth");
260                                return Ok(PluginOutput {
261                                    context: ctx,
262                                    named_outputs: HashMap::new(),
263                                });
264                            }
265                            Err(e) => return Self::reject(ctx, &e),
266                        }
267                    }
268                    _ => return Self::reject(ctx, "Consumer has no valid jwt-auth secret"),
269                }
270            }
271            return Self::reject(ctx, "Unknown JWT key");
272        }
273
274        Self::reject(ctx, "Invalid JWT")
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::consumers::{ConsumerConfig, ConsumerStore};
282    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
283    use jsonwebtoken::{encode, EncodingKey, Header};
284
285    fn config(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
286        pairs
287            .iter()
288            .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
289            .collect()
290    }
291
292    fn ctx_with_token(token: Option<&str>) -> Context {
293        let mut headers = HashMap::new();
294        if let Some(t) = token {
295            headers.insert("authorization".to_string(), vec![format!("Bearer {}", t)]);
296        }
297        Context {
298            request: GatewayRequest {
299                method: "GET".to_string(),
300                path: "/".to_string(),
301                host: "h".to_string(),
302                scheme: "http".to_string(),
303                headers,
304                query_params: HashMap::new(),
305                body: Bytes::new(),
306                remote_addr: "1.2.3.4:5".to_string(),
307                protocol: Protocol::Http1,
308            },
309            response: GatewayResponse {
310                status_code: 0,
311                headers: HashMap::new(),
312                body: Bytes::new(),
313            },
314            message: HashMap::new(),
315            errors: Vec::new(),
316        }
317    }
318
319    /// Signs `claims` with `secret` and `alg`.
320    fn make_token(claims: serde_json::Value, secret: &str, alg: Algorithm) -> String {
321        encode(
322            &Header::new(alg),
323            &claims,
324            &EncodingKey::from_secret(secret.as_bytes()),
325        )
326        .unwrap()
327    }
328
329    #[test]
330    fn test_requires_secret_or_consumers() {
331        assert!(JwtAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
332    }
333
334    #[test]
335    fn test_accepts_supported_algorithms() {
336        for alg in ["HS256", "HS384", "HS512"] {
337            let cfg = config(&[("secret", "s3cret"), ("algorithm", alg)]);
338            assert!(
339                JwtAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_ok(),
340                "{alg} should be accepted"
341            );
342        }
343    }
344
345    #[test]
346    fn test_rejects_unknown_algorithm() {
347        // An unsupported algorithm must fail at config load, not silently
348        // verify with HS256.
349        for alg in ["RS256", "ES256", "none"] {
350            let cfg = config(&[("secret", "s3cret"), ("algorithm", alg)]);
351            assert!(
352                JwtAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err(),
353                "{alg} should be rejected"
354            );
355        }
356    }
357
358    #[tokio::test]
359    async fn test_inline_secret_still_works() {
360        let cfg = config(&[("secret", "s3cret")]);
361        let plugin = JwtAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
362
363        let token = make_token(
364            serde_json::json!({ "sub": "u1", "exp": 9999999999u64 }),
365            "s3cret",
366            Algorithm::HS256,
367        );
368        let out = plugin
369            .execute(ctx_with_token(Some(&token)), &HashMap::new())
370            .await
371            .unwrap();
372        assert_eq!(
373            out.context.message.get("user_id"),
374            Some(&serde_json::json!("u1"))
375        );
376
377        // wrong secret rejected
378        let forged = make_token(
379            serde_json::json!({ "sub": "u1", "exp": 9999999999u64 }),
380            "other",
381            Algorithm::HS256,
382        );
383        assert!(plugin
384            .execute(ctx_with_token(Some(&forged)), &HashMap::new())
385            .await
386            .is_err());
387    }
388
389    fn resources_with_consumers() -> Arc<PluginResources> {
390        let resources = PluginResources::empty();
391        let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
392            {
393                "name": "alice",
394                "credentials": {
395                    "jwt-auth": { "key": "alice-key", "secret": "alice-secret", "algorithm": "HS256" }
396                }
397            }
398        ]))
399        .unwrap();
400        resources
401            .consumers
402            .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
403        resources
404    }
405
406    #[tokio::test]
407    async fn test_consumer_mode_verifies_and_attaches() {
408        let resources = resources_with_consumers();
409        let mut cfg = HashMap::new();
410        cfg.insert("use_consumers".to_string(), serde_json::json!(true));
411        let plugin = JwtAuthPlugin::from_config(&cfg, &resources).unwrap();
412
413        let token = make_token(
414            serde_json::json!({ "key": "alice-key", "sub": "alice", "exp": 9999999999u64 }),
415            "alice-secret",
416            Algorithm::HS256,
417        );
418        let out = plugin
419            .execute(ctx_with_token(Some(&token)), &HashMap::new())
420            .await
421            .unwrap();
422        assert_eq!(
423            out.context.message.get("consumer.name"),
424            Some(&serde_json::json!("alice"))
425        );
426
427        // right key claim but token signed with the wrong secret is rejected
428        let forged = make_token(
429            serde_json::json!({ "key": "alice-key", "exp": 9999999999u64 }),
430            "wrong-secret",
431            Algorithm::HS256,
432        );
433        assert!(plugin
434            .execute(ctx_with_token(Some(&forged)), &HashMap::new())
435            .await
436            .is_err());
437
438        // unknown key claim is rejected
439        let unknown = make_token(
440            serde_json::json!({ "key": "nobody", "exp": 9999999999u64 }),
441            "x",
442            Algorithm::HS256,
443        );
444        assert!(plugin
445            .execute(ctx_with_token(Some(&unknown)), &HashMap::new())
446            .await
447            .is_err());
448    }
449}