arc_web/http/middlewares/
jwt_middleware.rs1use 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 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 return Box::pin(async move {
159 let r = fut.await?;
160 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 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 Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
196 }
197}
198
199fn 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#[allow(dead_code)]
214pub(crate) fn _arc_store_marker(_: &Arc<dyn SessionStore>) {}