Skip to main content

arc_web/http/middlewares/
idle_timeout_middleware.rs

1//! Idle-timeout middleware (HIPAA-3, §164.312(a)(2)(iii) Automatic Logoff).
2//!
3//! For cookie-session-protected routes: enforce that the session is dropped
4//! when the user has been idle longer than [`IdleTimeoutMiddleware::seconds`].
5//! Configurable via `SESSION_IDLE_TIMEOUT_SECS`; defaults to 900s (15min) per
6//! HHS OCR guidance for systems handling PHI.
7//!
8//! ## Behavior
9//!
10//! On each request:
11//! 1. If the session has no post-cutover `SessionUser`, pass through unchanged.
12//! 2. If `last_active_at` is missing (first authenticated hit), stamp `now`
13//!    and continue.
14//! 3. If `now - last_active_at > idle_timeout`, purge the session and
15//!    redirect to `/signin?reason=idle`. The user must re-authenticate.
16//! 4. Otherwise, refresh `last_active_at = now` and continue.
17//!
18//! Idle timeout is enforced **independently** of the absolute session
19//! lifetime configured in [`actix_session`]. A long-lived session that goes
20//! idle still gets logged out. A short-lived session that's actively used
21//! still expires on its absolute deadline.
22//!
23//! Stateless API requests (JWT-bearer, no session) are unaffected: they have
24//! no session, so step 1 short-circuits.
25
26use actix_session::SessionExt;
27use actix_web::body::EitherBody;
28use actix_web::{
29    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
30    Error, HttpResponse,
31};
32use futures_util::future::LocalBoxFuture;
33use std::future::{ready, Ready};
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use crate::helpers::session::get_session_user;
37
38/// Default per HHS OCR guidance for PHI-bearing systems.
39pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900;
40
41const SESSION_KEY_LAST_ACTIVE: &str = "last_active_at";
42
43/// Idle-timeout enforcement middleware.
44#[derive(Debug, Clone, Copy)]
45pub struct IdleTimeoutMiddleware {
46    pub seconds: u64,
47}
48
49impl IdleTimeoutMiddleware {
50    pub fn new(seconds: u64) -> Self {
51        Self { seconds }
52    }
53
54    /// Construct from `SESSION_IDLE_TIMEOUT_SECS`; falls back to default.
55    pub fn from_env() -> Self {
56        let secs = std::env::var("SESSION_IDLE_TIMEOUT_SECS")
57            .ok()
58            .and_then(|v| v.parse::<u64>().ok())
59            .unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS);
60        Self::new(secs)
61    }
62}
63
64impl Default for IdleTimeoutMiddleware {
65    fn default() -> Self {
66        Self::new(DEFAULT_IDLE_TIMEOUT_SECS)
67    }
68}
69
70impl<S, B> Transform<S, ServiceRequest> for IdleTimeoutMiddleware
71where
72    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
73    S::Future: 'static,
74    B: 'static,
75{
76    type Response = ServiceResponse<EitherBody<B>>;
77    type Error = Error;
78    type InitError = ();
79    type Transform = IdleTimeoutCheck<S>;
80    type Future = Ready<Result<Self::Transform, Self::InitError>>;
81
82    fn new_transform(&self, service: S) -> Self::Future {
83        ready(Ok(IdleTimeoutCheck {
84            service,
85            seconds: self.seconds,
86        }))
87    }
88}
89
90/// Inner service produced by [`IdleTimeoutMiddleware`].
91pub struct IdleTimeoutCheck<S> {
92    service: S,
93    seconds: u64,
94}
95
96fn now_secs() -> u64 {
97    SystemTime::now()
98        .duration_since(UNIX_EPOCH)
99        .map(|d| d.as_secs())
100        .unwrap_or(0)
101}
102
103impl<S, B> Service<ServiceRequest> for IdleTimeoutCheck<S>
104where
105    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
106    S::Future: 'static,
107    B: 'static,
108{
109    type Response = ServiceResponse<EitherBody<B>>;
110    type Error = Error;
111    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
112
113    forward_ready!(service);
114
115    fn call(&self, req: ServiceRequest) -> Self::Future {
116        let session = req.get_session();
117        let limit = self.seconds;
118
119        // No authenticated post-cutover session user -> nothing to enforce.
120        let user_present = get_session_user(&session).is_some();
121        if !user_present {
122            let fut = self.service.call(req);
123            return Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) });
124        }
125
126        let now = now_secs();
127        let last_active: Option<u64> = session.get(SESSION_KEY_LAST_ACTIVE).ok().flatten();
128
129        match last_active {
130            None => {
131                let _ = session.insert(SESSION_KEY_LAST_ACTIVE, now);
132            }
133            Some(prev) if now.saturating_sub(prev) > limit => {
134                tracing::info!(
135                    idle_for_secs = now.saturating_sub(prev),
136                    limit_secs = limit,
137                    "session exceeded idle timeout — purging"
138                );
139                session.purge();
140                return Box::pin(async move {
141                    Ok(req.into_response(
142                        HttpResponse::Found()
143                            .insert_header(("Location", "/signin?reason=idle"))
144                            .finish()
145                            .map_into_right_body(),
146                    ))
147                });
148            }
149            Some(_) => {
150                let _ = session.insert(SESSION_KEY_LAST_ACTIVE, now);
151            }
152        }
153
154        let fut = self.service.call(req);
155        Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) })
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use actix_session::storage::CookieSessionStore;
163    use actix_session::{Session, SessionMiddleware};
164    use actix_web::cookie::{Cookie, Key};
165    use actix_web::{http, test, web, App, HttpResponse};
166
167    fn key() -> Key {
168        Key::from(&[0u8; 64])
169    }
170
171    #[actix_web::test]
172    async fn test_no_session_passes_through() {
173        let app = test::init_service(
174            App::new()
175                .wrap(IdleTimeoutMiddleware::new(60))
176                .wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
177                .route(
178                    "/p",
179                    web::get().to(|| async { HttpResponse::Ok().finish() }),
180                ),
181        )
182        .await;
183        let req = test::TestRequest::get().uri("/p").to_request();
184        let resp = test::call_service(&app, req).await;
185        assert_eq!(resp.status(), http::StatusCode::OK);
186    }
187
188    #[actix_web::test]
189    async fn test_idle_session_redirects_to_signin() {
190        let app = test::init_service(
191            App::new()
192                .wrap(IdleTimeoutMiddleware::new(1))
193                .wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
194                .route(
195                    "/seed",
196                    web::get().to(|s: Session| async move {
197                        crate::helpers::session::set_session_user(
198                            &s,
199                            &crate::helpers::session::SessionUser {
200                                id: "42".to_string(),
201                                name: "Ada".to_string(),
202                                email: "ada@example.test".to_string(),
203                            },
204                        );
205                        // last_active_at = 100s ago — already past the 1s limit
206                        s.insert(SESSION_KEY_LAST_ACTIVE, now_secs().saturating_sub(100))
207                            .unwrap();
208                        HttpResponse::Ok().finish()
209                    }),
210                )
211                .route(
212                    "/protected",
213                    web::get().to(|| async { HttpResponse::Ok().finish() }),
214                ),
215        )
216        .await;
217
218        let seed = test::TestRequest::get().uri("/seed").to_request();
219        let seed_resp = test::call_service(&app, seed).await;
220        assert_eq!(seed_resp.status(), http::StatusCode::OK);
221        let cookie = Cookie::parse_encoded(
222            seed_resp
223                .headers()
224                .get("set-cookie")
225                .unwrap()
226                .to_str()
227                .unwrap(),
228        )
229        .unwrap();
230
231        let req = test::TestRequest::get()
232            .cookie(cookie)
233            .uri("/protected")
234            .to_request();
235        let resp = test::call_service(&app, req).await;
236        assert_eq!(resp.status(), http::StatusCode::FOUND);
237        assert!(
238            resp.headers()
239                .get("location")
240                .and_then(|v| v.to_str().ok())
241                .map(|l| l.contains("/signin?reason=idle"))
242                .unwrap_or(false),
243            "expected redirect to /signin?reason=idle"
244        );
245    }
246
247    #[actix_web::test]
248    async fn test_active_session_refreshes_last_active() {
249        let app = test::init_service(
250            App::new()
251                .wrap(IdleTimeoutMiddleware::new(3600))
252                .wrap(SessionMiddleware::new(CookieSessionStore::default(), key()))
253                .route(
254                    "/seed",
255                    web::get().to(|s: Session| async move {
256                        crate::helpers::session::set_session_user(
257                            &s,
258                            &crate::helpers::session::SessionUser {
259                                id: "42".to_string(),
260                                name: "Ada".to_string(),
261                                email: "ada@example.test".to_string(),
262                            },
263                        );
264                        s.insert(SESSION_KEY_LAST_ACTIVE, now_secs().saturating_sub(10))
265                            .unwrap();
266                        HttpResponse::Ok().finish()
267                    }),
268                )
269                .route(
270                    "/protected",
271                    web::get().to(|| async { HttpResponse::Ok().finish() }),
272                ),
273        )
274        .await;
275
276        let seed = test::TestRequest::get().uri("/seed").to_request();
277        let seed_resp = test::call_service(&app, seed).await;
278        let cookie = Cookie::parse_encoded(
279            seed_resp
280                .headers()
281                .get("set-cookie")
282                .unwrap()
283                .to_str()
284                .unwrap(),
285        )
286        .unwrap();
287
288        let req = test::TestRequest::get()
289            .cookie(cookie)
290            .uri("/protected")
291            .to_request();
292        let resp = test::call_service(&app, req).await;
293        assert_eq!(resp.status(), http::StatusCode::OK);
294    }
295}
296
297#[cfg(test)]
298mod env_tests {
299    use super::{IdleTimeoutMiddleware, DEFAULT_IDLE_TIMEOUT_SECS};
300    use serial_test::serial;
301    use std::env;
302
303    #[test]
304    #[serial]
305    fn test_from_env_uses_default_when_unset() {
306        env::remove_var("SESSION_IDLE_TIMEOUT_SECS");
307        assert_eq!(
308            IdleTimeoutMiddleware::from_env().seconds,
309            DEFAULT_IDLE_TIMEOUT_SECS
310        );
311    }
312
313    #[test]
314    #[serial]
315    fn test_from_env_parses_value() {
316        env::set_var("SESSION_IDLE_TIMEOUT_SECS", "300");
317        assert_eq!(IdleTimeoutMiddleware::from_env().seconds, 300);
318        env::remove_var("SESSION_IDLE_TIMEOUT_SECS");
319    }
320}