Skip to main content

pjson_rs/infrastructure/http/
auth.rs

1//! HTTP authentication middleware for PJS endpoints.
2//!
3//! This module provides Tower `Layer` implementations for API key and (optionally) JWT
4//! authentication. Both layers mirror the boilerplate pattern of `RateLimitMiddleware`
5//! in `middleware.rs` so that they compose cleanly with the existing middleware stack.
6//!
7//! # Security design
8//!
9//! ## API key comparison
10//!
11//! Raw API keys are never stored. At construction time each configured key is tagged with
12//! HMAC-SHA256 using a per-process random `hmac_key`. During authentication the candidate
13//! token is tagged with the same key and the resulting 32-byte digest is compared against
14//! every stored tag using `ConstantTimeEq`.
15//!
16//! This design eliminates two side-channel classes:
17//! - **Length leakage** — all tags are exactly 32 bytes regardless of original key length.
18//! - **Key-index leakage** — the comparison always iterates the full tag list and accumulates
19//!   results with bitwise OR; no early return is taken on a partial match.
20//!
21//! The unavoidable single bit — "any key matched vs. none matched" — leaks via the HTTP
22//! response code (200 vs 401). This is acceptable per industry practice and inherent to
23//! the protocol.
24//!
25//! ## CORS preflight
26//!
27//! `OPTIONS` requests bypass authentication unconditionally. Browsers do not attach
28//! credentials on preflight (Fetch spec §3.7), so challenging them would silently break
29//! every cross-origin `POST` from a browser client. The CORS layer is applied outside
30//! both routers in `apply_common_layers`, providing belt-and-suspenders coverage.
31//!
32//! # Feature gates
33//!
34//! The entire module is gated behind `#[cfg(feature = "http-server")]`.
35//! `JwtAuthLayer` is additionally gated behind `#[cfg(feature = "http-auth-jwt")]`.
36
37#[cfg(feature = "http-server")]
38mod inner {
39    use axum::{
40        body::Body,
41        http::{Method, Request, Response, StatusCode, header},
42    };
43    use hmac::{Hmac, KeyInit, Mac};
44    use sha2::Sha256;
45    use std::{
46        fmt,
47        future::Future,
48        pin::Pin,
49        sync::Arc,
50        task::{Context, Poll},
51    };
52    use subtle::ConstantTimeEq;
53    use tower::{Layer, Service};
54
55    type HmacSha256 = Hmac<Sha256>;
56
57    // ── Error type ──────────────────────────────────────────────────────────────
58
59    /// Errors that can occur when constructing an auth layer from a key list.
60    #[derive(Debug, thiserror::Error)]
61    pub enum AuthConfigError {
62        /// Returned when the key list passed to the constructor is empty.
63        #[error("API key list must not be empty")]
64        EmptyKeyList,
65        /// Returned when any key contains ASCII whitespace characters.
66        ///
67        /// Keys with leading or trailing whitespace are almost always a configuration
68        /// error (copy-paste of a quoted value, trailing newline, etc.). Rejecting them
69        /// at construction time prevents silent authentication failures later.
70        #[error("API key must not contain whitespace")]
71        WhitespaceInKey,
72        /// Returned when the system RNG fails to seed the per-process HMAC key.
73        #[error("failed to seed HMAC key from system RNG: {0}")]
74        RngFailure(getrandom::Error),
75    }
76
77    // ── Internal state ───────────────────────────────────────────────────────────
78
79    /// Shared, reference-counted state stored inside each cloned layer/service.
80    struct ApiKeyState {
81        /// Per-process random seed. Used only for tag derivation — never exported or logged.
82        hmac_key: [u8; 32],
83        /// Pre-computed 32-byte HMAC-SHA256 tags of every configured API key.
84        ///
85        /// Fixed width regardless of original key length, so comparison cannot leak
86        /// length classes.
87        tags: Vec<[u8; 32]>,
88    }
89
90    // ── ApiKeyConfig (held by callers, consumed by ApiKeyAuthLayer::new) ────────
91
92    /// Configuration for [`ApiKeyAuthLayer`].
93    ///
94    /// Contains the pre-processed HMAC tags of all accepted API keys plus the
95    /// per-process random seed used to derive those tags.
96    ///
97    /// # Examples
98    ///
99    /// ```rust,ignore
100    /// use pjson_rs::infrastructure::http::auth::ApiKeyConfig;
101    ///
102    /// let config = ApiKeyConfig::new(&["secret-key-1", "secret-key-2"])?;
103    /// let layer = ApiKeyAuthLayer::new(config);
104    /// ```
105    pub struct ApiKeyConfig {
106        /// HMAC-SHA256 tags of all configured API keys.
107        pub(crate) keys: Vec<[u8; 32]>,
108        /// Per-process seed used to derive tags. Never exported.
109        pub(crate) hmac_key: [u8; 32],
110    }
111
112    impl ApiKeyConfig {
113        /// Construct from a slice of raw API key strings.
114        ///
115        /// # Errors
116        ///
117        /// - [`AuthConfigError::EmptyKeyList`] — `raw_keys` is empty.
118        /// - [`AuthConfigError::WhitespaceInKey`] — any key contains ASCII whitespace.
119        /// - [`AuthConfigError::RngFailure`] — the system RNG could not generate the HMAC seed.
120        pub fn new(raw_keys: &[&str]) -> Result<Self, AuthConfigError> {
121            if raw_keys.is_empty() {
122                return Err(AuthConfigError::EmptyKeyList);
123            }
124            if raw_keys
125                .iter()
126                .any(|k| k.bytes().any(|b| b.is_ascii_whitespace()))
127            {
128                return Err(AuthConfigError::WhitespaceInKey);
129            }
130
131            let mut hmac_key = [0u8; 32];
132            getrandom::fill(&mut hmac_key).map_err(AuthConfigError::RngFailure)?;
133
134            let keys = raw_keys
135                .iter()
136                .map(|k| hmac_tag(&hmac_key, k.as_bytes()))
137                .collect();
138            Ok(Self { keys, hmac_key })
139        }
140    }
141
142    impl fmt::Debug for ApiKeyConfig {
143        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144            f.debug_struct("ApiKeyConfig")
145                .field("keys", &format!("[{} redacted tags]", self.keys.len()))
146                .field("hmac_key", &"[redacted]")
147                .finish()
148        }
149    }
150
151    // ── Layer ────────────────────────────────────────────────────────────────────
152
153    /// Tower [`Layer`] that enforces API key authentication on every non-OPTIONS request.
154    ///
155    /// Wrap only the routes that need protection. Public routes (e.g. `/pjs/health`)
156    /// should live in a separate router that is merged **without** this layer:
157    ///
158    /// ```rust,ignore
159    /// let protected = protected_routes().layer(ApiKeyAuthLayer::new(config));
160    /// let router = Router::new()
161    ///     .merge(public_routes())
162    ///     .merge(protected)
163    ///     .layer(apply_common_layers());
164    /// ```
165    ///
166    /// Authentication accepts tokens from two header sources (first match wins):
167    /// 1. `Authorization: Bearer <token>`
168    /// 2. `X-PJS-API-Key: <token>`
169    ///
170    /// On failure the layer returns `401 Unauthorized` with a JSON body
171    /// `{"error":"Unauthorized"}`.
172    #[derive(Clone)]
173    pub struct ApiKeyAuthLayer {
174        inner: Arc<ApiKeyState>,
175    }
176
177    impl ApiKeyAuthLayer {
178        /// Construct from a pre-built [`ApiKeyConfig`].
179        pub fn new(config: ApiKeyConfig) -> Self {
180            Self {
181                inner: Arc::new(ApiKeyState {
182                    hmac_key: config.hmac_key,
183                    tags: config.keys,
184                }),
185            }
186        }
187    }
188
189    impl<S> Layer<S> for ApiKeyAuthLayer {
190        type Service = ApiKeyAuthService<S>;
191
192        fn layer(&self, inner: S) -> Self::Service {
193            ApiKeyAuthService {
194                inner,
195                state: self.inner.clone(),
196            }
197        }
198    }
199
200    // ── Service ───────────────────────────────────────────────────────────────────
201
202    /// The [`Service`] produced by [`ApiKeyAuthLayer`].
203    #[derive(Clone)]
204    pub struct ApiKeyAuthService<S> {
205        inner: S,
206        state: Arc<ApiKeyState>,
207    }
208
209    impl<S> Service<Request<Body>> for ApiKeyAuthService<S>
210    where
211        S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
212        S::Future: Send + 'static,
213    {
214        type Response = Response<Body>;
215        type Error = S::Error;
216        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
217
218        fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
219            self.inner.poll_ready(cx)
220        }
221
222        fn call(&mut self, req: Request<Body>) -> Self::Future {
223            // CORS preflight bypass: browsers do not send Authorization on OPTIONS
224            // (Fetch spec §3.7). Even with CORS layered outside auth, this is a
225            // defensive bypass for direct OPTIONS hits and non-browser preflight.
226            if req.method() == Method::OPTIONS {
227                let fut = self.inner.call(req);
228                return Box::pin(fut);
229            }
230
231            let state = self.state.clone();
232            let mut inner = self.inner.clone();
233
234            Box::pin(async move {
235                match extract_token(&req) {
236                    Some(candidate) if matches_any(&state, candidate) => inner.call(req).await,
237                    _ => Ok(unauthorized_response()),
238                }
239            })
240        }
241    }
242
243    // ── Helpers ───────────────────────────────────────────────────────────────────
244
245    /// Compute an HMAC-SHA256 tag for `data` under `key`.
246    fn hmac_tag(key: &[u8; 32], data: &[u8]) -> [u8; 32] {
247        let mut mac = HmacSha256::new_from_slice(key)
248            // PANIC: new_from_slice only fails for HMAC types that reject certain key lengths;
249            // HMAC-SHA256 accepts any key length, so this cannot fail.
250            .expect("HMAC-SHA256 accepts any key length");
251        mac.update(data);
252        mac.finalize().into_bytes().into()
253    }
254
255    /// Constant-time membership test.
256    ///
257    /// Tags the candidate with the stored HMAC key and compares against every stored tag
258    /// using `ConstantTimeEq`. The full list is always iterated — no early return —
259    /// and results are accumulated with bitwise OR to prevent timing-based key-index
260    /// discovery.
261    fn matches_any(state: &ApiKeyState, candidate: &[u8]) -> bool {
262        let candidate_tag = hmac_tag(&state.hmac_key, candidate);
263        let mut acc: u8 = 0;
264        for tag in &state.tags {
265            // Both sides are 32-byte arrays; no length branch, no length leakage.
266            acc |= tag.ct_eq(&candidate_tag).unwrap_u8();
267        }
268        // Single branch on the aggregate result — leaks only the unavoidable
269        // "any match vs. no match" bit, which is inherent to the 200/401 response split.
270        acc == 1
271    }
272
273    /// Extract the bearer token or API key from the request headers.
274    ///
275    /// Returns a borrow of the raw bytes from the header value — no allocation on the
276    /// hot path. Returns `None` when neither expected header is present or parseable.
277    ///
278    /// Header sources (first match wins):
279    /// 1. `Authorization: Bearer <token>`
280    /// 2. `X-PJS-API-Key: <token>`
281    fn extract_token(req: &Request<Body>) -> Option<&[u8]> {
282        if let Some(v) = req.headers().get(header::AUTHORIZATION)
283            && let Some(stripped) = v.as_bytes().strip_prefix(b"Bearer ")
284        {
285            return Some(trim_ascii(stripped));
286        }
287        if let Some(v) = req.headers().get("x-pjs-api-key") {
288            return Some(trim_ascii(v.as_bytes()));
289        }
290        None
291    }
292
293    /// Strip leading and trailing ASCII whitespace from a byte slice.
294    fn trim_ascii(bytes: &[u8]) -> &[u8] {
295        let start = bytes
296            .iter()
297            .position(|b| !b.is_ascii_whitespace())
298            .unwrap_or(bytes.len());
299        let end = bytes
300            .iter()
301            .rposition(|b| !b.is_ascii_whitespace())
302            .map_or(start, |i| i + 1);
303        &bytes[start..end]
304    }
305
306    /// Build a `401 Unauthorized` response with a JSON body.
307    fn unauthorized_response() -> Response<Body> {
308        let body = serde_json::json!({ "error": "Unauthorized" }).to_string();
309        Response::builder()
310            .status(StatusCode::UNAUTHORIZED)
311            .header(header::CONTENT_TYPE, "application/json")
312            .body(Body::from(body))
313            // PANIC: the builder arguments are all static constants; this cannot fail.
314            .expect("static unauthorized response is always valid")
315    }
316
317    // ── JWT layer (feature-gated) ─────────────────────────────────────────────────
318
319    #[cfg(feature = "http-auth-jwt")]
320    pub use jwt::{JwtAuthLayer, JwtAuthService, JwtConfig};
321
322    #[cfg(feature = "http-auth-jwt")]
323    mod jwt {
324        //! JWT authentication layer.
325        //!
326        //! # Algorithm choice
327        //!
328        //! > **Performance note:** RS256 (RSA) verification throughput is approximately
329        //! > 100× lower than HS256. At high request rates (≥ 1 000 req/s), RS256 will
330        //! > saturate a CPU core. HS256 is recommended for production load profiles unless
331        //! > asymmetric key distribution is a hard requirement.
332        //!
333        //! # Usage
334        //!
335        //! ```rust,ignore
336        //! use pjson_rs::infrastructure::http::auth::{JwtAuthLayer, JwtConfig};
337        //! use jsonwebtoken::{DecodingKey, Validation, Algorithm};
338        //!
339        //! let config = JwtConfig {
340        //!     decoding_key: DecodingKey::from_secret(b"my-secret"),
341        //!     validation: Validation::new(Algorithm::HS256),
342        //! };
343        //! let layer = JwtAuthLayer::<MyClaims>::new(config);
344        //! ```
345
346        use axum::{
347            body::Body,
348            http::{Method, Request, Response, StatusCode, header},
349        };
350        use jsonwebtoken::{DecodingKey, Validation};
351        use serde::de::DeserializeOwned;
352        use std::{
353            future::Future,
354            marker::PhantomData,
355            pin::Pin,
356            sync::Arc,
357            task::{Context, Poll},
358        };
359        use tower::{Layer, Service};
360
361        /// Configuration for [`JwtAuthLayer`].
362        ///
363        /// Callers are responsible for configuring the [`Validation`] struct to match
364        /// their issuer, audience, and algorithm requirements before passing it here.
365        pub struct JwtConfig {
366            /// Key used to verify JWT signatures.
367            pub decoding_key: DecodingKey,
368            /// Validation parameters (algorithm, issuer, audience, expiry, etc.).
369            pub validation: Validation,
370        }
371
372        /// Tower [`Layer`] that enforces JWT Bearer token authentication.
373        ///
374        /// Only `Authorization: Bearer <token>` is checked; `X-PJS-API-Key` is not
375        /// accepted for JWT auth.
376        ///
377        /// Claims type `C` must be [`DeserializeOwned`] + [`Send`] + [`Sync`].
378        /// The decoded claims are **discarded** — this layer only validates the token.
379        /// If you need access to claims downstream, attach them via request extensions
380        /// in a separate extractor layer.
381        ///
382        /// `OPTIONS` requests are passed through without authentication (same as
383        /// [`super::ApiKeyAuthLayer`]).
384        pub struct JwtAuthLayer<C> {
385            inner: Arc<JwtState>,
386            _claims: PhantomData<fn() -> C>,
387        }
388
389        impl<C> Clone for JwtAuthLayer<C> {
390            fn clone(&self) -> Self {
391                Self {
392                    inner: self.inner.clone(),
393                    _claims: PhantomData,
394                }
395            }
396        }
397
398        struct JwtState {
399            decoding_key: DecodingKey,
400            validation: Validation,
401        }
402
403        impl<C> JwtAuthLayer<C>
404        where
405            C: DeserializeOwned + Send + Sync + 'static,
406        {
407            /// Construct from a [`JwtConfig`].
408            pub fn new(config: JwtConfig) -> Self {
409                Self {
410                    inner: Arc::new(JwtState {
411                        decoding_key: config.decoding_key,
412                        validation: config.validation,
413                    }),
414                    _claims: PhantomData,
415                }
416            }
417        }
418
419        impl<S, C> Layer<S> for JwtAuthLayer<C>
420        where
421            C: DeserializeOwned + Send + Sync + 'static,
422        {
423            type Service = JwtAuthService<S, C>;
424
425            fn layer(&self, inner: S) -> Self::Service {
426                JwtAuthService {
427                    inner,
428                    state: self.inner.clone(),
429                    _claims: PhantomData,
430                }
431            }
432        }
433
434        /// The [`Service`] produced by [`JwtAuthLayer`].
435        pub struct JwtAuthService<S, C> {
436            inner: S,
437            state: Arc<JwtState>,
438            _claims: PhantomData<fn() -> C>,
439        }
440
441        impl<S, C> Clone for JwtAuthService<S, C>
442        where
443            S: Clone,
444        {
445            fn clone(&self) -> Self {
446                Self {
447                    inner: self.inner.clone(),
448                    state: self.state.clone(),
449                    _claims: PhantomData,
450                }
451            }
452        }
453
454        impl<S, C> Service<Request<Body>> for JwtAuthService<S, C>
455        where
456            S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
457            S::Future: Send + 'static,
458            C: DeserializeOwned + Send + Sync + 'static,
459        {
460            type Response = Response<Body>;
461            type Error = S::Error;
462            type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
463
464            fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
465                self.inner.poll_ready(cx)
466            }
467
468            fn call(&mut self, req: Request<Body>) -> Self::Future {
469                // CORS preflight bypass — see ApiKeyAuthService for rationale.
470                if req.method() == Method::OPTIONS {
471                    let fut = self.inner.call(req);
472                    return Box::pin(fut);
473                }
474
475                let state = self.state.clone();
476                let mut inner = self.inner.clone();
477
478                Box::pin(async move {
479                    let token = match extract_bearer(&req) {
480                        Some(t) => t,
481                        None => return Ok(jwt_unauthorized_response()),
482                    };
483
484                    let token_str = match std::str::from_utf8(token) {
485                        Ok(s) => s,
486                        Err(_) => return Ok(jwt_unauthorized_response()),
487                    };
488
489                    match jsonwebtoken::decode::<C>(
490                        token_str,
491                        &state.decoding_key,
492                        &state.validation,
493                    ) {
494                        Ok(_) => inner.call(req).await,
495                        Err(_) => Ok(jwt_unauthorized_response()),
496                    }
497                })
498            }
499        }
500
501        /// Extract a Bearer token from the `Authorization` header.
502        ///
503        /// Returns borrowed bytes from the [`HeaderValue`] — no allocation.
504        fn extract_bearer(req: &Request<Body>) -> Option<&[u8]> {
505            req.headers()
506                .get(header::AUTHORIZATION)?
507                .as_bytes()
508                .strip_prefix(b"Bearer ")
509        }
510
511        fn jwt_unauthorized_response() -> Response<Body> {
512            let body = serde_json::json!({ "error": "Unauthorized" }).to_string();
513            Response::builder()
514                .status(StatusCode::UNAUTHORIZED)
515                .header(header::CONTENT_TYPE, "application/json")
516                .body(Body::from(body))
517                // PANIC: static builder arguments; cannot fail.
518                .expect("static unauthorized response is always valid")
519        }
520    }
521
522    // ── Unit tests ────────────────────────────────────────────────────────────────
523
524    #[cfg(test)]
525    mod tests {
526        use super::*;
527        use axum::{
528            body::Body,
529            http::{Method, Request, StatusCode},
530        };
531        use tower::{Service, ServiceExt};
532
533        // ── ApiKeyConfig construction ────────────────────────────────────────────
534
535        #[test]
536        fn api_key_config_debug_redacts_key_material() {
537            let config = ApiKeyConfig::new(&["test-key-one"]).unwrap();
538            let debug = format!("{config:?}");
539            assert!(
540                debug.contains("redacted"),
541                "debug output must redact keys: {debug}"
542            );
543            assert!(
544                !debug.contains("hmac_key: ["),
545                "hmac_key must not appear as raw bytes: {debug}"
546            );
547        }
548
549        #[test]
550        fn empty_key_list_is_rejected() {
551            let err = ApiKeyConfig::new(&[]).unwrap_err();
552            assert!(matches!(err, AuthConfigError::EmptyKeyList));
553        }
554
555        #[test]
556        fn key_with_whitespace_is_rejected() {
557            let err = ApiKeyConfig::new(&["valid-key", "bad key"]).unwrap_err();
558            assert!(matches!(err, AuthConfigError::WhitespaceInKey));
559        }
560
561        #[test]
562        fn key_with_leading_whitespace_is_rejected() {
563            let err = ApiKeyConfig::new(&[" leading"]).unwrap_err();
564            assert!(matches!(err, AuthConfigError::WhitespaceInKey));
565        }
566
567        #[test]
568        fn key_with_trailing_whitespace_is_rejected() {
569            let err = ApiKeyConfig::new(&["trailing "]).unwrap_err();
570            assert!(matches!(err, AuthConfigError::WhitespaceInKey));
571        }
572
573        #[test]
574        fn valid_single_key_is_accepted() {
575            assert!(ApiKeyConfig::new(&["valid-key"]).is_ok());
576        }
577
578        #[test]
579        fn valid_multiple_keys_are_accepted() {
580            assert!(ApiKeyConfig::new(&["key-one", "key-two", "key-three"]).is_ok());
581        }
582
583        // ── Helper to build a test service ───────────────────────────────────────
584
585        type OkFn = fn(
586            Request<Body>,
587        )
588            -> std::future::Ready<Result<Response<Body>, std::convert::Infallible>>;
589        type TestSvc = ApiKeyAuthService<tower::util::ServiceFn<OkFn>>;
590
591        fn make_service(key: &str) -> TestSvc {
592            let config = ApiKeyConfig::new(&[key]).expect("valid key");
593            let layer = ApiKeyAuthLayer::new(config);
594            layer.layer(tower::service_fn(|_req: Request<Body>| {
595                std::future::ready(Ok::<_, std::convert::Infallible>(
596                    Response::builder()
597                        .status(StatusCode::OK)
598                        .body(Body::empty())
599                        .unwrap(),
600                ))
601            }))
602        }
603
604        // ── Authentication behaviour ─────────────────────────────────────────────
605
606        #[tokio::test]
607        async fn valid_bearer_token_returns_200() {
608            let mut svc = make_service("my-secret-key");
609            let req = Request::builder()
610                .method(Method::GET)
611                .header("Authorization", "Bearer my-secret-key")
612                .body(Body::empty())
613                .unwrap();
614            let resp = svc.ready().await.unwrap().call(req).await.unwrap();
615            assert_eq!(resp.status(), StatusCode::OK);
616        }
617
618        #[tokio::test]
619        async fn valid_x_pjs_api_key_returns_200() {
620            let mut svc = make_service("my-secret-key");
621            let req = Request::builder()
622                .method(Method::GET)
623                .header("X-PJS-API-Key", "my-secret-key")
624                .body(Body::empty())
625                .unwrap();
626            let resp = svc.ready().await.unwrap().call(req).await.unwrap();
627            assert_eq!(resp.status(), StatusCode::OK);
628        }
629
630        #[tokio::test]
631        async fn wrong_token_returns_401() {
632            let mut svc = make_service("my-secret-key");
633            let req = Request::builder()
634                .method(Method::GET)
635                .header("Authorization", "Bearer wrong-key")
636                .body(Body::empty())
637                .unwrap();
638            let resp = svc.ready().await.unwrap().call(req).await.unwrap();
639            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
640        }
641
642        #[tokio::test]
643        async fn missing_header_returns_401() {
644            let mut svc = make_service("my-secret-key");
645            let req = Request::builder()
646                .method(Method::GET)
647                .body(Body::empty())
648                .unwrap();
649            let resp = svc.ready().await.unwrap().call(req).await.unwrap();
650            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
651        }
652
653        #[tokio::test]
654        async fn options_bypasses_auth() {
655            // OPTIONS must pass through even with no auth header.
656            let mut svc = make_service("my-secret-key");
657            let req = Request::builder()
658                .method(Method::OPTIONS)
659                .body(Body::empty())
660                .unwrap();
661            let resp = svc.ready().await.unwrap().call(req).await.unwrap();
662            // The inner handler returns 200; auth must not block OPTIONS.
663            assert_eq!(resp.status(), StatusCode::OK);
664        }
665
666        // ── matches_any timing correctness (structural, not wall-clock) ──────────
667
668        #[test]
669        fn matches_any_correct_single_key() {
670            let config = ApiKeyConfig::new(&["secret"]).unwrap();
671            let state = ApiKeyState {
672                hmac_key: config.hmac_key,
673                tags: config.keys,
674            };
675            assert!(matches_any(&state, b"secret"));
676            assert!(!matches_any(&state, b"wrong"));
677        }
678
679        #[test]
680        fn matches_any_correct_multiple_keys() {
681            let config = ApiKeyConfig::new(&["key-a", "key-b", "key-c"]).unwrap();
682            let state = ApiKeyState {
683                hmac_key: config.hmac_key,
684                tags: config.keys,
685            };
686            assert!(matches_any(&state, b"key-a"));
687            assert!(matches_any(&state, b"key-b"));
688            assert!(matches_any(&state, b"key-c"));
689            assert!(!matches_any(&state, b"key-d"));
690        }
691
692        // ── extract_token ────────────────────────────────────────────────────────
693
694        #[test]
695        fn extract_token_bearer() {
696            let req = Request::builder()
697                .header("Authorization", "Bearer test-token")
698                .body(Body::empty())
699                .unwrap();
700            assert_eq!(extract_token(&req), Some(b"test-token".as_slice()));
701        }
702
703        #[test]
704        fn extract_token_x_pjs_api_key() {
705            let req = Request::builder()
706                .header("X-PJS-API-Key", "test-token")
707                .body(Body::empty())
708                .unwrap();
709            assert_eq!(extract_token(&req), Some(b"test-token".as_slice()));
710        }
711
712        #[test]
713        fn extract_token_none_when_absent() {
714            let req = Request::builder().body(Body::empty()).unwrap();
715            assert_eq!(extract_token(&req), None);
716        }
717
718        #[test]
719        fn extract_token_bearer_preferred_over_x_pjs() {
720            let req = Request::builder()
721                .header("Authorization", "Bearer bearer-val")
722                .header("X-PJS-API-Key", "api-key-val")
723                .body(Body::empty())
724                .unwrap();
725            assert_eq!(extract_token(&req), Some(b"bearer-val".as_slice()));
726        }
727    }
728}
729
730// Re-export everything from the inner module under the feature gate.
731#[cfg(feature = "http-server")]
732pub use inner::{ApiKeyAuthLayer, ApiKeyAuthService, ApiKeyConfig, AuthConfigError};
733
734#[cfg(all(feature = "http-server", feature = "http-auth-jwt"))]
735pub use inner::{JwtAuthLayer, JwtAuthService, JwtConfig};