Skip to main content

coil_runtime/browser/
host.rs

1use super::flash::{FlashMessage, deserialize_flash_messages, serialize_flash_messages};
2use super::session::{
3    BrowserInstant, BrowserSessionRecord, BrowserSessionStatus, DistributedSessionStoreClient,
4    IssuedBrowserSession, RotatedBrowserSession, SessionIssueRequest, SessionStoreBackend,
5    SessionStoreBackendKind, issue_session,
6};
7use super::support::{
8    FLASH_COOKIE_MAX_AGE_SECS, map_flash_cookie_error, map_session_cookie_error,
9    validate_browser_value,
10};
11use super::*;
12use std::time::Duration;
13use thiserror::Error;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResolvedBrowserRequest {
17    pub session: SessionContext,
18    pub principal_id: Option<String>,
19    pub flash_messages: Vec<FlashMessage>,
20    pub response_cookies: Vec<String>,
21}
22
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum RuntimeBrowserError {
25    #[error("browser value `{field}` must not be empty")]
26    EmptyValue { field: &'static str },
27    #[error("session cookie failed validation: {reason}")]
28    InvalidSessionCookie { reason: String },
29    #[error("flash cookie failed validation: {reason}")]
30    InvalidFlashCookie { reason: String },
31    #[error("session `{session_id}` is not present in the server-side store")]
32    UnknownSession { session_id: String },
33    #[error("session `{session_id}` has expired")]
34    ExpiredSession { session_id: String },
35    #[error("session `{session_id}` has been revoked")]
36    RevokedSession { session_id: String },
37    #[error("flash cookie payload is malformed")]
38    InvalidFlashPayload,
39    #[error("flash cookie contains unknown level `{level}`")]
40    InvalidFlashLevel { level: String },
41    #[error(
42        "live browser session store `{kind:?}` for `{scope}` requires an explicit distributed runtime"
43    )]
44    LiveSharedSessionStoreUnavailable {
45        kind: SessionStoreBackendKind,
46        scope: String,
47    },
48    #[error("live browser session store `{kind:?}` for `{scope}` failed: {reason}")]
49    LiveSharedSessionStoreFailure {
50        kind: SessionStoreBackendKind,
51        scope: String,
52        reason: String,
53    },
54}
55
56#[derive(Debug, Error, Clone, PartialEq, Eq)]
57pub enum BrowserHostBuildError {
58    #[error("memory session stores are test-only and cannot back a live browser host")]
59    MemoryStoreRequiresTestOnlyBrowserHost,
60    #[error("memory session stores cannot use a distributed session client")]
61    MemoryStoreCannotUseDistributedClient,
62    #[error(
63        "live browser session stores require an explicit distributed runtime; `{kind:?}` is not live-supported"
64    )]
65    LiveSharedSessionStoreRequiresExplicitRuntime { kind: SessionStoreBackendKind },
66    #[error(
67        "live browser session store `{kind:?}` for `{scope}` could not be initialized at `{path}`: {reason}"
68    )]
69    LiveSharedSessionStoreInitializationFailed {
70        kind: SessionStoreBackendKind,
71        scope: String,
72        path: String,
73        reason: String,
74    },
75    #[error("session store client kind mismatch: expected `{expected:?}`, got `{actual:?}`")]
76    SessionStoreClientKindMismatch {
77        expected: SessionStoreBackendKind,
78        actual: SessionStoreBackendKind,
79    },
80}
81
82#[derive(Debug, Clone)]
83pub struct BrowserHost {
84    pub customer_app: String,
85    pub services: BrowserSecurityServices,
86    pub(super) session_store_kind: SessionStoreBackendKind,
87    pub(super) sessions: SessionStoreBackend,
88}
89
90impl BrowserHost {
91    #[cfg(test)]
92    pub(crate) fn new_with_scope(
93        customer_app: String,
94        services: BrowserSecurityServices,
95        backend_scope: impl Into<String>,
96    ) -> Result<Self, BrowserHostBuildError> {
97        let backend_scope = backend_scope.into();
98        let (session_store_kind, sessions) =
99            SessionStoreBackend::shared(&customer_app, &services.sessions, &backend_scope)?;
100        Ok(Self {
101            customer_app,
102            services,
103            session_store_kind,
104            sessions,
105        })
106    }
107
108    pub fn with_session_store_client(
109        customer_app: String,
110        services: BrowserSecurityServices,
111        client: DistributedSessionStoreClient,
112    ) -> Result<Self, BrowserHostBuildError> {
113        let (session_store_kind, sessions) =
114            SessionStoreBackend::with_client(&services.sessions, client)?;
115        if !sessions.is_live_shared_state_supported() {
116            return Err(
117                BrowserHostBuildError::LiveSharedSessionStoreRequiresExplicitRuntime {
118                    kind: session_store_kind,
119                },
120            );
121        }
122        Ok(Self {
123            customer_app,
124            services,
125            session_store_kind,
126            sessions,
127        })
128    }
129
130    pub fn session_store_kind(&self) -> SessionStoreBackendKind {
131        self.session_store_kind
132    }
133
134    pub fn session_store_is_shared(&self) -> bool {
135        self.sessions.is_shared()
136    }
137
138    pub fn issue_session(
139        &mut self,
140        request: SessionIssueRequest,
141        cookie_secret: &[u8],
142        now: BrowserInstant,
143    ) -> Result<IssuedBrowserSession, RuntimeBrowserError> {
144        issue_session(self, request, cookie_secret, now)
145    }
146
147    pub fn rotate_session(
148        &mut self,
149        session_id: &str,
150        cookie_secret: &[u8],
151        now: BrowserInstant,
152    ) -> Result<RotatedBrowserSession, RuntimeBrowserError> {
153        let session_id = validate_browser_value("session_id", session_id.to_string())?;
154        let existing = self.sessions.session(&session_id)?.ok_or_else(|| {
155            RuntimeBrowserError::UnknownSession {
156                session_id: session_id.clone(),
157            }
158        })?;
159        let principal_id = match existing.status_at(now) {
160            BrowserSessionStatus::Active => {
161                self.sessions.revoke(&session_id, now)?;
162                existing.principal_id.clone()
163            }
164            BrowserSessionStatus::IdleExpired | BrowserSessionStatus::AbsoluteExpired => {
165                self.sessions.delete(&session_id)?;
166                return Err(RuntimeBrowserError::ExpiredSession { session_id });
167            }
168            BrowserSessionStatus::Revoked => {
169                return Err(RuntimeBrowserError::RevokedSession { session_id });
170            }
171        };
172
173        let issued =
174            self.issue_session(SessionIssueRequest { principal_id }, cookie_secret, now)?;
175        Ok(RotatedBrowserSession {
176            previous_session_id: session_id,
177            issued,
178        })
179    }
180
181    pub fn revoke_session(
182        &mut self,
183        session_id: &str,
184        now: BrowserInstant,
185    ) -> Result<(), RuntimeBrowserError> {
186        let session_id = validate_browser_value("session_id", session_id.to_string())?;
187        self.sessions.revoke(&session_id, now)
188    }
189
190    pub fn issue_csrf_token(
191        &self,
192        csrf_secret: &[u8],
193        session_id: &str,
194        action: &str,
195    ) -> Result<String, RuntimeBrowserError> {
196        let session_id = validate_browser_value("session_id", session_id.to_string())?;
197        let action = validate_browser_value("action", action.to_string())?;
198        self.services
199            .csrf
200            .issue_token(csrf_secret, &session_id, &action)
201            .map_err(map_session_cookie_error)
202    }
203
204    pub fn issue_flash_cookie(
205        &self,
206        cookie_secret: &[u8],
207        messages: &[FlashMessage],
208    ) -> Result<String, RuntimeBrowserError> {
209        if messages.is_empty() {
210            return Err(RuntimeBrowserError::EmptyValue {
211                field: "flash_messages",
212            });
213        }
214
215        let payload = serialize_flash_messages(messages)?;
216        let value = self
217            .services
218            .sessions
219            .flash_cookie
220            .protect(cookie_secret, &payload)
221            .map_err(map_flash_cookie_error)?;
222        Ok(self
223            .services
224            .sessions
225            .flash_cookie
226            .render_set_cookie(&value, Some(Duration::from_secs(FLASH_COOKIE_MAX_AGE_SECS))))
227    }
228
229    pub fn clear_flash_cookie_header(&self) -> String {
230        self.services
231            .sessions
232            .flash_cookie
233            .render_set_cookie("", Some(Duration::from_secs(0)))
234    }
235
236    pub fn clear_session_cookie_header(&self) -> String {
237        self.services
238            .sessions
239            .session_cookie
240            .render_set_cookie("", Some(Duration::from_secs(0)))
241    }
242
243    pub fn session(
244        &self,
245        session_id: &str,
246    ) -> Result<Option<BrowserSessionRecord>, RuntimeBrowserError> {
247        self.sessions.session(session_id)
248    }
249
250    pub fn resolve_request(
251        &mut self,
252        request: &RequestInput,
253        cookie_secret: &[u8],
254        now: BrowserInstant,
255    ) -> Result<ResolvedBrowserRequest, RuntimeBrowserError> {
256        let mut response_cookies = Vec::new();
257        let flash_messages = match request.flash_cookie.as_deref() {
258            Some(cookie) => {
259                let messages = self.consume_flash_cookie(cookie_secret, cookie)?;
260                response_cookies.push(self.clear_flash_cookie_header());
261                messages
262            }
263            None => Vec::new(),
264        };
265
266        let mut resolved_from_cookie = false;
267        let session_id = if let Some(session_id) = request.session_id.as_ref() {
268            Some(validate_browser_value("session_id", session_id.clone())?)
269        } else if let Some(cookie) = request.session_cookie.as_deref() {
270            resolved_from_cookie = true;
271            Some(self.verify_session_cookie(cookie_secret, cookie)?)
272        } else {
273            None
274        };
275
276        let Some(session_id) = session_id else {
277            return Ok(ResolvedBrowserRequest {
278                session: SessionContext {
279                    session_id: None,
280                    resolved_from_cookie,
281                },
282                principal_id: None,
283                flash_messages,
284                response_cookies,
285            });
286        };
287
288        let (session_id, principal_id, refreshed_cookie) =
289            match self.touch_active_session(&session_id, cookie_secret, now) {
290                Ok((principal_id, refreshed_cookie)) => {
291                    (session_id, principal_id, refreshed_cookie)
292                }
293                Err(RuntimeBrowserError::UnknownSession { .. }) if resolved_from_cookie => {
294                    let issued =
295                        self.issue_session(SessionIssueRequest::new(), cookie_secret, now)?;
296                    (
297                        issued.record.session_id.clone(),
298                        issued.record.principal_id.clone(),
299                        issued.set_cookie_header,
300                    )
301                }
302                Err(error) => return Err(error),
303            };
304        response_cookies.push(refreshed_cookie);
305
306        Ok(ResolvedBrowserRequest {
307            session: SessionContext {
308                session_id: Some(session_id),
309                resolved_from_cookie,
310            },
311            principal_id,
312            flash_messages,
313            response_cookies,
314        })
315    }
316
317    pub(super) fn issue_cookie_for_record(
318        &self,
319        record: BrowserSessionRecord,
320        cookie_secret: &[u8],
321    ) -> Result<IssuedBrowserSession, RuntimeBrowserError> {
322        let cookie_value = self
323            .services
324            .sessions
325            .session_cookie
326            .protect(cookie_secret, &record.session_id)
327            .map_err(map_session_cookie_error)?;
328        let set_cookie_header = self
329            .services
330            .sessions
331            .session_cookie
332            .render_set_cookie(&cookie_value, Some(self.services.sessions.idle_timeout));
333        Ok(IssuedBrowserSession {
334            record,
335            cookie_value,
336            set_cookie_header,
337        })
338    }
339
340    fn verify_session_cookie(
341        &self,
342        cookie_secret: &[u8],
343        cookie: &str,
344    ) -> Result<String, RuntimeBrowserError> {
345        self.services
346            .sessions
347            .session_cookie
348            .unprotect(cookie_secret, cookie)
349            .map_err(map_session_cookie_error)
350    }
351
352    fn consume_flash_cookie(
353        &self,
354        cookie_secret: &[u8],
355        cookie: &str,
356    ) -> Result<Vec<FlashMessage>, RuntimeBrowserError> {
357        let payload = self
358            .services
359            .sessions
360            .flash_cookie
361            .unprotect(cookie_secret, cookie)
362            .map_err(map_flash_cookie_error)?;
363        deserialize_flash_messages(&payload)
364    }
365
366    fn touch_active_session(
367        &mut self,
368        session_id: &str,
369        cookie_secret: &[u8],
370        now: BrowserInstant,
371    ) -> Result<(Option<String>, String), RuntimeBrowserError> {
372        let principal_id = self.sessions.touch_active_session(
373            session_id,
374            self.services.sessions.idle_timeout,
375            now,
376        )?;
377        let cookie_value = self
378            .services
379            .sessions
380            .session_cookie
381            .protect(cookie_secret, session_id)
382            .map_err(map_session_cookie_error)?;
383        let cookie_header = self
384            .services
385            .sessions
386            .session_cookie
387            .render_set_cookie(&cookie_value, Some(self.services.sessions.idle_timeout));
388        Ok((principal_id, cookie_header))
389    }
390}