Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

crates/holger-ui-server/src/session.rs

//! **The person's session, in an HttpOnly cookie** (A34-38, edda `nornir-identity-session`
//! `cookie::HostCookie`, the one Gunnar's console sets).
//!
//! holger mints the session (its passkey door's login, registration and step-up finish answer
//! `{"token": …}`); this server takes the token OUT of that answer and hands it to the browser as
//! `__Host-holger_session` (HttpOnly, Secure, SameSite=Strict, Path=/), so no script on the page
//! can read it. Every call the browser makes then carries the cookie, and this server turns it
//! back into `authorization: Bearer` for holger, as the page used to. It decides nothing: holger
//! checks the session on every call, and a bearer the caller sends itself is left as it is.
//!
//! A Holger without passkeys takes a pasted bearer: the page posts it to [`TOKEN_DOOR`] (JSON
//! only, so no cross-site form can set it) and it rides in the same cookie.

use axum::body::Body;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use http::{header, HeaderMap, HeaderValue, Method, StatusCode};
use http_body_util::BodyExt as _;
use nornir_identity_session::cookie::HostCookie;

/// The cookie's name.
pub const SESSION_COOKIE: &str = "__Host-holger_session";
/// holger's doors whose 200 answer carries a fresh session.
pub const SESSION_MINTS: [&str; 3] = ["/-/auth/passkey/login/finish", "/-/auth/passkey/register/finish", "/-/auth/passkey/step-up/finish"];
/// holger's logout door: the cookie goes with the session.
pub const SESSION_ENDS: &str = "/-/auth/passkey/logout";
/// Where the page hands over a pasted bearer (`{"token": "…"}`; an empty one clears it).
pub const TOKEN_DOOR: &str = "/-/console/token";
/// How long a session cookie lives when holger names no expiry (holger's own session TTL).
pub const SESSION_TTL_SECS: i64 = 12 * 3600;
/// The largest session answer this server reads to take the token out.
const MAX_SESSION_ANSWER: usize = 64 * 1024;

fn cookie() -> HostCookie {
    HostCookie::new(SESSION_COOKIE).expect("SESSION_COOKIE is __Host- prefixed")
}

fn now_secs() -> i64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}

/// Pure: **the bearer a request's cookie carries**, for a request that sends none of its own.
pub fn cookie_bearer(headers: &HeaderMap) -> Option<String> {
    if headers.contains_key(header::AUTHORIZATION) {
        return None;
    }
    let lines = headers.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok());
    cookie().value_in(lines).filter(|v| !v.is_empty())
}

/// Pure: **a session answer with its token taken out**: the answer the page reads (the token
/// gone, `signed_in: true` in its place) and the `Set-Cookie` that carries the token, living
/// until holger's `expires_at` (else [`SESSION_TTL_SECS`]). `None`: no token in it.
pub fn take_session(answer: &[u8], now: i64) -> Option<(Vec<u8>, String)> {
    let mut v: serde_json::Value = serde_json::from_slice(answer).ok()?;
    let token = v.as_object_mut()?.remove("token")?.as_str()?.to_owned();
    if !fits_the_cookie(&token) {
        return None;
    }
    let max_age = v.get("expires_at").and_then(|e| e.as_i64()).map(|at| at - now).filter(|a| *a > 0).unwrap_or(SESSION_TTL_SECS);
    v["signed_in"] = serde_json::Value::Bool(true);
    Some((serde_json::to_vec(&v).ok()?, cookie().set(&token, max_age)))
}

/// Can `token` ride in the cookie as it is (non-empty, printable ASCII, no `;` or `,`)?
fn fits_the_cookie(token: &str) -> bool {
    !token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic() && b != b';' && b != b',')
}

/// Pure: **does the answer carry a session token at all** (a `token` field that is text)?
fn carries_a_token(answer: &[u8]) -> bool {
    serde_json::from_slice::<serde_json::Value>(answer).ok().is_some_and(|v| v.get("token").is_some_and(|t| t.is_string()))
}

/// Said when holger minted a session whose token this console cannot keep in its cookie
/// (RA5-45): the token is dropped, never handed to the page.
pub const UNFIT_TOKEN: &str = "holger minted a session this console cannot keep in its cookie: nothing was kept; sign in again, and if it happens again, tell holger's operator";

/// The `Set-Cookie` for `value` without its life: what every cookie this server sets begins with.
pub fn set_line(value: &str) -> String {
    let full = cookie().set(value, 0);
    full.strip_suffix("; Max-Age=0").unwrap_or(&full).to_owned()
}

/// The `Set-Cookie` that ends the session cookie.
pub fn cleared() -> String {
    cookie().set("", 0)
}

/// Pure: **is this a cookie-authenticated state change from another site?** (RA5-5, the rule
/// Gunnar's console keeps in `csrf_blocked`). The cookie is SameSite=Strict, which still sends
/// it from a sibling site of the same registrable domain, so an unsafe method that rides on the
/// cookie must come from this very site: `Sec-Fetch-Site`, where the browser sends it, says
/// `same-origin`, and `Origin`, where sent, names this site's host. A request that rides on the
/// cookie and sends neither is refused too. A read, and a caller's own `authorization`, never.
pub fn cross_site(method: &Method, headers: &HeaderMap, authority: Option<&str>) -> bool {
    if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) || cookie_bearer(headers).is_none() {
        return false;
    }
    let text = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).map(str::trim);
    let fetch_site = text("sec-fetch-site");
    if fetch_site.is_some_and(|s| !s.eq_ignore_ascii_case("same-origin")) {
        return true;
    }
    let host = text("host").or(authority).map(str::to_ascii_lowercase);
    match text("origin") {
        Some(origin) => {
            let named = origin.split_once("://").map(|(_, rest)| rest.trim_end_matches('/').to_ascii_lowercase());
            named.is_none() || host.is_none() || named != host
        }
        None => fetch_site.is_none(),
    }
}

/// Refused: a cookie-authenticated state change from another site.
pub const CROSS_SITE_REFUSED: &str = "cross-site request blocked: a change on this console must come from this console's own page";

/// **The layer**: a request with the cookie and no `authorization` of its own carries the
/// cookie's bearer from here on (every pass-through, and a merged relay's doors alike). A
/// cookie-authenticated state change from another site is refused here (403), before the
/// cookie becomes anybody's bearer (RA5-5).
pub async fn bearer_from_cookie(mut req: Request, next: Next) -> Response {
    if cross_site(req.method(), req.headers(), req.uri().authority().map(|a| a.as_str())) {
        tracing::warn!(method = %req.method(), path = %req.uri().path(), "blocked a cookie-authenticated request with a missing or foreign Origin");
        return (StatusCode::FORBIDDEN, [(header::CACHE_CONTROL, "no-store")], CROSS_SITE_REFUSED).into_response();
    }
    if let Some(bearer) = cookie_bearer(req.headers()) {
        if let Ok(v) = HeaderValue::from_str(&format!("Bearer {bearer}")) {
            req.headers_mut().insert(header::AUTHORIZATION, v);
        }
    }
    next.run(req).await
}

/// **The console's router with the session cookie** (the layer above). holger's front wraps
/// its merged router (the relay's doors and this console) with this, once.
pub fn with_session(router: axum::Router) -> axum::Router {
    router.layer(axum::middleware::from_fn(bearer_from_cookie))
}

/// **holger's answer, as the browser gets it**: a session mint's token moved into the cookie,
/// a logout holger DID clears the cookie; everything else as holger said it. A logout holger
/// refused keeps the cookie (RA5-11): the session lives on at holger, so the browser keeps the
/// way to end it and the page says holger's words, never "logged out".
pub async fn answer(method: &Method, path: &str, res: Response) -> Response {
    if path == SESSION_ENDS {
        let mut res = res;
        if res.status().is_success() {
            if let Ok(v) = HeaderValue::from_str(&cleared()) {
                res.headers_mut().append(header::SET_COOKIE, v);
            }
        }
        return res;
    }
    if method != Method::POST || !SESSION_MINTS.contains(&path) || res.status() != StatusCode::OK {
        return res;
    }
    let (mut parts, body) = res.into_parts();
    let bytes = match http_body_util::Limited::new(body, MAX_SESSION_ANSWER).collect().await {
        Ok(c) => c.to_bytes(),
        Err(_) => return (StatusCode::BAD_GATEWAY, "holger's session answer could not be read").into_response(),
    };
    match take_session(&bytes, now_secs()) {
        Some((page, set)) => {
            parts.headers.remove(header::CONTENT_LENGTH);
            if let Ok(v) = HeaderValue::from_str(&set) {
                parts.headers.append(header::SET_COOKIE, v);
            }
            parts.headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
            Response::from_parts(parts, Body::from(page))
        }
        // RA5-45: a token that does not fit the cookie never reaches the page.
        None if carries_a_token(&bytes) => {
            tracing::error!(path, "holger minted a session token that does not fit the session cookie: dropped");
            (StatusCode::BAD_GATEWAY, [(header::CACHE_CONTROL, "no-store")], UNFIT_TOKEN).into_response()
        }
        None => Response::from_parts(parts, Body::from(bytes)),
    }
}

/// **[`TOKEN_DOOR`]**: a pasted bearer into the cookie (JSON only), an empty one clears it.
pub async fn token_door(req: Request) -> Response {
    let json = req.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|c| c.trim().starts_with("application/json"));
    if !json {
        return (StatusCode::UNSUPPORTED_MEDIA_TYPE, "send the token as JSON").into_response();
    }
    let Ok(bytes) = http_body_util::Limited::new(req.into_body(), 16 * 1024).collect().await.map(|c| c.to_bytes()) else {
        return (StatusCode::PAYLOAD_TOO_LARGE, "a token is at most 16 KiB").into_response();
    };
    let token = serde_json::from_slice::<serde_json::Value>(&bytes).ok().and_then(|v| v.get("token").and_then(|t| t.as_str()).map(|t| t.trim().to_owned())).unwrap_or_default();
    if !token.bytes().all(|b| b.is_ascii_graphic() && b != b';' && b != b',') {
        return (StatusCode::BAD_REQUEST, "that is not a bearer token").into_response();
    }
    let set = if token.is_empty() { cleared() } else { cookie().set(&token, SESSION_TTL_SECS) };
    let mut res = (StatusCode::NO_CONTENT, ()).into_response();
    if let Ok(v) = HeaderValue::from_str(&set) {
        res.headers_mut().append(header::SET_COOKIE, v);
    }
    res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
    res
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_token_leaves_the_answer_and_rides_in_an_httponly_host_cookie() {
        let (page, set) = take_session(br#"{"token":"tok-1","expires_at":1100,"display":"anna","role":"Admin"}"#, 1000).unwrap();
        let page: serde_json::Value = serde_json::from_slice(&page).unwrap();
        assert!(page.get("token").is_none(), "the page never sees the token: {page}");
        assert_eq!((page["signed_in"].as_bool(), page["display"].as_str()), (Some(true), Some("anna")));
        assert_eq!(set, cookie().set("tok-1", 100), "edda's one session cookie, living until holger's expiry");
        assert!(set.starts_with("__Host-holger_session=tok-1; ") && set.ends_with("; Max-Age=100"), "{set}");
        assert!(take_session(br#"{"ok":true}"#, 0).is_none(), "an answer with no session sets no cookie");
        assert!(take_session(br#"{"token":"a;b"}"#, 0).is_none(), "a token that would break the cookie is not set");
        assert!(take_session(br#"{"token":"t"}"#, 0).unwrap().1.ends_with(&format!("Max-Age={SESSION_TTL_SECS}")));
    }

    /// ★ **RA5-45: a minted token that does not fit the cookie never reaches the page.** It was
    /// handed on unchanged, token in the body (readable by script, the exposure A34-38 removed)
    /// and no cookie set: the login answered 200 and the console then read signed out, with no
    /// words. Now the token is dropped and the answer says why.
    #[tokio::test]
    async fn a_minted_token_that_does_not_fit_the_cookie_never_reaches_the_page() {
        let minted = (StatusCode::OK, r#"{"token":"tok;secret-part","display":"anna"}"#).into_response();
        let res = answer(&Method::POST, SESSION_MINTS[0], minted).await;
        let status = res.status();
        assert!(res.headers().get(header::SET_COOKIE).is_none());
        let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
        assert!(!body.contains("secret-part"), "the token reached the page: {body}");
        assert_ne!(status, StatusCode::OK, "a login whose session cannot be kept is not a 200: {body}");
        assert!(body.contains("sign in again"), "said by name: {body}");
        // An answer with no token at all (a refusal holger worded) is passed on as it is.
        let plain = (StatusCode::OK, r#"{"ok":true}"#).into_response();
        let res = answer(&Method::POST, SESSION_MINTS[0], plain).await;
        assert_eq!(res.status(), StatusCode::OK);
    }

    #[test]
    fn the_cookie_is_the_bearer_only_where_the_caller_sends_none() {
        let mut h = HeaderMap::new();
        h.insert(header::COOKIE, HeaderValue::from_static("a=1; __Host-holger_session=tok-1"));
        assert_eq!(cookie_bearer(&h).as_deref(), Some("tok-1"));
        h.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer machine"));
        assert_eq!(cookie_bearer(&h), None, "a caller's own bearer stands");
        let mut two = HeaderMap::new();
        two.append(header::COOKIE, HeaderValue::from_static("__Host-holger_session=a"));
        two.append(header::COOKIE, HeaderValue::from_static("__Host-holger_session=b"));
        assert_eq!(cookie_bearer(&two), None, "two session cookies are none (a planted one never wins)");
        assert!(cleared().ends_with("Max-Age=0"));
    }

    /// ★ RA5-5: **a cookie-authenticated state change from another site is refused.** The
    /// cookie is SameSite=Strict, so a page on a sibling site (`test2.holger.rs` against
    /// `holger.rs`) still sends it; a POST whose Origin or Sec-Fetch-Site is not this site's is
    /// refused (403) and never carries the person's bearer to holger. A read, a same-origin call
    /// and a caller's own bearer pass as before.
    #[tokio::test]
    async fn a_cookie_state_change_from_another_site_is_refused() {
        use tower::ServiceExt as _;
        let app = with_session(axum::Router::new().route(
            "/grpc/x",
            axum::routing::any(|h: HeaderMap| async move { h.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).unwrap_or("none").to_owned() }),
        ));
        let call = |method: Method, extra: &[(&str, &str)]| {
            let mut r = http::Request::builder().method(method).uri("/grpc/x").header(header::HOST, "holger.mock.invalid").header(header::COOKIE, "__Host-holger_session=tok-1");
            for (k, v) in extra {
                r = r.header(*k, *v);
            }
            app.clone().oneshot(r.body(Body::empty()).unwrap())
        };
        let said = |r: Response| async move { (r.status(), String::from_utf8(r.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap()) };
        for foreign in [&[("origin", "https://test2.holger.mock.invalid")][..], &[("sec-fetch-site", "same-site")], &[("origin", "null")], &[]] {
            let (status, body) = said(call(Method::POST, foreign).await.unwrap()).await;
            assert_eq!(status, StatusCode::FORBIDDEN, "{foreign:?}: {body}");
            assert!(!body.contains("tok-1"), "the bearer never reaches holger: {body}");
        }
        let (status, body) = said(call(Method::POST, &[("origin", "https://holger.mock.invalid"), ("sec-fetch-site", "same-origin")]).await.unwrap()).await;
        assert_eq!((status, body.as_str()), (StatusCode::OK, "Bearer tok-1"), "this site's own call");
        let (status, body) = said(call(Method::GET, &[("sec-fetch-site", "cross-site")]).await.unwrap()).await;
        assert_eq!((status, body.as_str()), (StatusCode::OK, "Bearer tok-1"), "a read changes nothing");
        let (status, body) = said(call(Method::POST, &[("origin", "https://evil.mock.invalid"), ("authorization", "Bearer machine")]).await.unwrap()).await;
        assert_eq!((status, body.as_str()), (StatusCode::OK, "Bearer machine"), "a caller's own bearer is no cookie session");
    }
}