polyc-web-session 2026.8.3

Browser session establishment (ADR 0007): one-time login challenges, the shared session cookie, and both the persona-passkey and wallet-passkey ceremonies that mint a polyc_crypto::session token. Consumed by polyc-control-plane; carries no ceremony-consumer naming of its own.
//! The one browser session cookie both login ceremonies mint and read —
//! deliberately framework-free (plain strings in, plain strings out) so this
//! crate carries no HTTP-server dependency at all; `polyc-control-plane`'s
//! own thin `axum` handlers extract/set the actual header.

/// Name of the session cookie.
///
/// Not `pc_explorer_session` — carries no consumer naming, matching this
/// crate's own charter (see the crate doc): the SAME cookie is set by either
/// ceremony and read by every read-authz gate downstream, none of which is
/// specific to one ceremony.
pub const SESSION_COOKIE_NAME: &str = "pc_web_session";

/// Build the `Set-Cookie` header value that logs a browser in.
///
/// The session `token`, `HttpOnly` (never readable from page script),
/// `Secure` (never sent over plain HTTP), `SameSite=Lax`, scoped to `domain`
/// (the login page's registrable host — the same field both ceremonies'
/// `WebAuthn` verification, when they have one, is scoped to), and
/// `Max-Age` of [`crate::SESSION_TTL_MS`] in seconds (matching the signed
/// token's own expiry, so the browser never holds a cookie the server has
/// already stopped honoring, or vice versa).
#[must_use]
pub fn set_session_cookie_header(token: &str, domain: &str) -> String {
    let max_age_secs = crate::SESSION_TTL_MS / 1_000;
    format!(
        "{SESSION_COOKIE_NAME}={token}; HttpOnly; Secure; SameSite=Lax; Path=/; \
         Domain={domain}; Max-Age={max_age_secs}"
    )
}

/// Build the `Set-Cookie` header value that logs a browser out.
///
/// An empty value and `Max-Age=0`, which every browser treats as "delete
/// this cookie immediately" — same attributes as
/// [`set_session_cookie_header`] otherwise, since a `Set-Cookie` that does
/// not repeat `Path`/`Domain` would target a different cookie than the one
/// login set.
#[must_use]
pub fn clear_session_cookie_header(domain: &str) -> String {
    format!(
        "{SESSION_COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Domain={domain}; \
         Max-Age=0"
    )
}

/// Read [`SESSION_COOKIE_NAME`]'s value out of a request's raw `Cookie:`
/// header value.
///
/// The header is e.g. `axum`'s `headers.get(header::COOKIE)`, already
/// decoded to a `&str`. `None` on no matching cookie name among however many
/// the browser sent — every caller treats `None` the same as an invalid
/// session (401), so this never needs to distinguish "no cookie" from
/// "malformed cookie header" for the caller.
#[must_use]
pub fn extract_session_cookie(raw_cookie_header: &str) -> Option<String> {
    raw_cookie_header.split(';').map(str::trim).find_map(|kv| {
        let (name, value) = kv.split_once('=')?;
        (name == SESSION_COOKIE_NAME).then(|| value.to_owned())
    })
}

/// Name of the session-family rotation-grant cookie
/// (`docs/reference/web-session-lifetime.md`, "Refreshing").
///
/// Carries a derivable, signed `(family_id, generation)` statement
/// (`polyc_crypto::session_grant`) — never a session itself, and never read
/// by page script (`HttpOnly`, same as [`SESSION_COOKIE_NAME`]). Rides a
/// SEPARATE `Set-Cookie` from the session token on every ceremony/refresh
/// reply that mints one.
pub const GRANT_COOKIE_NAME: &str = "pc_web_grant";

/// Build the `Set-Cookie` header value carrying a session family's rotation
/// grant, alongside [`set_session_cookie_header`]'s own cookie.
///
/// Unlike the session cookie's `Max-Age` (a fixed [`crate::SESSION_TTL_MS`]),
/// this one tracks the FAMILY's absolute deadline, not the embedded token's
/// — the design record's "two deadlines" split
/// (`docs/reference/web-session-lifetime.md`, "Refreshing"): the grant cookie
/// must outlive any one token, so a refresh can still read it back after the
/// token itself has long expired. `family_expires_ms` at or before `now_ms`
/// floors to `Max-Age=0` (deletes immediately) rather than underflowing.
#[must_use]
pub fn set_grant_cookie_header(
    grant: &str,
    domain: &str,
    family_expires_ms: u64,
    now_ms: u64,
) -> String {
    let max_age_secs = family_expires_ms.saturating_sub(now_ms) / 1_000;
    format!(
        "{GRANT_COOKIE_NAME}={grant}; HttpOnly; Secure; SameSite=Lax; Path=/; \
         Domain={domain}; Max-Age={max_age_secs}"
    )
}

/// Build the `Set-Cookie` header value that clears the rotation-grant cookie.
///
/// Same shape as [`clear_session_cookie_header`], for the same reason: a
/// `Set-Cookie` that does not repeat `Path`/`Domain` targets a different
/// cookie than the one a ceremony set, and so clears nothing.
#[must_use]
pub fn clear_grant_cookie_header(domain: &str) -> String {
    format!(
        "{GRANT_COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Domain={domain}; \
         Max-Age=0"
    )
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    #[test]
    fn set_then_extract_round_trips_the_token() {
        let header = set_session_cookie_header("tok-123", "explore.polychrome.test");
        assert!(header.starts_with("pc_web_session=tok-123;"));
        assert!(header.contains("Domain=explore.polychrome.test"));
        assert!(header.contains("Max-Age=900")); // 15 min in seconds

        let cookie_value_line = header.split(';').next().unwrap();
        assert_eq!(
            extract_session_cookie(cookie_value_line),
            Some("tok-123".to_owned())
        );
    }

    #[test]
    fn extract_finds_the_named_cookie_among_several() {
        let raw = "other=ignored; pc_web_session=the-token; another=also-ignored";
        assert_eq!(extract_session_cookie(raw), Some("the-token".to_owned()));
    }

    #[test]
    fn extract_returns_none_when_absent() {
        assert_eq!(extract_session_cookie("other=ignored"), None);
        assert_eq!(extract_session_cookie(""), None);
    }

    #[test]
    fn clear_header_expires_immediately_with_the_same_scope() {
        let header = clear_session_cookie_header("explore.polychrome.test");
        assert!(header.starts_with("pc_web_session=;"));
        assert!(header.contains("Domain=explore.polychrome.test"));
        assert!(header.contains("Max-Age=0"));
    }

    #[test]
    fn grant_cookie_max_age_tracks_the_family_deadline_not_the_token_ttl() {
        let now_ms = 1_700_000_000_000_u64;
        let family_expires_ms = now_ms + 90 * 24 * 60 * 60 * 1000;
        let header = set_grant_cookie_header(
            "grant-abc",
            "explore.polychrome.test",
            family_expires_ms,
            now_ms,
        );
        assert!(header.starts_with("pc_web_grant=grant-abc;"));
        assert!(header.contains("HttpOnly"));
        assert!(header.contains("Secure"));
        assert!(header.contains("SameSite=Lax"));
        assert!(header.contains("Path=/"));
        assert!(header.contains("Domain=explore.polychrome.test"));
        // 90 days in seconds — nowhere near `SESSION_TTL_MS`'s 900s.
        assert!(header.contains("Max-Age=7776000"));

        let cookie_value_line = header.split(';').next().unwrap();
        let (name, value) = cookie_value_line.split_once('=').unwrap();
        assert_eq!(name, GRANT_COOKIE_NAME);
        assert_eq!(value, "grant-abc");
    }

    #[test]
    fn grant_cookie_max_age_floors_at_zero_rather_than_underflowing() {
        let now_ms = 1_700_000_000_000_u64;
        let header =
            set_grant_cookie_header("grant-abc", "explore.polychrome.test", now_ms - 1, now_ms);
        assert!(header.contains("Max-Age=0"));
    }

    #[test]
    fn clear_grant_header_expires_immediately_with_the_same_scope() {
        let header = clear_grant_cookie_header("explore.polychrome.test");
        assert!(header.starts_with("pc_web_grant=;"));
        assert!(header.contains("Domain=explore.polychrome.test"));
        assert!(header.contains("Max-Age=0"));
    }
}