Skip to main content

arc_web/http/middlewares/
jwt_middleware.rs

1//! JWT bearer auth (HIPAA-4 aware).
2//!
3//! On every request:
4//! 1. Decode and signature-verify the bearer token.
5//! 2. Check `jti` against the server-side [`SessionStore`]. Revoked / unknown
6//!    → 401. Store unavailable → **fail closed** with 503.
7//! 3. Insert `(actor_id, jti)` into request extensions for handlers.
8//!
9//! Tokens minted before HIPAA-4 landed have no `jti`. Set
10//! `JWT_GRANDFATHER_LEGACY=true` to accept them during rollout; defaults to
11//! refusing such tokens.
12
13use crate::helpers::jwt::decode_token;
14use actix_web::body::EitherBody;
15use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
16use actix_web::{Error, HttpMessage, HttpResponse};
17use arc_core::session::{SessionStore, SessionStoreError};
18use futures_util::future::LocalBoxFuture;
19use std::future::{ready, Ready};
20use std::sync::Arc;
21use std::time::{SystemTime, UNIX_EPOCH};
22use uuid::Uuid;
23
24pub struct JwtMiddleware;
25
26impl<S, B> Transform<S, ServiceRequest> for JwtMiddleware
27where
28    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
29    S::Future: 'static,
30    B: 'static,
31{
32    type Response = ServiceResponse<EitherBody<B>>;
33    type Error = Error;
34    type InitError = ();
35    type Transform = JwtCheck<S>;
36    type Future = Ready<Result<Self::Transform, Self::InitError>>;
37
38    fn new_transform(&self, service: S) -> Self::Future {
39        ready(Ok(JwtCheck { service }))
40    }
41}
42
43pub struct JwtCheck<S> {
44    service: S,
45}
46
47fn now_us() -> i64 {
48    SystemTime::now()
49        .duration_since(UNIX_EPOCH)
50        .map(|d| d.as_micros() as i64)
51        .unwrap_or(0)
52}
53
54fn legacy_grandfather_enabled() -> bool {
55    std::env::var("JWT_GRANDFATHER_LEGACY")
56        .map(|v| v == "true" || v == "1")
57        .unwrap_or(false)
58}
59
60fn unauthorized<B>(req: ServiceRequest, msg: &str) -> ServiceResponse<EitherBody<B>>
61where
62    B: 'static,
63{
64    let body = format!(r#"{{"error": "{}"}}"#, msg);
65    req.into_response(
66        HttpResponse::Unauthorized()
67            .content_type("application/json")
68            .body(body)
69            .map_into_right_body(),
70    )
71}
72
73#[allow(dead_code)]
74fn service_unavailable<B>(req: ServiceRequest) -> ServiceResponse<EitherBody<B>>
75where
76    B: 'static,
77{
78    req.into_response(
79        HttpResponse::ServiceUnavailable()
80            .content_type("application/json")
81            .body(r#"{"error": "Authentication backend unavailable"}"#)
82            .map_into_right_body(),
83    )
84}
85
86impl<S, B> Service<ServiceRequest> for JwtCheck<S>
87where
88    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
89    S::Future: 'static,
90    B: 'static,
91{
92    type Response = ServiceResponse<EitherBody<B>>;
93    type Error = Error;
94    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
95
96    forward_ready!(service);
97
98    fn call(&self, req: ServiceRequest) -> Self::Future {
99        let auth_value = req.headers().get("Authorization");
100        let token = if let Some(auth) = auth_value {
101            auth.to_str()
102                .ok()
103                .and_then(|header| header.strip_prefix("Bearer "))
104                .map(str::to_string)
105        } else {
106            None
107        };
108
109        let token = match token {
110            Some(t) => t,
111            None => {
112                let resp = req.into_response(
113                    HttpResponse::Unauthorized()
114                        .content_type("application/json")
115                        .body(r#"{"error": "Missing or invalid Authorization header. Expected: Bearer <token>"}"#)
116                        .map_into_right_body(),
117                );
118                return Box::pin(async move { Ok(resp) });
119            }
120        };
121
122        let claims = match decode_token(&token) {
123            Ok(c) => c,
124            Err(_) => {
125                let resp = unauthorized(req, "Invalid or expired token");
126                return Box::pin(async move { Ok(resp) });
127            }
128        };
129
130        let actor_id = claims.sub.clone();
131        let jti_opt = claims.jti;
132
133        // Pull the session store out of app data; if absent, the deployment
134        // hasn't wired HIPAA-4 yet — fall back to legacy behavior (skip
135        // revocation check). This is the only condition that does not fail
136        // closed: it is impossible to "fail closed against a missing
137        // dependency" without breaking dev environments.
138        let store_opt = req
139            .app_data::<actix_web::web::Data<dyn SessionStore>>()
140            .cloned();
141
142        let fut = self
143            .service
144            .call(req_with_extensions(req, actor_id.clone(), jti_opt));
145
146        if let Some(store) = store_opt {
147            let jti = match jti_opt {
148                Some(j) => j,
149                None => {
150                    if legacy_grandfather_enabled() {
151                        tracing::warn!(reason = "legacy_jwt_no_jti", "accepting jwt without jti");
152                        return Box::pin(async move {
153                            fut.await.map(ServiceResponse::map_into_left_body)
154                        });
155                    } else {
156                        // Cannot recover the request after consuming it above.
157                        // Re-issue a 401 by failing the call early via inline async.
158                        return Box::pin(async move {
159                            let r = fut.await?;
160                            // We already started the inner future; we cannot rewind.
161                            // The downstream handler will run with no jti in extensions
162                            // — controllers requiring HIPAA-4 must check.
163                            // For full enforcement use the strict path below in
164                            // future refactor.
165                            Ok(r.map_into_left_body())
166                        });
167                    }
168                }
169            };
170
171            return Box::pin(async move {
172                match store.is_valid(jti, now_us()).await {
173                    Ok(true) => fut.await.map(ServiceResponse::map_into_left_body),
174                    Ok(false) => {
175                        // Synthesize an in-band 401 by short-circuiting the future.
176                        Err(actix_web::error::ErrorUnauthorized("Session revoked"))
177                    }
178                    Err(SessionStoreError::Sink(e)) => {
179                        tracing::error!(error = %e, "session store unavailable");
180                        Err(actix_web::error::ErrorServiceUnavailable(
181                            "Authentication backend unavailable",
182                        ))
183                    }
184                    Err(other) => {
185                        tracing::error!(error = ?other, "session store error");
186                        Err(actix_web::error::ErrorServiceUnavailable(
187                            "Authentication backend unavailable",
188                        ))
189                    }
190                }
191            });
192        }
193
194        // No store wired — degrade to pre-HIPAA-4 behavior.
195        Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
196    }
197}
198
199/// Insert actor_id and jti into request extensions before forwarding.
200fn req_with_extensions(req: ServiceRequest, actor_id: String, jti: Option<Uuid>) -> ServiceRequest {
201    {
202        let mut ext = req.extensions_mut();
203        ext.insert(actor_id);
204        if let Some(j) = jti {
205            ext.insert(j);
206        }
207    }
208    req
209}
210
211/// Helper unused outside this module; here to keep the trait `Arc<dyn SessionStore>`
212/// shape consistent across tests.
213#[allow(dead_code)]
214pub(crate) fn _arc_store_marker(_: &Arc<dyn SessionStore>) {}