featherbit/plugins/native/
jwt_auth.rs1use 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
29fn 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
45pub struct JwtAuthPlugin {
59 secret: Option<String>,
62 algorithm: Algorithm,
64 header_name: String,
66 use_consumers: bool,
69 resources: Arc<PluginResources>,
70}
71
72impl JwtAuthPlugin {
73 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 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 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 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 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 Err(e) if !self.use_consumers => return Self::reject(ctx, &e),
218 Err(_) => {}
219 }
220 }
221
222 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 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 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 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 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 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}