Skip to main content

coil_runtime/browser/
session.rs

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}