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};