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