Skip to main content

tako_rs_plugins/middleware/session/
layer.rs

1//! The [`SessionMiddleware`] builder and its request-time enforcement.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::Duration;
7use std::time::Instant;
8
9use http::HeaderValue;
10use tako_rs_core::middleware::IntoMiddleware;
11use tako_rs_core::middleware::Next;
12use tako_rs_core::types::Request;
13use tako_rs_core::types::Response;
14
15use super::cookie::SameSite;
16use super::cookie::build_cookie;
17use super::cookie::build_expired_cookie;
18use super::cookie::extract_cookie_value;
19use super::cookie::generate_session_id;
20use super::data::Session;
21use super::store::SessionEntry;
22use super::store::SessionStoreHandle;
23use super::store::SessionTtl;
24use super::store::Store;
25
26/// Builder / configuration.
27pub struct SessionMiddleware {
28  cookie_name: String,
29  ttl: SessionTtl,
30  path: String,
31  domain: Option<String>,
32  secure: bool,
33  http_only: bool,
34  same_site: SameSite,
35  store: Store,
36}
37
38impl Default for SessionMiddleware {
39  fn default() -> Self {
40    Self::new()
41  }
42}
43
44impl SessionMiddleware {
45  /// Creates a new session middleware with sensible defaults.
46  pub fn new() -> Self {
47    Self {
48      cookie_name: "tako_session".to_string(),
49      ttl: SessionTtl::default(),
50      path: "/".to_string(),
51      domain: None,
52      secure: false,
53      http_only: true,
54      same_site: SameSite::Lax,
55      store: Store::new(),
56    }
57  }
58
59  /// Cookie name (default `"tako_session"`).
60  pub fn cookie_name(mut self, name: &str) -> Self {
61    self.cookie_name = name.to_string();
62    self
63  }
64
65  /// Backwards-compatible idle TTL setter (sets `idle_secs`, leaves
66  /// `absolute_secs` at the default 24 h cap).
67  pub fn ttl_secs(mut self, secs: u64) -> Self {
68    self.ttl.idle_secs = secs;
69    self
70  }
71
72  /// Sets the full TTL policy.
73  pub fn ttl(mut self, ttl: SessionTtl) -> Self {
74    self.ttl = ttl;
75    self
76  }
77
78  /// Cookie path (default `"/"`).
79  pub fn path(mut self, path: &str) -> Self {
80    self.path = path.to_string();
81    self
82  }
83
84  /// Optional cookie `Domain` attribute.
85  pub fn domain(mut self, domain: &str) -> Self {
86    self.domain = Some(domain.to_string());
87    self
88  }
89
90  /// Toggles the `Secure` flag.
91  pub fn secure(mut self, secure: bool) -> Self {
92    self.secure = secure;
93    self
94  }
95
96  /// Toggles the `HttpOnly` flag (default true).
97  pub fn http_only(mut self, on: bool) -> Self {
98    self.http_only = on;
99    self
100  }
101
102  /// Sets the `SameSite` attribute. Default: `Lax`. Note that `None` requires
103  /// `Secure = true` per all major browsers.
104  pub fn same_site(mut self, ss: SameSite) -> Self {
105    self.same_site = ss;
106    self
107  }
108
109  /// Returns a programmatic handle for revocation flows.
110  pub fn handle(&self) -> SessionStoreHandle {
111    SessionStoreHandle {
112      store: self.store.clone(),
113    }
114  }
115}
116
117impl IntoMiddleware for SessionMiddleware {
118  fn into_middleware(
119    self,
120  ) -> impl Fn(Request, Next) -> Pin<Box<dyn Future<Output = Response> + Send + 'static>>
121  + Clone
122  + Send
123  + Sync
124  + 'static {
125    let store = self.store.clone();
126    let cookie_name = Arc::new(self.cookie_name);
127    let ttl = self.ttl;
128    let path = Arc::new(self.path);
129    let domain = self.domain.map(Arc::new);
130    let secure = self.secure;
131    let http_only = self.http_only;
132    let same_site = self.same_site;
133
134    // Periodic janitor — expiry is enforced lazily on read, but a sweep keeps
135    // RAM bounded for sessions that are never touched again.
136    {
137      let store = store.clone();
138      let interval = Duration::from_secs(ttl.idle_secs.clamp(60, 3_600));
139      #[cfg(not(feature = "compio"))]
140      tokio::spawn(async move {
141        let mut tick = tokio::time::interval(interval);
142        loop {
143          tick.tick().await;
144          store.retain_expired(ttl);
145        }
146      });
147      #[cfg(feature = "compio")]
148      compio::runtime::spawn(async move {
149        loop {
150          compio::time::sleep(interval).await;
151          store.retain_expired(ttl);
152        }
153      })
154      .detach();
155    }
156
157    move |mut req: Request, next: Next| {
158      let store = store.clone();
159      let cookie_name = cookie_name.clone();
160      let path = path.clone();
161      let domain = domain.clone();
162
163      Box::pin(async move {
164        let now = Instant::now();
165        let idle = Duration::from_secs(ttl.idle_secs);
166        let absolute = ttl.absolute_secs.map(Duration::from_secs);
167
168        let inbound_id = extract_cookie_value(&req, &cookie_name).map(str::to_string);
169        let (sid, data, created_at, was_existing) = match inbound_id {
170          Some(ref id) => match store.get(id) {
171            Some(entry)
172              if now.duration_since(entry.last_seen_at) <= idle
173                && absolute.is_none_or(|abs| now.duration_since(entry.created_at) <= abs) =>
174            {
175              (id.clone(), entry.data, entry.created_at, true)
176            }
177            _ => {
178              if let Some(id) = inbound_id.as_ref() {
179                store.remove(id);
180              }
181              (generate_session_id(), serde_json::Map::new(), now, false)
182            }
183          },
184          None => (generate_session_id(), serde_json::Map::new(), now, false),
185        };
186
187        let session = Session::new(data);
188        req.extensions_mut().insert(session.clone());
189
190        let resp_outcome = next.run(req).await;
191        let mut resp = resp_outcome;
192
193        let dirty = session.is_dirty();
194        let rotated = session.rotation_requested();
195        let destroyed = session.is_destroyed();
196
197        // Destruction (logout) takes precedence over rotation/refresh: drop
198        // the server entry and emit a Set-Cookie that the UA will treat as
199        // an immediate delete.
200        if destroyed {
201          if was_existing {
202            store.remove(&sid);
203          }
204          let expired = build_expired_cookie(
205            &cookie_name,
206            &path,
207            domain.as_deref().map(String::as_str),
208            secure,
209            http_only,
210            same_site,
211          );
212          if let Ok(v) = HeaderValue::from_str(&expired) {
213            resp.headers_mut().append(http::header::SET_COOKIE, v);
214          }
215          let _ = dirty;
216          return resp;
217        }
218
219        // Effective session id: rotate if requested.
220        let effective_sid = if rotated {
221          if was_existing {
222            store.remove(&sid);
223          }
224          generate_session_id()
225        } else {
226          sid
227        };
228
229        // Always touch on every request — rolling refresh keeps the cookie
230        // alive while the user is active. Caller-side logout uses
231        // `Session::destroy` which short-circuits this path.
232        let updated_entry = SessionEntry {
233          data: session.snapshot(),
234          created_at,
235          last_seen_at: now,
236        };
237        store.upsert(effective_sid.clone(), updated_entry);
238
239        // Re-emit the cookie on every response. Browsers ignore identical
240        // `Set-Cookie` headers cheaply; the upside is that long-lived UAs
241        // see the refreshed `Max-Age`.
242        let max_age = match absolute {
243          Some(abs) => {
244            let elapsed = now.duration_since(created_at);
245            let absolute_remaining = abs.saturating_sub(elapsed);
246            absolute_remaining.as_secs().min(idle.as_secs())
247          }
248          None => idle.as_secs(),
249        };
250
251        let cookie_value = build_cookie(
252          &cookie_name,
253          &effective_sid,
254          &path,
255          domain.as_deref().map(String::as_str),
256          max_age,
257          secure,
258          http_only,
259          same_site,
260        );
261        if let Ok(v) = HeaderValue::from_str(&cookie_value) {
262          resp.headers_mut().append(http::header::SET_COOKIE, v);
263        }
264
265        let _ = dirty;
266
267        resp
268      })
269    }
270  }
271}