Skip to main content

featherbit/plugins/native/
hmac_auth.rs

1//! HMAC request-signing authentication plugin (`hmac-auth`).
2//!
3//! Port of APISIX's `hmac-auth` plugin (3.17). A client proves possession of a
4//! shared `secret_key` by signing a canonical *signing string* built from the
5//! request and sending the base64 signature alongside the `access_key` that
6//! identifies the credential. featherbit recomputes the signature with the
7//! matching secret and compares; a mismatch, an unknown key, a stale `Date`,
8//! or a missing required signed header is rejected as `401 HMAC_INVALID`
9//! through the node's error port.
10//!
11//! # Wire format
12//!
13//! The signature parameters are read from either:
14//!
15//! - an `Authorization` header of the form
16//!   `Signature keyId="<access_key>",algorithm="hmac-sha256",headers="date @request-target",signature="<base64>"`
17//!   (APISIX 3.17's format — `keyId` is the featherbit `access_key`), or
18//! - the discrete headers `X-HMAC-ACCESS-KEY`, `X-HMAC-ALGORITHM`,
19//!   `X-HMAC-SIGNED-HEADERS` (space-separated), and `X-HMAC-SIGNATURE`.
20//!
21//! The `Date` header (RFC 1123 / GMT) carries the timestamp checked against
22//! `clock_skew`.
23//!
24//! # Signing string
25//!
26//! Mirrors APISIX's `generate_signature`: the access key on the first line,
27//! then one line per signed header, terminated by a trailing newline:
28//!
29//! ```text
30//! <access_key>\n
31//! <h1>: <value1>\n
32//! <h2>: <value2>\n
33//! ```
34//!
35//! The pseudo-header `@request-target` is rendered as `<METHOD> <request-uri>`
36//! instead of a header lookup. `signature = base64(HMAC(secret_key, signing_string))`.
37//!
38//! # Deviations from APISIX
39//!
40//! - Consumer credentials use the field names `access_key` / `secret_key`
41//!   (featherbit's `hmac-auth` consumer index is keyed on `access_key`),
42//!   whereas APISIX names them `key_id` / `secret_key`.
43//! - A single `algorithm` is accepted per node (default `hmac-sha256`) rather
44//!   than APISIX's `allowed_algorithms` list; the client's declared algorithm
45//!   must match it.
46//! - `@request-target`'s request URI is reconstructed from the parsed path
47//!   plus a **sorted** `key=value` query string (the original query byte order
48//!   is not retained), so a client signing `@request-target` must canonicalise
49//!   its query the same way.
50//! - Only the RFC 1123 (`Sun, 06 Nov 1994 08:49:37 GMT`) `Date` format is
51//!   parsed for clock-skew checks.
52//! - Request-body digest validation (`validate_request_body`) is not
53//!   implemented.
54
55use async_trait::async_trait;
56use base64::engine::general_purpose::STANDARD as BASE64;
57use base64::Engine;
58use bytes::Bytes;
59use ring::hmac;
60use std::collections::HashMap;
61use std::sync::Arc;
62use std::time::{SystemTime, UNIX_EPOCH};
63
64use crate::consumers::attach_consumer;
65use crate::context::{Context, GatewayError};
66use crate::plugins::resources::PluginResources;
67use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
68
69/// The X-HMAC-* header names (lowercased) used as the alternative to the
70/// `Authorization: Signature ...` presentation.
71const HDR_ACCESS_KEY: &str = "x-hmac-access-key";
72const HDR_ALGORITHM: &str = "x-hmac-algorithm";
73const HDR_SIGNED_HEADERS: &str = "x-hmac-signed-headers";
74const HDR_SIGNATURE: &str = "x-hmac-signature";
75
76/// Signature parameters extracted from the request.
77struct HmacParams {
78    access_key: String,
79    algorithm: Option<String>,
80    signature: String,
81    signed_headers: Vec<String>,
82}
83
84/// Authenticates requests by verifying an HMAC signature over a canonical
85/// signing string.
86///
87/// With inline `access_key`/`secret_key` a single credential is accepted. With
88/// `use_consumers: true` the presented `access_key` is resolved against the
89/// gateway's `consumers:` section (their `hmac-auth: {access_key, secret_key}`
90/// credentials) and, on a valid signature, the consumer's identity is attached
91/// to the request. Both may be enabled together — the inline key is checked
92/// first.
93pub struct HmacAuthPlugin {
94    /// Inline credential access key (the `keyId`), if configured.
95    access_key: Option<String>,
96    /// Inline secret paired with `access_key`.
97    secret_key: Option<String>,
98    /// The single algorithm this node accepts.
99    algorithm: HmacAlgorithm,
100    /// Maximum allowed difference between the `Date` header and now, in
101    /// seconds; `0` disables the check.
102    clock_skew: u64,
103    /// Header names the client MUST have included in its signature.
104    signed_headers: Vec<String>,
105    /// When true, keys are also resolved against the consumer store.
106    use_consumers: bool,
107    /// Consumer attached when no credential matches (instead of rejecting).
108    anonymous_consumer: Option<String>,
109    /// When false (default), the X-HMAC-* proof headers are stripped before
110    /// proxying upstream.
111    keep_headers: bool,
112    /// When true, the `Authorization` header is stripped before proxying.
113    hide_credentials: bool,
114    /// Realm advertised in the `WWW-Authenticate` challenge.
115    realm: String,
116    resources: Arc<PluginResources>,
117}
118
119/// Supported HMAC algorithms.
120#[derive(Clone, Copy, PartialEq)]
121enum HmacAlgorithm {
122    Sha1,
123    Sha256,
124    Sha512,
125}
126
127impl HmacAlgorithm {
128    /// Parses an APISIX-style algorithm name (`hmac-sha1` / `hmac-sha256` /
129    /// `hmac-sha512`), defaulting to `hmac-sha256` when absent.
130    fn parse(name: Option<&str>) -> Result<Self, String> {
131        match name {
132            None | Some("hmac-sha256") => Ok(Self::Sha256),
133            Some("hmac-sha1") => Ok(Self::Sha1),
134            Some("hmac-sha512") => Ok(Self::Sha512),
135            Some(other) => Err(format!(
136                "Unknown hmac-auth algorithm '{}' — supported: hmac-sha1, hmac-sha256, hmac-sha512",
137                other
138            )),
139        }
140    }
141
142    /// Canonical name as it appears on the wire.
143    fn name(&self) -> &'static str {
144        match self {
145            Self::Sha1 => "hmac-sha1",
146            Self::Sha256 => "hmac-sha256",
147            Self::Sha512 => "hmac-sha512",
148        }
149    }
150
151    /// The corresponding `ring` HMAC algorithm.
152    fn ring_algorithm(&self) -> hmac::Algorithm {
153        match self {
154            Self::Sha1 => hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY,
155            Self::Sha256 => hmac::HMAC_SHA256,
156            Self::Sha512 => hmac::HMAC_SHA512,
157        }
158    }
159}
160
161/// Current unix timestamp in seconds.
162fn now() -> u64 {
163    SystemTime::now()
164        .duration_since(UNIX_EPOCH)
165        .map(|d| d.as_secs())
166        .unwrap_or(0)
167}
168
169/// Parses an RFC 1123 HTTP date (`Sun, 06 Nov 1994 08:49:37 GMT`) into a unix
170/// timestamp. Returns `None` on any malformed component.
171fn parse_http_date(s: &str) -> Option<i64> {
172    // Expected tokens: ["Sun,", "06", "Nov", "1994", "08:49:37", "GMT"]
173    let parts: Vec<&str> = s.split_whitespace().collect();
174    if parts.len() != 6 {
175        return None;
176    }
177    let day: i64 = parts[1].parse().ok()?;
178    let month = match parts[2] {
179        "Jan" => 1,
180        "Feb" => 2,
181        "Mar" => 3,
182        "Apr" => 4,
183        "May" => 5,
184        "Jun" => 6,
185        "Jul" => 7,
186        "Aug" => 8,
187        "Sep" => 9,
188        "Oct" => 10,
189        "Nov" => 11,
190        "Dec" => 12,
191        _ => return None,
192    };
193    let year: i64 = parts[3].parse().ok()?;
194    let hms: Vec<&str> = parts[4].split(':').collect();
195    if hms.len() != 3 {
196        return None;
197    }
198    let hour: i64 = hms[0].parse().ok()?;
199    let minute: i64 = hms[1].parse().ok()?;
200    let second: i64 = hms[2].parse().ok()?;
201
202    // days_from_civil (Howard Hinnant's algorithm).
203    let y = if month <= 2 { year - 1 } else { year };
204    let era = if y >= 0 { y } else { y - 399 } / 400;
205    let yoe = y - era * 400;
206    let m = month;
207    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1;
208    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
209    let days = era * 146097 + doe - 719468;
210
211    Some(days * 86400 + hour * 3600 + minute * 60 + second)
212}
213
214impl HmacAuthPlugin {
215    /// Builds the plugin from node config.
216    ///
217    /// Accepted keys:
218    /// - `use_consumers` (bool, default `false`): resolve the `access_key`
219    ///   against the gateway's `consumers:` section and attach the consumer.
220    /// - `access_key` (string, optional): inline single-credential key id.
221    /// - `secret_key` (string, required when `access_key` is set): the paired
222    ///   secret.
223    /// - At least one of `access_key` / `use_consumers` must be provided.
224    /// - `algorithm` (string, default `"hmac-sha256"`): one of `hmac-sha1`,
225    ///   `hmac-sha256`, `hmac-sha512`.
226    /// - `clock_skew` (integer seconds, default `300`): max `Date` drift; `0`
227    ///   disables the check.
228    /// - `signed_headers` (array of strings, optional): headers the client
229    ///   must have signed; a request omitting any is rejected.
230    /// - `keep_headers` (bool, default `false`): keep the `X-HMAC-*` proof
231    ///   headers when proxying (they are stripped by default).
232    /// - `hide_credentials` (bool, default `false`): strip the `Authorization`
233    ///   header before proxying.
234    /// - `anonymous_consumer` (string, optional): consumer attached when no
235    ///   credential matches, instead of rejecting.
236    /// - `realm` (string, default `"hmac"`): `WWW-Authenticate` realm.
237    ///
238    /// ```yaml
239    /// type: hmac-auth
240    /// config:
241    ///   use_consumers: true
242    ///   algorithm: hmac-sha256
243    ///   clock_skew: 300
244    ///   signed_headers: [date]
245    /// ```
246    pub fn from_config(
247        config: &HashMap<String, serde_json::Value>,
248        resources: &Arc<PluginResources>,
249    ) -> Result<Self, String> {
250        let access_key = config
251            .get("access_key")
252            .and_then(|v| v.as_str())
253            .filter(|s| !s.is_empty())
254            .map(String::from);
255
256        let secret_key = config
257            .get("secret_key")
258            .and_then(|v| v.as_str())
259            .filter(|s| !s.is_empty())
260            .map(String::from);
261
262        let use_consumers = config
263            .get("use_consumers")
264            .and_then(|v| v.as_bool())
265            .unwrap_or(false);
266
267        if access_key.is_none() && !use_consumers {
268            return Err(
269                "hmac-auth plugin requires 'access_key'+'secret_key' or 'use_consumers: true'"
270                    .to_string(),
271            );
272        }
273        if access_key.is_some() && secret_key.is_none() {
274            return Err("hmac-auth plugin: 'access_key' requires 'secret_key'".to_string());
275        }
276
277        let algorithm = HmacAlgorithm::parse(config.get("algorithm").and_then(|v| v.as_str()))?;
278
279        let clock_skew = match config.get("clock_skew") {
280            None => 300,
281            Some(v) => v.as_u64().ok_or_else(|| {
282                "hmac-auth: clock_skew must be a non-negative integer".to_string()
283            })?,
284        };
285
286        let signed_headers: Vec<String> = config
287            .get("signed_headers")
288            .and_then(|v| v.as_array())
289            .map(|seq| {
290                seq.iter()
291                    .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
292                    .collect()
293            })
294            .unwrap_or_default();
295
296        let use_flag = |key: &str| config.get(key).and_then(|v| v.as_bool()).unwrap_or(false);
297
298        let anonymous_consumer = config
299            .get("anonymous_consumer")
300            .and_then(|v| v.as_str())
301            .map(String::from);
302
303        let realm = config
304            .get("realm")
305            .and_then(|v| v.as_str())
306            .unwrap_or("hmac")
307            .to_string();
308
309        Ok(Self {
310            access_key,
311            secret_key,
312            algorithm,
313            clock_skew,
314            signed_headers,
315            use_consumers,
316            anonymous_consumer,
317            keep_headers: use_flag("keep_headers"),
318            hide_credentials: use_flag("hide_credentials"),
319            realm,
320            resources: resources.clone(),
321        })
322    }
323
324    /// Builds the 401 rejection routed through the error port with code
325    /// `HMAC_INVALID`.
326    fn reject(&self, mut ctx: Context, msg: &str) -> PluginResult {
327        ctx.response.status_code = 401;
328        ctx.response.body = Bytes::from(format!(
329            r#"{{"error": "unauthorized", "message": "{}"}}"#,
330            msg
331        ));
332        ctx.response.headers.insert(
333            "content-type".to_string(),
334            vec!["application/json".to_string()],
335        );
336        ctx.response.headers.insert(
337            "www-authenticate".to_string(),
338            vec![format!("hmac realm=\"{}\"", self.realm)],
339        );
340        Err(PluginExecutionError {
341            context: ctx,
342            error: GatewayError {
343                node_id: String::new(),
344                code: "HMAC_INVALID".to_string(),
345                message: msg.to_string(),
346                metadata: HashMap::new(),
347            },
348        })
349    }
350
351    /// Reads a single-valued request header (lowercased key).
352    fn header<'a>(ctx: &'a Context, name: &str) -> Option<&'a str> {
353        ctx.request
354            .headers
355            .get(name)
356            .and_then(|v| v.first())
357            .map(|s| s.as_str())
358    }
359
360    /// Extracts the signature parameters from the `Authorization: Signature`
361    /// header or, failing that, the `X-HMAC-*` headers.
362    fn retrieve_params(ctx: &Context) -> Option<HmacParams> {
363        if let Some(auth) = Self::header(ctx, "authorization") {
364            if let Some(rest) = auth.strip_prefix("Signature ") {
365                return Self::parse_authorization(rest);
366            }
367        }
368
369        // X-HMAC-* header form.
370        let access_key = Self::header(ctx, HDR_ACCESS_KEY)?.to_string();
371        let signature = Self::header(ctx, HDR_SIGNATURE)?.to_string();
372        let algorithm = Self::header(ctx, HDR_ALGORITHM).map(String::from);
373        let signed_headers = Self::header(ctx, HDR_SIGNED_HEADERS)
374            .map(|s| s.split_whitespace().map(|h| h.to_string()).collect())
375            .unwrap_or_default();
376        Some(HmacParams {
377            access_key,
378            algorithm,
379            signature,
380            signed_headers,
381        })
382    }
383
384    /// Parses the comma-separated `keyId="..",algorithm="..",headers="..",signature=".."`
385    /// field list (the part after `Signature `).
386    fn parse_authorization(rest: &str) -> Option<HmacParams> {
387        let mut key_id = None;
388        let mut algorithm = None;
389        let mut signature = None;
390        let mut headers = Vec::new();
391
392        for field in rest.split(',') {
393            let field = field.trim();
394            let Some((k, v)) = field.split_once('=') else {
395                continue;
396            };
397            let value = v.trim().trim_matches('"');
398            match k.trim() {
399                "keyId" => key_id = Some(value.to_string()),
400                "algorithm" => algorithm = Some(value.to_string()),
401                "signature" => signature = Some(value.to_string()),
402                "headers" => headers = value.split_whitespace().map(|h| h.to_string()).collect(),
403                _ => {}
404            }
405        }
406
407        Some(HmacParams {
408            access_key: key_id?,
409            algorithm,
410            signature: signature?,
411            signed_headers: headers,
412        })
413    }
414
415    /// Reconstructs the request URI (path plus a sorted query string) used for
416    /// the `@request-target` pseudo-header.
417    fn request_uri(ctx: &Context) -> String {
418        if ctx.request.query_params.is_empty() {
419            return ctx.request.path.clone();
420        }
421        let mut pairs: Vec<(String, String)> = Vec::new();
422        for (k, vs) in &ctx.request.query_params {
423            for v in vs {
424                pairs.push((k.clone(), v.clone()));
425            }
426        }
427        pairs.sort();
428        let query = pairs
429            .iter()
430            .map(|(k, v)| format!("{}={}", k, v))
431            .collect::<Vec<_>>()
432            .join("&");
433        format!("{}?{}", ctx.request.path, query)
434    }
435
436    /// Builds the canonical signing string from the presented signed headers.
437    fn signing_string(&self, ctx: &Context, params: &HmacParams) -> String {
438        let mut items = vec![params.access_key.clone()];
439        for h in &params.signed_headers {
440            if h == "@request-target" {
441                items.push(format!("{} {}", ctx.request.method, Self::request_uri(ctx)));
442            } else if let Some(value) = Self::header(ctx, &h.to_lowercase()) {
443                items.push(format!("{}: {}", h, value));
444            }
445            // A listed-but-absent real header is skipped (APISIX parity).
446        }
447        let mut s = items.join("\n");
448        s.push('\n');
449        s
450    }
451
452    /// Verifies the client signature against the recomputed one.
453    fn verify_signature(&self, ctx: &Context, params: &HmacParams, secret: &str) -> bool {
454        let Ok(sig_bytes) = BASE64.decode(&params.signature) else {
455            return false;
456        };
457        let signing_string = self.signing_string(ctx, params);
458        let key = hmac::Key::new(self.algorithm.ring_algorithm(), secret.as_bytes());
459        hmac::verify(&key, signing_string.as_bytes(), &sig_bytes).is_ok()
460    }
461
462    /// Enforces the algorithm, clock skew, and required signed headers common
463    /// to both credential sources. Returns `Err(reason)` on any violation.
464    fn validate_common(&self, ctx: &Context, params: &HmacParams) -> Result<(), String> {
465        // Algorithm: if the client declared one it must match the node's.
466        if let Some(ref algo) = params.algorithm {
467            if algo != self.algorithm.name() {
468                return Err("Invalid algorithm".to_string());
469            }
470        }
471
472        // Clock skew against the Date header.
473        if self.clock_skew > 0 {
474            let date = Self::header(ctx, "date")
475                .ok_or("Date header missing, failed to validate clock skew")?;
476            let ts = parse_http_date(date).ok_or("Invalid GMT format time")?;
477            let diff = (now() as i64 - ts).unsigned_abs();
478            if diff > self.clock_skew {
479                return Err("Clock skew exceeded".to_string());
480            }
481        }
482
483        // Required signed headers must all be present in the client's list.
484        for required in &self.signed_headers {
485            if !params
486                .signed_headers
487                .iter()
488                .any(|h| h.to_lowercase() == *required)
489            {
490                return Err(format!(
491                    "expected header \"{}\" missing in signing",
492                    required
493                ));
494            }
495        }
496
497        Ok(())
498    }
499
500    /// Strips the proof/credential headers per `keep_headers`/`hide_credentials`.
501    fn strip_headers(&self, ctx: &mut Context) {
502        if !self.keep_headers {
503            ctx.request.headers.remove(HDR_ACCESS_KEY);
504            ctx.request.headers.remove(HDR_ALGORITHM);
505            ctx.request.headers.remove(HDR_SIGNED_HEADERS);
506            ctx.request.headers.remove(HDR_SIGNATURE);
507        }
508        if self.hide_credentials {
509            ctx.request.headers.remove("authorization");
510        }
511    }
512}
513
514#[async_trait]
515impl Plugin for HmacAuthPlugin {
516    fn plugin_type(&self) -> &str {
517        "hmac-auth"
518    }
519
520    async fn execute(
521        &self,
522        mut ctx: Context,
523        _named_inputs: &HashMap<String, serde_json::Value>,
524    ) -> PluginResult {
525        let params = match Self::retrieve_params(&ctx) {
526            Some(p) => p,
527            None => {
528                if self.anonymous_consumer.is_some() {
529                    return self.attach_anonymous(ctx);
530                }
531                return self.reject(ctx, "client request can't be validated");
532            }
533        };
534
535        if let Err(e) = self.validate_common(&ctx, &params) {
536            return self.reject(ctx, &e);
537        }
538
539        // Inline credential first.
540        if let (Some(ak), Some(sk)) = (&self.access_key, &self.secret_key) {
541            if &params.access_key == ak {
542                if self.verify_signature(&ctx, &params, sk) {
543                    self.strip_headers(&mut ctx);
544                    return Ok(PluginOutput {
545                        context: ctx,
546                        named_outputs: HashMap::new(),
547                    });
548                }
549                return self.reject(ctx, "Invalid signature");
550            }
551        }
552
553        // Consumer store.
554        if self.use_consumers {
555            let store = self.resources.consumers.load();
556            if let Some(consumer) = store.find_by_credential("hmac-auth", &params.access_key) {
557                let secret = consumer
558                    .credentials
559                    .get("hmac-auth")
560                    .and_then(|c| c.get("secret_key"))
561                    .and_then(|v| v.as_str());
562                if let Some(secret) = secret {
563                    if self.verify_signature(&ctx, &params, secret) {
564                        self.strip_headers(&mut ctx);
565                        attach_consumer(&mut ctx, &consumer, "hmac-auth");
566                        return Ok(PluginOutput {
567                            context: ctx,
568                            named_outputs: HashMap::new(),
569                        });
570                    }
571                }
572                return self.reject(ctx, "Invalid signature");
573            }
574        }
575
576        // Anonymous fallback.
577        if self.anonymous_consumer.is_some() {
578            return self.attach_anonymous(ctx);
579        }
580
581        self.reject(ctx, "Invalid access key")
582    }
583}
584
585impl HmacAuthPlugin {
586    /// Attaches the configured anonymous consumer, or rejects if it is unknown.
587    fn attach_anonymous(&self, mut ctx: Context) -> PluginResult {
588        if let Some(ref name) = self.anonymous_consumer {
589            let store = self.resources.consumers.load();
590            if let Some(consumer) = store.get(name) {
591                attach_consumer(&mut ctx, &consumer, "hmac-auth");
592                return Ok(PluginOutput {
593                    context: ctx,
594                    named_outputs: HashMap::new(),
595                });
596            }
597        }
598        self.reject(ctx, "Invalid user authorization")
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::consumers::{ConsumerConfig, ConsumerStore};
606    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
607
608    /// A recent RFC 1123 date built from the current timestamp so clock-skew
609    /// checks pass. We format it here to avoid pulling in a date crate.
610    fn http_date(ts: i64) -> String {
611        // Reverse of parse_http_date's civil algorithm for a small range.
612        let days = ts.div_euclid(86400);
613        let secs = ts.rem_euclid(86400);
614        let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
615        // civil_from_days (Hinnant).
616        let z = days + 719468;
617        let era = if z >= 0 { z } else { z - 146096 } / 146097;
618        let doe = z - era * 146097;
619        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
620        let y = yoe + era * 400;
621        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
622        let mp = (5 * doy + 2) / 153;
623        let d = doy - (153 * mp + 2) / 5 + 1;
624        let month = if mp < 10 { mp + 3 } else { mp - 9 };
625        let year = if month <= 2 { y + 1 } else { y };
626        let month_name = [
627            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
628        ][(month - 1) as usize];
629        // Weekday is not validated by the parser, so any token works.
630        format!(
631            "Mon, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
632            d, month_name, year, h, m, s
633        )
634    }
635
636    fn base_ctx() -> Context {
637        Context {
638            request: GatewayRequest {
639                method: "GET".to_string(),
640                path: "/api".to_string(),
641                host: "h".to_string(),
642                scheme: "http".to_string(),
643                headers: HashMap::new(),
644                query_params: HashMap::new(),
645                body: Bytes::new(),
646                remote_addr: "1.2.3.4:5".to_string(),
647                protocol: Protocol::Http1,
648            },
649            response: GatewayResponse {
650                status_code: 0,
651                headers: HashMap::new(),
652                body: Bytes::new(),
653            },
654            message: HashMap::new(),
655            errors: Vec::new(),
656        }
657    }
658
659    /// Computes the base64 signature a well-behaved client would send for the
660    /// given signed headers.
661    fn sign(
662        secret: &str,
663        alg: HmacAlgorithm,
664        access_key: &str,
665        signed: &[(&str, &str)],
666        method: &str,
667        uri: &str,
668    ) -> String {
669        let mut items = vec![access_key.to_string()];
670        for (h, v) in signed {
671            if *h == "@request-target" {
672                items.push(format!("{} {}", method, uri));
673            } else {
674                items.push(format!("{}: {}", h, v));
675            }
676        }
677        let mut s = items.join("\n");
678        s.push('\n');
679        let key = hmac::Key::new(alg.ring_algorithm(), secret.as_bytes());
680        BASE64.encode(hmac::sign(&key, s.as_bytes()).as_ref())
681    }
682
683    /// Builds a request signed with the X-HMAC-* headers over `date` only.
684    fn signed_request(secret: &str, access_key: &str, alg: HmacAlgorithm, date: &str) -> Context {
685        let mut ctx = base_ctx();
686        let signature = sign(secret, alg, access_key, &[("date", date)], "GET", "/api");
687        ctx.request
688            .headers
689            .insert("date".to_string(), vec![date.to_string()]);
690        ctx.request
691            .headers
692            .insert(HDR_ACCESS_KEY.to_string(), vec![access_key.to_string()]);
693        ctx.request
694            .headers
695            .insert(HDR_ALGORITHM.to_string(), vec![alg.name().to_string()]);
696        ctx.request
697            .headers
698            .insert(HDR_SIGNED_HEADERS.to_string(), vec!["date".to_string()]);
699        ctx.request
700            .headers
701            .insert(HDR_SIGNATURE.to_string(), vec![signature]);
702        ctx
703    }
704
705    fn inline_plugin(extra: serde_json::Value) -> HmacAuthPlugin {
706        let mut config: HashMap<String, serde_json::Value> =
707            serde_json::from_value(serde_json::json!({
708                "access_key": "ak1",
709                "secret_key": "sk1",
710            }))
711            .unwrap();
712        if let serde_json::Value::Object(m) = extra {
713            for (k, v) in m {
714                config.insert(k, v);
715            }
716        }
717        HmacAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap()
718    }
719
720    #[test]
721    fn test_config_requires_credential_or_consumers() {
722        assert!(HmacAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
723        // access_key without secret_key is rejected
724        let cfg: HashMap<String, serde_json::Value> =
725            serde_json::from_value(serde_json::json!({ "access_key": "x" })).unwrap();
726        assert!(HmacAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
727    }
728
729    #[test]
730    fn test_rejects_unknown_algorithm() {
731        let cfg: HashMap<String, serde_json::Value> = serde_json::from_value(
732            serde_json::json!({ "access_key": "a", "secret_key": "b", "algorithm": "md5" }),
733        )
734        .unwrap();
735        assert!(HmacAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
736    }
737
738    #[test]
739    fn test_http_date_round_trip() {
740        // A known timestamp: 2001-09-09 01:46:40 UTC = 1_000_000_000.
741        assert_eq!(
742            parse_http_date("Sun, 09 Sep 2001 01:46:40 GMT"),
743            Some(1_000_000_000)
744        );
745        // The formatter and parser agree.
746        let ts = 1_600_000_000;
747        assert_eq!(parse_http_date(&http_date(ts)), Some(ts));
748    }
749
750    #[tokio::test]
751    async fn test_inline_valid_signature_passes() {
752        let plugin = inline_plugin(serde_json::json!({}));
753        let date = http_date(now() as i64);
754        let ctx = signed_request("sk1", "ak1", HmacAlgorithm::Sha256, &date);
755        let out = plugin.execute(ctx, &HashMap::new()).await.unwrap();
756        // keep_headers defaults false → proof headers stripped
757        assert!(!out.context.request.headers.contains_key(HDR_SIGNATURE));
758    }
759
760    #[tokio::test]
761    async fn test_wrong_secret_rejected() {
762        let plugin = inline_plugin(serde_json::json!({}));
763        let date = http_date(now() as i64);
764        // client signs with the wrong secret
765        let ctx = signed_request("wrong", "ak1", HmacAlgorithm::Sha256, &date);
766        let err = plugin.execute(ctx, &HashMap::new()).await.unwrap_err();
767        assert_eq!(err.error.code, "HMAC_INVALID");
768        assert_eq!(err.context.response.status_code, 401);
769    }
770
771    #[tokio::test]
772    async fn test_clock_skew_exceeded_rejected() {
773        let plugin = inline_plugin(serde_json::json!({ "clock_skew": 10 }));
774        let stale = http_date(now() as i64 - 3600);
775        let ctx = signed_request("sk1", "ak1", HmacAlgorithm::Sha256, &stale);
776        assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
777    }
778
779    #[tokio::test]
780    async fn test_missing_required_signed_header_rejected() {
781        // require @request-target but the client only signs date
782        let plugin = inline_plugin(serde_json::json!({ "signed_headers": ["@request-target"] }));
783        let date = http_date(now() as i64);
784        let ctx = signed_request("sk1", "ak1", HmacAlgorithm::Sha256, &date);
785        assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
786    }
787
788    #[tokio::test]
789    async fn test_authorization_signature_form() {
790        let plugin = inline_plugin(serde_json::json!({ "clock_skew": 0 }));
791        let mut ctx = base_ctx();
792        let signature = sign(
793            "sk1",
794            HmacAlgorithm::Sha256,
795            "ak1",
796            &[("@request-target", "")],
797            "GET",
798            "/api",
799        );
800        let auth = format!(
801            "Signature keyId=\"ak1\",algorithm=\"hmac-sha256\",headers=\"@request-target\",signature=\"{}\"",
802            signature
803        );
804        ctx.request
805            .headers
806            .insert("authorization".to_string(), vec![auth]);
807        assert!(plugin.execute(ctx, &HashMap::new()).await.is_ok());
808    }
809
810    fn resources_with_consumers() -> Arc<PluginResources> {
811        let resources = PluginResources::empty();
812        let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
813            {
814                "name": "alice",
815                "credentials": { "hmac-auth": { "access_key": "alice-ak", "secret_key": "alice-sk" } }
816            },
817            { "name": "guest" }
818        ]))
819        .unwrap();
820        resources
821            .consumers
822            .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
823        resources
824    }
825
826    #[tokio::test]
827    async fn test_consumer_mode_attaches_identity() {
828        let resources = resources_with_consumers();
829        let mut config = HashMap::new();
830        config.insert("use_consumers".to_string(), serde_json::json!(true));
831        config.insert("clock_skew".to_string(), serde_json::json!(0));
832        let plugin = HmacAuthPlugin::from_config(&config, &resources).unwrap();
833
834        let date = http_date(now() as i64);
835        let ctx = signed_request("alice-sk", "alice-ak", HmacAlgorithm::Sha256, &date);
836        let out = plugin.execute(ctx, &HashMap::new()).await.unwrap();
837        assert_eq!(
838            out.context.message.get("consumer.name"),
839            Some(&serde_json::json!("alice"))
840        );
841        assert_eq!(
842            out.context.request.headers.get("x-consumer-username"),
843            Some(&vec!["alice".to_string()])
844        );
845
846        // unknown access key rejected
847        let ctx = signed_request("x", "nobody", HmacAlgorithm::Sha256, &date);
848        assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
849    }
850
851    #[tokio::test]
852    async fn test_anonymous_consumer_fallback() {
853        let resources = resources_with_consumers();
854        let mut config = HashMap::new();
855        config.insert("use_consumers".to_string(), serde_json::json!(true));
856        config.insert("anonymous_consumer".to_string(), serde_json::json!("guest"));
857        let plugin = HmacAuthPlugin::from_config(&config, &resources).unwrap();
858
859        // no signature at all → anonymous
860        let out = plugin.execute(base_ctx(), &HashMap::new()).await.unwrap();
861        assert_eq!(
862            out.context.message.get("consumer.name"),
863            Some(&serde_json::json!("guest"))
864        );
865    }
866}