Skip to main content

rest/
auth.rs

1use actix_web::{
2    body::{EitherBody, MessageBody},
3    dev::{Service, ServiceRequest, ServiceResponse, Transform},
4    http::{header, StatusCode},
5    Error, HttpMessage, HttpRequest, HttpResponse,
6};
7use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
8use futures::future::{ok, LocalBoxFuture, Ready};
9use hmac::{Hmac, Mac};
10use rust_zero_core::{
11    AuthFailure, JwtClaimProjection, RequestSignature, RequestSignatureVerifier,
12    AUTH_KEY_ID_HEADER, AUTH_SIGNATURE_HEADER, AUTH_TIMESTAMP_HEADER,
13};
14use serde::{de::DeserializeOwned, Serialize};
15use sha2::Sha256;
16use std::{
17    collections::BTreeMap,
18    fmt,
19    sync::Arc,
20    task::{Context, Poll},
21    time::{SystemTime, UNIX_EPOCH},
22};
23
24#[derive(Serialize)]
25struct AuthFailureBody {
26    code: &'static str,
27    message: &'static str,
28}
29
30fn auth_failure_response(failure: AuthFailure) -> HttpResponse {
31    HttpResponse::build(StatusCode::UNAUTHORIZED)
32        .insert_header((header::WWW_AUTHENTICATE, "Bearer"))
33        .json(AuthFailureBody {
34            code: failure.code(),
35            message: failure.message(),
36        })
37}
38
39type Validator<T> = dyn Fn(&str) -> Option<T> + Send + Sync;
40
41/// Validates bearer credentials and stores the resulting identity in request extensions.
42pub struct BearerAuth<T> {
43    validator: Arc<Validator<T>>,
44    challenge: header::HeaderValue,
45}
46
47impl<T> Clone for BearerAuth<T> {
48    fn clone(&self) -> Self {
49        Self {
50            validator: Arc::clone(&self.validator),
51            challenge: self.challenge.clone(),
52        }
53    }
54}
55
56impl<T> BearerAuth<T>
57where
58    T: Clone + 'static,
59{
60    pub fn new(validator: impl Fn(&str) -> Option<T> + Send + Sync + 'static) -> Self {
61        Self {
62            validator: Arc::new(validator),
63            challenge: header::HeaderValue::from_static("Bearer"),
64        }
65    }
66
67    pub fn with_realm(mut self, realm: &str) -> Result<Self, header::InvalidHeaderValue> {
68        self.challenge = header::HeaderValue::from_str(&format!("Bearer realm=\"{realm}\""))?;
69        Ok(self)
70    }
71
72    /// Returns the authenticated identity installed by this middleware.
73    pub fn authenticated(request: &HttpRequest) -> Option<T> {
74        request
75            .extensions()
76            .get::<Authenticated<T>>()
77            .map(|identity| identity.0.clone())
78    }
79}
80
81#[derive(Debug, Clone)]
82struct Authenticated<T>(T);
83
84impl<S, B, T> Transform<S, ServiceRequest> for BearerAuth<T>
85where
86    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
87    S::Future: 'static,
88    B: MessageBody + 'static,
89    T: Clone + 'static,
90{
91    type Response = ServiceResponse<EitherBody<B>>;
92    type Error = Error;
93    type Transform = BearerAuthMiddleware<S, T>;
94    type InitError = ();
95    type Future = Ready<Result<Self::Transform, Self::InitError>>;
96
97    fn new_transform(&self, service: S) -> Self::Future {
98        ok(BearerAuthMiddleware {
99            service,
100            validator: Arc::clone(&self.validator),
101            challenge: self.challenge.clone(),
102        })
103    }
104}
105
106pub struct BearerAuthMiddleware<S, T> {
107    service: S,
108    validator: Arc<Validator<T>>,
109    challenge: header::HeaderValue,
110}
111
112impl<S, B, T> Service<ServiceRequest> for BearerAuthMiddleware<S, T>
113where
114    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
115    S::Future: 'static,
116    B: MessageBody + 'static,
117    T: Clone + 'static,
118{
119    type Response = ServiceResponse<EitherBody<B>>;
120    type Error = Error;
121    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
122
123    fn poll_ready(&self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
124        self.service.poll_ready(context)
125    }
126
127    fn call(&self, request: ServiceRequest) -> Self::Future {
128        let identity = request
129            .headers()
130            .get(header::AUTHORIZATION)
131            .and_then(|value| value.to_str().ok())
132            .and_then(bearer_token)
133            .and_then(|token| (self.validator)(token));
134
135        let Some(identity) = identity else {
136            let challenge = self.challenge.clone();
137            return Box::pin(async move {
138                let mut response = auth_failure_response(AuthFailure::InvalidCredentials);
139                response
140                    .headers_mut()
141                    .insert(header::WWW_AUTHENTICATE, challenge);
142                Ok(request.into_response(response.map_into_right_body()))
143            });
144        };
145
146        request.extensions_mut().insert(Authenticated(identity));
147        let future = self.service.call(request);
148        Box::pin(async move { Ok(future.await?.map_into_left_body()) })
149    }
150}
151
152pub(crate) fn bearer_token(value: &str) -> Option<&str> {
153    let (scheme, token) = value.split_once(char::is_whitespace)?;
154    let token = token.trim();
155    (scheme.eq_ignore_ascii_case("bearer")
156        && !token.is_empty()
157        && !token.contains(char::is_whitespace))
158    .then_some(token)
159}
160
161type HmacSha256 = Hmac<Sha256>;
162
163/// Errors produced while decoding or validating an HS256 JSON Web Token.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum JwtError {
166    Malformed,
167    UnsupportedAlgorithm,
168    InvalidSignature,
169    Expired,
170    NotYetValid,
171    InvalidClaims,
172}
173
174impl fmt::Display for JwtError {
175    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176        formatter.write_str(match self {
177            Self::Malformed => "malformed JWT",
178            Self::UnsupportedAlgorithm => "JWT must use HS256",
179            Self::InvalidSignature => "invalid JWT signature",
180            Self::Expired => "JWT has expired",
181            Self::NotYetValid => "JWT is not yet valid",
182            Self::InvalidClaims => "invalid JWT claims",
183        })
184    }
185}
186
187impl std::error::Error for JwtError {}
188
189impl From<JwtError> for AuthFailure {
190    fn from(error: JwtError) -> Self {
191        match error {
192            JwtError::Malformed | JwtError::UnsupportedAlgorithm | JwtError::InvalidClaims => {
193                Self::MalformedCredentials
194            }
195            JwtError::InvalidSignature => Self::InvalidCredentials,
196            JwtError::Expired => Self::ExpiredCredentials,
197            JwtError::NotYetValid => Self::NotYetValid,
198        }
199    }
200}
201
202#[derive(Debug, Clone)]
203pub(crate) struct ProjectedClaims(pub(crate) BTreeMap<String, serde_json::Value>);
204
205/// Encodes serializable claims as an HS256 JSON Web Token.
206pub fn encode_hs256<T>(claims: &T, secret: &[u8]) -> Result<String, JwtError>
207where
208    T: Serialize,
209{
210    if secret.is_empty() {
211        return Err(JwtError::InvalidSignature);
212    }
213    let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
214    let claims =
215        URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).map_err(|_| JwtError::InvalidClaims)?);
216    let signing_input = format!("{header}.{claims}");
217    let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| JwtError::InvalidSignature)?;
218    mac.update(signing_input.as_bytes());
219    let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
220    Ok(format!("{signing_input}.{signature}"))
221}
222
223pub(crate) fn decode_hs256<T>(
224    token: &str,
225    secrets: &[Arc<[u8]>],
226    leeway_seconds: u64,
227) -> Result<T, JwtError>
228where
229    T: DeserializeOwned,
230{
231    let mut segments = token.split('.');
232    let header = segments.next().ok_or(JwtError::Malformed)?;
233    let claims = segments.next().ok_or(JwtError::Malformed)?;
234    let signature = segments.next().ok_or(JwtError::Malformed)?;
235    if segments.next().is_some() {
236        return Err(JwtError::Malformed);
237    }
238
239    let header_value: serde_json::Value = serde_json::from_slice(
240        &URL_SAFE_NO_PAD
241            .decode(header)
242            .map_err(|_| JwtError::Malformed)?,
243    )
244    .map_err(|_| JwtError::Malformed)?;
245    if header_value.get("alg").and_then(|value| value.as_str()) != Some("HS256") {
246        return Err(JwtError::UnsupportedAlgorithm);
247    }
248
249    let signature = URL_SAFE_NO_PAD
250        .decode(signature)
251        .map_err(|_| JwtError::Malformed)?;
252    let signing_input = format!("{header}.{claims}");
253    let valid = secrets.iter().any(|secret| {
254        HmacSha256::new_from_slice(secret)
255            .map(|mut mac| {
256                mac.update(signing_input.as_bytes());
257                mac.verify_slice(&signature).is_ok()
258            })
259            .unwrap_or(false)
260    });
261    if !valid {
262        return Err(JwtError::InvalidSignature);
263    }
264
265    let claim_bytes = URL_SAFE_NO_PAD
266        .decode(claims)
267        .map_err(|_| JwtError::Malformed)?;
268    let claim_value: serde_json::Value =
269        serde_json::from_slice(&claim_bytes).map_err(|_| JwtError::InvalidClaims)?;
270    let now = SystemTime::now()
271        .duration_since(UNIX_EPOCH)
272        .unwrap_or_default()
273        .as_secs();
274    if claim_value
275        .get("exp")
276        .and_then(serde_json::Value::as_u64)
277        .is_some_and(|expires| now > expires.saturating_add(leeway_seconds))
278    {
279        return Err(JwtError::Expired);
280    }
281    if claim_value
282        .get("nbf")
283        .and_then(serde_json::Value::as_u64)
284        .is_some_and(|not_before| now.saturating_add(leeway_seconds) < not_before)
285    {
286        return Err(JwtError::NotYetValid);
287    }
288
289    serde_json::from_slice(&claim_bytes).map_err(|_| JwtError::InvalidClaims)
290}
291
292/// Validates HS256 bearer tokens and exposes their typed claims to handlers.
293///
294/// A previous secret can be accepted during key rotation. Token headers are constrained to HS256
295/// before signature verification to prevent algorithm-confusion attacks.
296pub struct JwtAuth<T> {
297    secrets: Vec<Arc<[u8]>>,
298    leeway_seconds: u64,
299    challenge: header::HeaderValue,
300    projection: JwtClaimProjection,
301    marker: std::marker::PhantomData<fn() -> T>,
302}
303
304impl<T> Clone for JwtAuth<T> {
305    fn clone(&self) -> Self {
306        Self {
307            secrets: self.secrets.clone(),
308            leeway_seconds: self.leeway_seconds,
309            challenge: self.challenge.clone(),
310            projection: self.projection.clone(),
311            marker: std::marker::PhantomData,
312        }
313    }
314}
315
316impl<T> JwtAuth<T>
317where
318    T: Clone + DeserializeOwned + 'static,
319{
320    pub fn new(secret: impl AsRef<[u8]>) -> Self {
321        let secret = secret.as_ref();
322        assert!(!secret.is_empty(), "JWT secret cannot be empty");
323        Self {
324            secrets: vec![Arc::from(secret)],
325            leeway_seconds: 0,
326            challenge: header::HeaderValue::from_static("Bearer"),
327            projection: JwtClaimProjection::default(),
328            marker: std::marker::PhantomData,
329        }
330    }
331
332    pub fn with_previous_secret(mut self, secret: impl AsRef<[u8]>) -> Self {
333        let secret = secret.as_ref();
334        assert!(!secret.is_empty(), "previous JWT secret cannot be empty");
335        self.secrets.push(Arc::from(secret));
336        self
337    }
338
339    pub fn with_leeway(mut self, seconds: u64) -> Self {
340        self.leeway_seconds = seconds;
341        self
342    }
343
344    pub fn with_claim_projection(mut self, projection: JwtClaimProjection) -> Self {
345        self.projection = projection;
346        self
347    }
348
349    pub fn claims(request: &HttpRequest) -> Option<T> {
350        request
351            .extensions()
352            .get::<JwtClaims<T>>()
353            .map(|claims| claims.0.clone())
354    }
355
356    pub fn projected_claims(request: &HttpRequest) -> Option<BTreeMap<String, serde_json::Value>> {
357        request
358            .extensions()
359            .get::<ProjectedClaims>()
360            .map(|claims| claims.0.clone())
361    }
362}
363
364#[derive(Debug, Clone)]
365pub(crate) struct JwtClaims<T>(pub(crate) T);
366
367impl<S, B, T> Transform<S, ServiceRequest> for JwtAuth<T>
368where
369    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
370    S::Future: 'static,
371    B: MessageBody + 'static,
372    T: Clone + DeserializeOwned + 'static,
373{
374    type Response = ServiceResponse<EitherBody<B>>;
375    type Error = Error;
376    type Transform = JwtAuthMiddleware<S, T>;
377    type InitError = ();
378    type Future = Ready<Result<Self::Transform, Self::InitError>>;
379
380    fn new_transform(&self, service: S) -> Self::Future {
381        ok(JwtAuthMiddleware {
382            service,
383            secrets: self.secrets.clone(),
384            leeway_seconds: self.leeway_seconds,
385            challenge: self.challenge.clone(),
386            projection: self.projection.clone(),
387            marker: std::marker::PhantomData,
388        })
389    }
390}
391
392pub struct JwtAuthMiddleware<S, T> {
393    service: S,
394    secrets: Vec<Arc<[u8]>>,
395    leeway_seconds: u64,
396    challenge: header::HeaderValue,
397    projection: JwtClaimProjection,
398    marker: std::marker::PhantomData<fn() -> T>,
399}
400
401impl<S, B, T> Service<ServiceRequest> for JwtAuthMiddleware<S, T>
402where
403    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
404    S::Future: 'static,
405    B: MessageBody + 'static,
406    T: Clone + DeserializeOwned + 'static,
407{
408    type Response = ServiceResponse<EitherBody<B>>;
409    type Error = Error;
410    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
411
412    fn poll_ready(&self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
413        self.service.poll_ready(context)
414    }
415
416    fn call(&self, request: ServiceRequest) -> Self::Future {
417        let token = request
418            .headers()
419            .get(header::AUTHORIZATION)
420            .and_then(|value| value.to_str().ok())
421            .and_then(bearer_token)
422            .ok_or(AuthFailure::MissingCredentials);
423        let result = token.and_then(|token| {
424            decode_hs256::<T>(token, &self.secrets, self.leeway_seconds).map_err(AuthFailure::from)
425        });
426
427        let claims = match result {
428            Ok(claims) => claims,
429            Err(failure) => {
430                let challenge = self.challenge.clone();
431                return Box::pin(async move {
432                    let mut response = auth_failure_response(failure);
433                    response
434                        .headers_mut()
435                        .insert(header::WWW_AUTHENTICATE, challenge);
436                    Ok(request.into_response(response.map_into_right_body()))
437                });
438            }
439        };
440
441        let projected = token
442            .ok()
443            .and_then(|token| {
444                decode_hs256::<serde_json::Value>(token, &self.secrets, self.leeway_seconds).ok()
445            })
446            .map(|value| self.projection.project(&value))
447            .unwrap_or_default();
448        request.extensions_mut().insert(ProjectedClaims(projected));
449        request.extensions_mut().insert(JwtClaims(claims));
450        let future = self.service.call(request);
451        Box::pin(async move { Ok(future.await?.map_into_left_body()) })
452    }
453}
454
455/// Validates time-window-bounded HMAC signatures over the HTTP method and path/query target.
456#[derive(Debug, Clone)]
457pub struct RequestSignatureAuth {
458    verifier: RequestSignatureVerifier,
459}
460
461impl RequestSignatureAuth {
462    pub fn new(verifier: RequestSignatureVerifier) -> Self {
463        Self { verifier }
464    }
465
466    pub fn key_id(request: &HttpRequest) -> Option<String> {
467        request
468            .extensions()
469            .get::<SignatureKeyId>()
470            .map(|id| id.0.clone())
471    }
472}
473
474#[derive(Debug, Clone)]
475struct SignatureKeyId(String);
476
477impl<S, B> Transform<S, ServiceRequest> for RequestSignatureAuth
478where
479    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
480    S::Future: 'static,
481    B: MessageBody + 'static,
482{
483    type Response = ServiceResponse<EitherBody<B>>;
484    type Error = Error;
485    type Transform = RequestSignatureAuthMiddleware<S>;
486    type InitError = ();
487    type Future = Ready<Result<Self::Transform, Self::InitError>>;
488
489    fn new_transform(&self, service: S) -> Self::Future {
490        ok(RequestSignatureAuthMiddleware {
491            service,
492            verifier: self.verifier.clone(),
493        })
494    }
495}
496
497pub struct RequestSignatureAuthMiddleware<S> {
498    service: S,
499    verifier: RequestSignatureVerifier,
500}
501
502impl<S, B> Service<ServiceRequest> for RequestSignatureAuthMiddleware<S>
503where
504    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
505    S::Future: 'static,
506    B: MessageBody + 'static,
507{
508    type Response = ServiceResponse<EitherBody<B>>;
509    type Error = Error;
510    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
511
512    fn poll_ready(&self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
513        self.service.poll_ready(context)
514    }
515
516    fn call(&self, request: ServiceRequest) -> Self::Future {
517        let result = parse_signature(&request).and_then(|signature| {
518            self.verifier.verify(
519                &signature,
520                request.method().as_str(),
521                request
522                    .uri()
523                    .path_and_query()
524                    .map_or(request.path(), |value| value.as_str()),
525                unix_seconds(),
526            )?;
527            Ok(signature.key_id)
528        });
529        let key_id = match result {
530            Ok(key_id) => key_id,
531            Err(failure) => {
532                return Box::pin(async move {
533                    Ok(request.into_response(auth_failure_response(failure).map_into_right_body()))
534                });
535            }
536        };
537        request.extensions_mut().insert(SignatureKeyId(key_id));
538        let future = self.service.call(request);
539        Box::pin(async move { Ok(future.await?.map_into_left_body()) })
540    }
541}
542
543fn parse_signature(request: &ServiceRequest) -> Result<RequestSignature, AuthFailure> {
544    let header = |name| {
545        request
546            .headers()
547            .get(name)
548            .and_then(|value| value.to_str().ok())
549    };
550    let key_id = header(AUTH_KEY_ID_HEADER).ok_or(AuthFailure::MissingSignature)?;
551    let timestamp = header(AUTH_TIMESTAMP_HEADER)
552        .ok_or(AuthFailure::MissingSignature)?
553        .parse()
554        .map_err(|_| AuthFailure::InvalidSignature)?;
555    let signature = header(AUTH_SIGNATURE_HEADER).ok_or(AuthFailure::MissingSignature)?;
556    Ok(RequestSignature {
557        key_id: key_id.to_owned(),
558        timestamp,
559        signature: signature.to_owned(),
560    })
561}
562
563fn unix_seconds() -> i64 {
564    SystemTime::now()
565        .duration_since(UNIX_EPOCH)
566        .unwrap_or_default()
567        .as_secs() as i64
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use actix_web::{test, web, App};
574    use serde::{Deserialize, Serialize};
575
576    #[actix_rt::test]
577    async fn authenticates_and_exposes_the_identity() {
578        let app = test::init_service(
579            App::new()
580                .wrap(BearerAuth::new(|token| {
581                    (token == "valid").then(|| "user-42".to_owned())
582                }))
583                .route(
584                    "/",
585                    web::get().to(|request: HttpRequest| async move {
586                        BearerAuth::<String>::authenticated(&request).unwrap()
587                    }),
588                ),
589        )
590        .await;
591
592        let request = test::TestRequest::get()
593            .uri("/")
594            .insert_header((header::AUTHORIZATION, "Bearer valid"))
595            .to_request();
596        let response = test::call_service(&app, request).await;
597
598        assert_eq!(response.status(), StatusCode::OK);
599        assert_eq!(test::read_body(response).await, "user-42");
600    }
601
602    #[actix_rt::test]
603    async fn rejects_missing_and_invalid_credentials() {
604        let app = test::init_service(
605            App::new()
606                .wrap(
607                    BearerAuth::new(|token| (token == "valid").then_some(()))
608                        .with_realm("api")
609                        .unwrap(),
610                )
611                .route("/", web::get().to(|| async { "secret" })),
612        )
613        .await;
614
615        for authorization in [None, Some("Basic abc"), Some("Bearer invalid")] {
616            let mut request = test::TestRequest::get().uri("/");
617            if let Some(value) = authorization {
618                request = request.insert_header((header::AUTHORIZATION, value));
619            }
620            let response = test::call_service(&app, request.to_request()).await;
621            assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
622            assert_eq!(
623                response.headers().get(header::WWW_AUTHENTICATE).unwrap(),
624                "Bearer realm=\"api\""
625            );
626        }
627    }
628
629    #[derive(Debug, Clone, Deserialize, Serialize)]
630    struct Claims {
631        sub: String,
632        exp: u64,
633    }
634
635    #[actix_rt::test]
636    async fn validates_jwt_claims_and_supports_secret_rotation() {
637        let claims = Claims {
638            sub: "user-42".to_owned(),
639            exp: SystemTime::now()
640                .duration_since(UNIX_EPOCH)
641                .unwrap()
642                .as_secs()
643                + 60,
644        };
645        let token = encode_hs256(&claims, b"old-secret").unwrap();
646        let app = test::init_service(
647            App::new()
648                .wrap(JwtAuth::<Claims>::new("new-secret").with_previous_secret("old-secret"))
649                .route(
650                    "/",
651                    web::get().to(|request: HttpRequest| async move {
652                        JwtAuth::<Claims>::claims(&request).unwrap().sub
653                    }),
654                ),
655        )
656        .await;
657
658        let response = test::call_service(
659            &app,
660            test::TestRequest::get()
661                .uri("/")
662                .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
663                .to_request(),
664        )
665        .await;
666        assert_eq!(response.status(), StatusCode::OK);
667        assert_eq!(test::read_body(response).await, "user-42");
668    }
669
670    #[actix_rt::test]
671    async fn rejects_expired_jwts() {
672        let token = encode_hs256(
673            &Claims {
674                sub: "user-42".to_owned(),
675                exp: 1,
676            },
677            b"secret",
678        )
679        .unwrap();
680        let app = test::init_service(
681            App::new()
682                .wrap(JwtAuth::<Claims>::new("secret"))
683                .route("/", web::get().to(|| async { "secret" })),
684        )
685        .await;
686        let response = test::call_service(
687            &app,
688            test::TestRequest::get()
689                .uri("/")
690                .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
691                .to_request(),
692        )
693        .await;
694        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
695    }
696
697    #[actix_rt::test]
698    async fn projects_selected_jwt_claims() {
699        let token = encode_hs256(
700            &Claims {
701                sub: "user-42".to_owned(),
702                exp: unix_seconds() as u64 + 60,
703            },
704            b"secret",
705        )
706        .unwrap();
707        let projection = JwtClaimProjection::new([("caller".to_owned(), "sub".to_owned())]);
708        let app = test::init_service(
709            App::new()
710                .wrap(JwtAuth::<Claims>::new("secret").with_claim_projection(projection))
711                .route(
712                    "/",
713                    web::get().to(|request: HttpRequest| async move {
714                        web::Json(JwtAuth::<Claims>::projected_claims(&request).unwrap())
715                    }),
716                ),
717        )
718        .await;
719        let response = test::call_service(
720            &app,
721            test::TestRequest::get()
722                .uri("/")
723                .insert_header((header::AUTHORIZATION, format!("Bearer {token}")))
724                .to_request(),
725        )
726        .await;
727        let projected: serde_json::Value = test::read_body_json(response).await;
728        assert_eq!(projected["caller"], "user-42");
729        assert!(projected.get("exp").is_none());
730    }
731
732    #[actix_rt::test]
733    async fn authenticates_signed_http_targets_and_reports_stable_errors() {
734        let verifier = RequestSignatureVerifier::new(
735            [("client".to_owned(), b"secret".to_vec())],
736            std::time::Duration::from_secs(30),
737        )
738        .unwrap();
739        let app = test::init_service(App::new().wrap(RequestSignatureAuth::new(verifier)).route(
740            "/jobs",
741            web::post().to(|request: HttpRequest| async move {
742                RequestSignatureAuth::key_id(&request).unwrap()
743            }),
744        ))
745        .await;
746        let signature = rust_zero_core::sign_request(
747            "client",
748            b"secret",
749            unix_seconds(),
750            "POST",
751            "/jobs?priority=high",
752        )
753        .unwrap();
754        let response = test::call_service(
755            &app,
756            test::TestRequest::post()
757                .uri("/jobs?priority=high")
758                .insert_header((AUTH_KEY_ID_HEADER, signature.key_id))
759                .insert_header((AUTH_TIMESTAMP_HEADER, signature.timestamp.to_string()))
760                .insert_header((AUTH_SIGNATURE_HEADER, signature.signature))
761                .to_request(),
762        )
763        .await;
764        assert_eq!(test::read_body(response).await, "client");
765
766        let response =
767            test::call_service(&app, test::TestRequest::post().uri("/jobs").to_request()).await;
768        let failure: serde_json::Value = test::read_body_json(response).await;
769        assert_eq!(failure["code"], "auth_missing_signature");
770    }
771}