1use super::support::{issue_session_id, validate_browser_value};
2use super::*;
3use std::sync::Arc;
4use std::time::Duration;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum SessionStoreBackendKind {
8 Local,
9 Database,
10 Redis,
11 Valkey,
12}
13
14fn session_store_backend_kind(
15 store: coil_core::SessionStoreTopology,
16) -> SessionStoreBackendKind {
17 match store {
18 coil_core::SessionStoreTopology::Memory => SessionStoreBackendKind::Local,
19 coil_core::SessionStoreTopology::Database => SessionStoreBackendKind::Database,
20 coil_core::SessionStoreTopology::Redis => SessionStoreBackendKind::Redis,
21 coil_core::SessionStoreTopology::Valkey => SessionStoreBackendKind::Valkey,
22 }
23}
24
25#[derive(
26 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
27)]
28pub struct BrowserInstant(u64);
29
30impl BrowserInstant {
31 pub const fn from_unix_seconds(seconds: u64) -> Self {
32 Self(seconds)
33 }
34
35 pub const fn as_unix_seconds(self) -> u64 {
36 self.0
37 }
38
39 pub fn saturating_add(self, duration: Duration) -> Self {
40 Self(self.0.saturating_add(duration.as_secs()))
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct SessionIssueRequest {
46 pub principal_id: Option<String>,
47}
48
49impl SessionIssueRequest {
50 pub const fn new() -> Self {
51 Self { principal_id: None }
52 }
53
54 pub fn for_principal(
55 mut self,
56 principal_id: impl Into<String>,
57 ) -> Result<Self, RuntimeBrowserError> {
58 self.principal_id = Some(validate_browser_value("principal_id", principal_id.into())?);
59 Ok(self)
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum BrowserSessionStatus {
65 Active,
66 IdleExpired,
67 AbsoluteExpired,
68 Revoked,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct BrowserSessionRecord {
73 pub session_id: String,
74 pub principal_id: Option<String>,
75 pub issued_at: BrowserInstant,
76 pub last_seen_at: BrowserInstant,
77 pub idle_expires_at: BrowserInstant,
78 pub absolute_expires_at: BrowserInstant,
79 pub revoked_at: Option<BrowserInstant>,
80}
81
82impl BrowserSessionRecord {
83 pub fn status_at(&self, now: BrowserInstant) -> BrowserSessionStatus {
84 if self.revoked_at.is_some() {
85 BrowserSessionStatus::Revoked
86 } else if now.as_unix_seconds() > self.absolute_expires_at.as_unix_seconds() {
87 BrowserSessionStatus::AbsoluteExpired
88 } else if now.as_unix_seconds() > self.idle_expires_at.as_unix_seconds() {
89 BrowserSessionStatus::IdleExpired
90 } else {
91 BrowserSessionStatus::Active
92 }
93 }
94}
95
96pub trait DistributedSessionStoreRuntime: Send + Sync + 'static {
97 fn issue(&self, record: BrowserSessionRecord) -> Result<(), RuntimeBrowserError>;
98 fn session(
99 &self,
100 session_id: &str,
101 ) -> Result<Option<BrowserSessionRecord>, RuntimeBrowserError>;
102 fn delete(&self, session_id: &str) -> Result<(), RuntimeBrowserError>;
103 fn revoke(&self, session_id: &str, now: BrowserInstant) -> Result<(), RuntimeBrowserError>;
104 fn touch_active_session(
105 &self,
106 session_id: &str,
107 idle_timeout: Duration,
108 now: BrowserInstant,
109 ) -> Result<Option<String>, RuntimeBrowserError>;
110 fn is_shared_backend(&self) -> bool;
111 fn supports_live_shared_state(&self) -> bool {
112 false
113 }
114}
115
116#[derive(Clone)]
117pub struct DistributedSessionStoreClient {
118 kind: SessionStoreBackendKind,
119 runtime: Arc<dyn DistributedSessionStoreRuntime>,
120}
121
122impl DistributedSessionStoreClient {
123 pub fn new(
124 kind: SessionStoreBackendKind,
125 runtime: Arc<dyn DistributedSessionStoreRuntime>,
126 ) -> Self {
127 Self { kind, runtime }
128 }
129
130 #[cfg(test)]
131 pub(crate) fn test_only_sqlite_shared_runtime(
132 kind: SessionStoreBackendKind,
133 scope: impl Into<String>,
134 ) -> Arc<dyn DistributedSessionStoreRuntime> {
135 super::testing::test_only_sqlite_shared_runtime(kind, scope.into())
136 }
137
138 pub fn kind(&self) -> SessionStoreBackendKind {
139 self.kind
140 }
141
142 pub fn is_shared(&self) -> bool {
143 self.runtime.is_shared_backend()
144 }
145
146 pub fn supports_live_shared_state(&self) -> bool {
147 self.runtime.supports_live_shared_state()
148 }
149
150 pub(super) fn issue(&self, record: BrowserSessionRecord) -> Result<(), RuntimeBrowserError> {
151 self.runtime.issue(record)
152 }
153
154 pub(super) fn session(
155 &self,
156 session_id: &str,
157 ) -> Result<Option<BrowserSessionRecord>, RuntimeBrowserError> {
158 self.runtime.session(session_id)
159 }
160
161 pub(super) fn delete(&self, session_id: &str) -> Result<(), RuntimeBrowserError> {
162 self.runtime.delete(session_id)
163 }
164
165 pub(super) fn revoke(
166 &self,
167 session_id: &str,
168 now: BrowserInstant,
169 ) -> Result<(), RuntimeBrowserError> {
170 self.runtime.revoke(session_id, now)
171 }
172
173 pub(super) fn touch_active_session(
174 &self,
175 session_id: &str,
176 idle_timeout: Duration,
177 now: BrowserInstant,
178 ) -> Result<Option<String>, RuntimeBrowserError> {
179 self.runtime
180 .touch_active_session(session_id, idle_timeout, now)
181 }
182}
183
184impl std::fmt::Debug for DistributedSessionStoreClient {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.debug_struct("DistributedSessionStoreClient")
187 .field("kind", &self.kind)
188 .finish()
189 }
190}
191
192#[derive(Debug, Clone)]
193pub(super) enum SessionStoreBackend {
194 #[cfg(test)]
195 Local(testing::SessionStoreState),
196 Distributed(DistributedSessionStoreClient),
197}
198
199impl SessionStoreBackend {
200 #[cfg(test)]
201 pub(super) fn shared(
202 customer_app: &str,
203 services: &coil_core::SessionSecurityServices,
204 backend_scope: &str,
205 ) -> Result<(SessionStoreBackendKind, Self), BrowserHostBuildError> {
206 match services.store {
207 coil_core::SessionStoreTopology::Memory => {
208 Err(BrowserHostBuildError::MemoryStoreRequiresTestOnlyBrowserHost)
209 }
210 coil_core::SessionStoreTopology::Database => Ok((
211 SessionStoreBackendKind::Database,
212 Self::Distributed(DistributedSessionStoreClient::new(
213 SessionStoreBackendKind::Database,
214 DistributedSessionStoreClient::test_only_sqlite_shared_runtime(
215 SessionStoreBackendKind::Database,
216 format!("{backend_scope}:{customer_app}"),
217 ),
218 )),
219 )),
220 coil_core::SessionStoreTopology::Redis => Ok((
221 SessionStoreBackendKind::Redis,
222 Self::Distributed(DistributedSessionStoreClient::new(
223 SessionStoreBackendKind::Redis,
224 DistributedSessionStoreClient::test_only_sqlite_shared_runtime(
225 SessionStoreBackendKind::Redis,
226 format!("{backend_scope}:{customer_app}"),
227 ),
228 )),
229 )),
230 coil_core::SessionStoreTopology::Valkey => Ok((
231 SessionStoreBackendKind::Valkey,
232 Self::Distributed(DistributedSessionStoreClient::new(
233 SessionStoreBackendKind::Valkey,
234 DistributedSessionStoreClient::test_only_sqlite_shared_runtime(
235 SessionStoreBackendKind::Valkey,
236 format!("{backend_scope}:{customer_app}"),
237 ),
238 )),
239 )),
240 }
241 }
242
243 pub(super) fn with_client(
244 services: &coil_core::SessionSecurityServices,
245 client: DistributedSessionStoreClient,
246 ) -> Result<(SessionStoreBackendKind, Self), BrowserHostBuildError> {
247 let expected = session_store_backend_kind(services.store);
248 if expected == SessionStoreBackendKind::Local {
249 return Err(BrowserHostBuildError::MemoryStoreCannotUseDistributedClient);
250 }
251
252 if client.kind() != expected {
253 return Err(BrowserHostBuildError::SessionStoreClientKindMismatch {
254 expected,
255 actual: client.kind(),
256 });
257 }
258
259 Ok((expected, Self::Distributed(client)))
260 }
261
262 pub(super) fn is_shared(&self) -> bool {
263 match self {
264 #[cfg(test)]
265 Self::Local(_) => false,
266 Self::Distributed(client) => client.is_shared(),
267 }
268 }
269
270 pub(super) fn is_live_shared_state_supported(&self) -> bool {
271 match self {
272 #[cfg(test)]
273 Self::Local(_) => false,
274 Self::Distributed(client) => client.supports_live_shared_state(),
275 }
276 }
277
278 pub(super) fn issue(
279 &mut self,
280 record: BrowserSessionRecord,
281 ) -> Result<(), RuntimeBrowserError> {
282 match self {
283 #[cfg(test)]
284 Self::Local(state) => {
285 state.issue(record);
286 Ok(())
287 }
288 Self::Distributed(client) => client.issue(record),
289 }
290 }
291
292 pub(super) fn session(
293 &self,
294 session_id: &str,
295 ) -> Result<Option<BrowserSessionRecord>, RuntimeBrowserError> {
296 match self {
297 #[cfg(test)]
298 Self::Local(state) => Ok(state.session(session_id)),
299 Self::Distributed(client) => client.session(session_id),
300 }
301 }
302
303 pub(super) fn delete(&mut self, session_id: &str) -> Result<(), RuntimeBrowserError> {
304 match self {
305 #[cfg(test)]
306 Self::Local(state) => {
307 state.sessions.remove(session_id);
308 Ok(())
309 }
310 Self::Distributed(client) => client.delete(session_id),
311 }
312 }
313
314 pub(super) fn revoke(
315 &mut self,
316 session_id: &str,
317 now: BrowserInstant,
318 ) -> Result<(), RuntimeBrowserError> {
319 match self {
320 #[cfg(test)]
321 Self::Local(state) => state.revoke(session_id, now),
322 Self::Distributed(client) => client.revoke(session_id, now),
323 }
324 }
325
326 pub(super) fn touch_active_session(
327 &mut self,
328 session_id: &str,
329 idle_timeout: Duration,
330 now: BrowserInstant,
331 ) -> Result<Option<String>, RuntimeBrowserError> {
332 match self {
333 #[cfg(test)]
334 Self::Local(state) => state.touch_active_session(session_id, idle_timeout, now),
335 Self::Distributed(client) => client.touch_active_session(session_id, idle_timeout, now),
336 }
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct IssuedBrowserSession {
342 pub record: BrowserSessionRecord,
343 pub cookie_value: String,
344 pub set_cookie_header: String,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct RotatedBrowserSession {
349 pub previous_session_id: String,
350 pub issued: IssuedBrowserSession,
351}
352
353pub(super) fn issue_session(
354 host: &mut BrowserHost,
355 request: SessionIssueRequest,
356 cookie_secret: &[u8],
357 now: BrowserInstant,
358) -> Result<IssuedBrowserSession, RuntimeBrowserError> {
359 let session_id = issue_session_id();
360 let record = BrowserSessionRecord {
361 session_id: session_id.clone(),
362 principal_id: request.principal_id,
363 issued_at: now,
364 last_seen_at: now,
365 idle_expires_at: now.saturating_add(host.services.sessions.idle_timeout),
366 absolute_expires_at: now.saturating_add(host.services.sessions.absolute_timeout),
367 revoked_at: None,
368 };
369 let issued = host.issue_cookie_for_record(record.clone(), cookie_secret)?;
370 host.sessions.issue(record)?;
371 Ok(issued)
372}