Session Management & WebAuthn Sprint: Secure Sessions and Better Auth

A focused session bringing first‑class session management and sturdier WebAuthn flows. This day's todos establishes a hardened, centralized session layer and streamlines the auth handlers to rely on it with minimal duplication. Deep WebAuthn implementation details remain in the dedicated passkey article, keeping this post focused and operationally oriented.

Overview

Key accomplishments

1) 🔐 Secure session management utilities

A new Util.Session module centralizes session semantics for both password and WebAuthn flows:

Session timing and result types:

let session_max_age = 86400   (* 24h *)
let session_idle_timeout = 3600 (* 1h *)

type validation_result =
  | Valid
  | Expired
  | IdleTimeout
  | Invalid of string

Automatic validation middleware with public route bypass (full function):

let session_middleware inner_handler request =
  ...existing code ...
  if List.mem path public_paths then inner_handler request
  else
    match%lwt validate_session request with
    | Valid -> inner_handler request
    | Expired ->
        Dream.respond ~status:`Unauthorized
          ~headers:[ ("Content-Type", "application/json") ]
          {|{"error":"Session expired","code":"SESSION_EXPIRED"}|}
    | IdleTimeout ->
        Dream.respond ~status:`Unauthorized
          ~headers:[ ("Content-Type", "application/json") ]
          {|{"error":"Session idle timeout","code":"SESSION_IDLE_TIMEOUT"}|}
    | Invalid reason ->
        Dream.respond ~status:`Unauthorized
          ~headers:[ ("Content-Type", "application/json") ]
          (Printf.sprintf
             {|{"error":"Invalid session","code":"SESSION_INVALID","reason":"%s"}|}
             reason)

WebAuthn helpers encapsulate session storage (with clear function names):

let set_webauthn_challenge request challenge =
  Dream.set_session_field request challenge_field challenge

let get_webauthn_challenge request =
  Dream.session_field request challenge_field

let clear_webauthn_challenge request =
  Dream.set_session_field request challenge_field ""

let set_webauthn_user_id request user_id =
  Dream.set_session_field request user_id_webauthn_field user_id

let get_webauthn_user_id request =
  Dream.session_field request user_id_webauthn_field

let clear_webauthn_user_id request =
  Dream.set_session_field request user_id_webauthn_field ""

A compact stats view supports observability and UI hints:

type session_stats = { 
  user_id : string; 
  age_seconds : float; 
  idle_seconds : float;
  remaining_seconds : float; 
  device_id : string option; 
  ip_address : string option }

2) 🪪 WebAuthn handler upgrades

WebAuthn registration/authentication flows now log with consistent tags and richer context, and they use Util.Session to manage ephemeral challenge/user linkage:

Illustrative snippets:

let log_error msg = Dream.error (fun log -> log "[webauthn_auth_finish] %s" msg)
let log_warning msg = Dream.warning (fun log -> log "[webauthn_auth_finish] %s" msg)

let webauthn_authenticate_finish_handler request =
  try
    let wa = get_webauthn () in
    let%lwt body = Dream.body request in
    match Webauthn.authenticate_response_of_string body with
    | Error (`Json_decoding (ctx, msg, _)) ->
        log_warning (Printf.sprintf "JSON decode error: %s - %s" ctx msg);
        Dream.json ~status:`Bad_Request
          {|{"success":false,"message":"Invalid WebAuthn payload"}|}
    | Ok auth_response ->
        let stored_challenge_opt = Util.Session.get_webauthn_challenge request in
        let stored_user_id_opt = Util.Session.get_webauthn_user_id request in
        (match stored_challenge_opt, stored_user_id_opt with
        | Some challenge, Some user_id ->
            (* Verification logic would go here *)
            Dream.json
              (Printf.sprintf
                 {|{"success":true,"user_id":"%s","challenge":"%s"}|}
                 user_id challenge)
        | _ ->
            log_warning "Missing challenge or user id in session";
            Dream.json ~status:`Unauthorized
              {|{"success":false,"message":"Session challenge missing"}|})
  with exn ->
    let bt = Printexc.get_backtrace () in
    log_error (Printf.sprintf "Unexpected error: %s\n%s" (Printexc.to_string exn) bt);
    Dream.json ~status:`Internal_Server_Error
      {|{"success":false,"message":"Internal server error"}|}

3) Session‑aware server bootstrap

The server bootstrap is environment agnostic:

Representative guardrail for the secret:

let get_session_secret () =
  match Sys.getenv_opt "SESSION_SECRET" with
  | Some secret when String.length secret >= 32 -> secret
  | Some _ ->
      Printf.eprintf
        "Error: SESSION_SECRET is set but too short (minimum 32 characters).\n";
      exit 1
  | None ->
      Printf.eprintf
        "Error: SESSION_SECRET is not set. Please configure it in your environment.\n";
      exit 1

Technical deep dive

Session lifecycle and contracts

Edge cases covered:

Device fingerprinting and IP extraction

A simple, deterministic device fingerprint is derived from stable request headers, hashed via Digest. The client IP prioritizes X-Forwarded-For, then X-Real-IP, and finally the socket address, mapping cleanly to typical reverse‑proxy deployments.

WebAuthn challenge persistence

Registration/authentication flows persist the challenge and user identity in the session, minimizing cross‑request coupling and avoiding ad‑hoc stores. Helpers (set_webauthn_challenge, get_webauthn_challenge, set_webauthn_user_id) keep the handlers declarative.

Security considerations

Developer experience

Nintai: Courage to keep going when time gets tough.

Hey, this site is part of ring.muhokama.fun!