coil-runtime 0.1.1

HTTP runtime and request handling for the Coil framework.
Documentation
use super::flash::{FlashMessage, deserialize_flash_messages, serialize_flash_messages};
use super::session::{
    BrowserInstant, BrowserSessionRecord, BrowserSessionStatus, DistributedSessionStoreClient,
    IssuedBrowserSession, RotatedBrowserSession, SessionIssueRequest, SessionStoreBackend,
    SessionStoreBackendKind, issue_session,
};
use super::support::{
    FLASH_COOKIE_MAX_AGE_SECS, map_flash_cookie_error, map_session_cookie_error,
    validate_browser_value,
};
use super::*;
use std::time::Duration;
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedBrowserRequest {
    pub session: SessionContext,
    pub principal_id: Option<String>,
    pub flash_messages: Vec<FlashMessage>,
    pub response_cookies: Vec<String>,
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum RuntimeBrowserError {
    #[error("browser value `{field}` must not be empty")]
    EmptyValue { field: &'static str },
    #[error("session cookie failed validation: {reason}")]
    InvalidSessionCookie { reason: String },
    #[error("flash cookie failed validation: {reason}")]
    InvalidFlashCookie { reason: String },
    #[error("session `{session_id}` is not present in the server-side store")]
    UnknownSession { session_id: String },
    #[error("session `{session_id}` has expired")]
    ExpiredSession { session_id: String },
    #[error("session `{session_id}` has been revoked")]
    RevokedSession { session_id: String },
    #[error("flash cookie payload is malformed")]
    InvalidFlashPayload,
    #[error("flash cookie contains unknown level `{level}`")]
    InvalidFlashLevel { level: String },
    #[error(
        "live browser session store `{kind:?}` for `{scope}` requires an explicit distributed runtime"
    )]
    LiveSharedSessionStoreUnavailable {
        kind: SessionStoreBackendKind,
        scope: String,
    },
    #[error("live browser session store `{kind:?}` for `{scope}` failed: {reason}")]
    LiveSharedSessionStoreFailure {
        kind: SessionStoreBackendKind,
        scope: String,
        reason: String,
    },
}

#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum BrowserHostBuildError {
    #[error("memory session stores are test-only and cannot back a live browser host")]
    MemoryStoreRequiresTestOnlyBrowserHost,
    #[error("memory session stores cannot use a distributed session client")]
    MemoryStoreCannotUseDistributedClient,
    #[error(
        "live browser session stores require an explicit distributed runtime; `{kind:?}` is not live-supported"
    )]
    LiveSharedSessionStoreRequiresExplicitRuntime { kind: SessionStoreBackendKind },
    #[error(
        "live browser session store `{kind:?}` for `{scope}` could not be initialized at `{path}`: {reason}"
    )]
    LiveSharedSessionStoreInitializationFailed {
        kind: SessionStoreBackendKind,
        scope: String,
        path: String,
        reason: String,
    },
    #[error("session store client kind mismatch: expected `{expected:?}`, got `{actual:?}`")]
    SessionStoreClientKindMismatch {
        expected: SessionStoreBackendKind,
        actual: SessionStoreBackendKind,
    },
}

#[derive(Debug, Clone)]
pub struct BrowserHost {
    pub customer_app: String,
    pub services: BrowserSecurityServices,
    pub(super) session_store_kind: SessionStoreBackendKind,
    pub(super) sessions: SessionStoreBackend,
}

impl BrowserHost {
    #[cfg(test)]
    pub(crate) fn new_with_scope(
        customer_app: String,
        services: BrowserSecurityServices,
        backend_scope: impl Into<String>,
    ) -> Result<Self, BrowserHostBuildError> {
        let backend_scope = backend_scope.into();
        let (session_store_kind, sessions) =
            SessionStoreBackend::shared(&customer_app, &services.sessions, &backend_scope)?;
        Ok(Self {
            customer_app,
            services,
            session_store_kind,
            sessions,
        })
    }

    pub fn with_session_store_client(
        customer_app: String,
        services: BrowserSecurityServices,
        client: DistributedSessionStoreClient,
    ) -> Result<Self, BrowserHostBuildError> {
        let (session_store_kind, sessions) =
            SessionStoreBackend::with_client(&services.sessions, client)?;
        if !sessions.is_live_shared_state_supported() {
            return Err(
                BrowserHostBuildError::LiveSharedSessionStoreRequiresExplicitRuntime {
                    kind: session_store_kind,
                },
            );
        }
        Ok(Self {
            customer_app,
            services,
            session_store_kind,
            sessions,
        })
    }

    pub fn session_store_kind(&self) -> SessionStoreBackendKind {
        self.session_store_kind
    }

    pub fn session_store_is_shared(&self) -> bool {
        self.sessions.is_shared()
    }

    pub fn issue_session(
        &mut self,
        request: SessionIssueRequest,
        cookie_secret: &[u8],
        now: BrowserInstant,
    ) -> Result<IssuedBrowserSession, RuntimeBrowserError> {
        issue_session(self, request, cookie_secret, now)
    }

    pub fn rotate_session(
        &mut self,
        session_id: &str,
        cookie_secret: &[u8],
        now: BrowserInstant,
    ) -> Result<RotatedBrowserSession, RuntimeBrowserError> {
        let session_id = validate_browser_value("session_id", session_id.to_string())?;
        let existing = self.sessions.session(&session_id)?.ok_or_else(|| {
            RuntimeBrowserError::UnknownSession {
                session_id: session_id.clone(),
            }
        })?;
        let principal_id = match existing.status_at(now) {
            BrowserSessionStatus::Active => {
                self.sessions.revoke(&session_id, now)?;
                existing.principal_id.clone()
            }
            BrowserSessionStatus::IdleExpired | BrowserSessionStatus::AbsoluteExpired => {
                self.sessions.delete(&session_id)?;
                return Err(RuntimeBrowserError::ExpiredSession { session_id });
            }
            BrowserSessionStatus::Revoked => {
                return Err(RuntimeBrowserError::RevokedSession { session_id });
            }
        };

        let issued =
            self.issue_session(SessionIssueRequest { principal_id }, cookie_secret, now)?;
        Ok(RotatedBrowserSession {
            previous_session_id: session_id,
            issued,
        })
    }

    pub fn revoke_session(
        &mut self,
        session_id: &str,
        now: BrowserInstant,
    ) -> Result<(), RuntimeBrowserError> {
        let session_id = validate_browser_value("session_id", session_id.to_string())?;
        self.sessions.revoke(&session_id, now)
    }

    pub fn issue_csrf_token(
        &self,
        csrf_secret: &[u8],
        session_id: &str,
        action: &str,
    ) -> Result<String, RuntimeBrowserError> {
        let session_id = validate_browser_value("session_id", session_id.to_string())?;
        let action = validate_browser_value("action", action.to_string())?;
        self.services
            .csrf
            .issue_token(csrf_secret, &session_id, &action)
            .map_err(map_session_cookie_error)
    }

    pub fn issue_flash_cookie(
        &self,
        cookie_secret: &[u8],
        messages: &[FlashMessage],
    ) -> Result<String, RuntimeBrowserError> {
        if messages.is_empty() {
            return Err(RuntimeBrowserError::EmptyValue {
                field: "flash_messages",
            });
        }

        let payload = serialize_flash_messages(messages)?;
        let value = self
            .services
            .sessions
            .flash_cookie
            .protect(cookie_secret, &payload)
            .map_err(map_flash_cookie_error)?;
        Ok(self
            .services
            .sessions
            .flash_cookie
            .render_set_cookie(&value, Some(Duration::from_secs(FLASH_COOKIE_MAX_AGE_SECS))))
    }

    pub fn clear_flash_cookie_header(&self) -> String {
        self.services
            .sessions
            .flash_cookie
            .render_set_cookie("", Some(Duration::from_secs(0)))
    }

    pub fn clear_session_cookie_header(&self) -> String {
        self.services
            .sessions
            .session_cookie
            .render_set_cookie("", Some(Duration::from_secs(0)))
    }

    pub fn session(
        &self,
        session_id: &str,
    ) -> Result<Option<BrowserSessionRecord>, RuntimeBrowserError> {
        self.sessions.session(session_id)
    }

    pub fn resolve_request(
        &mut self,
        request: &RequestInput,
        cookie_secret: &[u8],
        now: BrowserInstant,
    ) -> Result<ResolvedBrowserRequest, RuntimeBrowserError> {
        let mut response_cookies = Vec::new();
        let flash_messages = match request.flash_cookie.as_deref() {
            Some(cookie) => {
                let messages = self.consume_flash_cookie(cookie_secret, cookie)?;
                response_cookies.push(self.clear_flash_cookie_header());
                messages
            }
            None => Vec::new(),
        };

        let mut resolved_from_cookie = false;
        let session_id = if let Some(session_id) = request.session_id.as_ref() {
            Some(validate_browser_value("session_id", session_id.clone())?)
        } else if let Some(cookie) = request.session_cookie.as_deref() {
            resolved_from_cookie = true;
            Some(self.verify_session_cookie(cookie_secret, cookie)?)
        } else {
            None
        };

        let Some(session_id) = session_id else {
            return Ok(ResolvedBrowserRequest {
                session: SessionContext {
                    session_id: None,
                    resolved_from_cookie,
                },
                principal_id: None,
                flash_messages,
                response_cookies,
            });
        };

        let (session_id, principal_id, refreshed_cookie) =
            match self.touch_active_session(&session_id, cookie_secret, now) {
                Ok((principal_id, refreshed_cookie)) => {
                    (session_id, principal_id, refreshed_cookie)
                }
                Err(RuntimeBrowserError::UnknownSession { .. }) if resolved_from_cookie => {
                    let issued =
                        self.issue_session(SessionIssueRequest::new(), cookie_secret, now)?;
                    (
                        issued.record.session_id.clone(),
                        issued.record.principal_id.clone(),
                        issued.set_cookie_header,
                    )
                }
                Err(error) => return Err(error),
            };
        response_cookies.push(refreshed_cookie);

        Ok(ResolvedBrowserRequest {
            session: SessionContext {
                session_id: Some(session_id),
                resolved_from_cookie,
            },
            principal_id,
            flash_messages,
            response_cookies,
        })
    }

    pub(super) fn issue_cookie_for_record(
        &self,
        record: BrowserSessionRecord,
        cookie_secret: &[u8],
    ) -> Result<IssuedBrowserSession, RuntimeBrowserError> {
        let cookie_value = self
            .services
            .sessions
            .session_cookie
            .protect(cookie_secret, &record.session_id)
            .map_err(map_session_cookie_error)?;
        let set_cookie_header = self
            .services
            .sessions
            .session_cookie
            .render_set_cookie(&cookie_value, Some(self.services.sessions.idle_timeout));
        Ok(IssuedBrowserSession {
            record,
            cookie_value,
            set_cookie_header,
        })
    }

    fn verify_session_cookie(
        &self,
        cookie_secret: &[u8],
        cookie: &str,
    ) -> Result<String, RuntimeBrowserError> {
        self.services
            .sessions
            .session_cookie
            .unprotect(cookie_secret, cookie)
            .map_err(map_session_cookie_error)
    }

    fn consume_flash_cookie(
        &self,
        cookie_secret: &[u8],
        cookie: &str,
    ) -> Result<Vec<FlashMessage>, RuntimeBrowserError> {
        let payload = self
            .services
            .sessions
            .flash_cookie
            .unprotect(cookie_secret, cookie)
            .map_err(map_flash_cookie_error)?;
        deserialize_flash_messages(&payload)
    }

    fn touch_active_session(
        &mut self,
        session_id: &str,
        cookie_secret: &[u8],
        now: BrowserInstant,
    ) -> Result<(Option<String>, String), RuntimeBrowserError> {
        let principal_id = self.sessions.touch_active_session(
            session_id,
            self.services.sessions.idle_timeout,
            now,
        )?;
        let cookie_value = self
            .services
            .sessions
            .session_cookie
            .protect(cookie_secret, session_id)
            .map_err(map_session_cookie_error)?;
        let cookie_header = self
            .services
            .sessions
            .session_cookie
            .render_set_cookie(&cookie_value, Some(self.services.sessions.idle_timeout));
        Ok((principal_id, cookie_header))
    }
}