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
- New session utilities with absolute and idle timeouts, device fingerprinting, IP capture, and per‑request renewal
- WebAuthn handlers now persist challenges/user IDs in session and emit clearer, structured logs
Key accomplishments
1) 🔐 Secure session management utilities
A new Util.Session module centralizes session semantics for
both password and WebAuthn flows:
- Session creation, validation, idle/absolute timeout, and invalidation
-
Device fingerprinting (stable hash of
User-Agent | Accept-Language | Accept-Encoding) and client IP capture (X-Forwarded-For→X-Real-IP→ socket) - Automatic per‑request renewal of
last_activity - WebAuthn challenge and user‑ID storage helpers
- JSON‑friendly error signaling for expired/idle/invalid sessions
- Optional stats API for UI/observability
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:
-
Structured logging with
Dream.error/warning/info, including backtraces on failures - Registration start stores a freshly generated challenge and the current user ID in the session
- Authentication start fetches the user by email and prepares an allow‑list
- Finish endpoints decode/validate payloads and respond with consistent JSON
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:
-
Enforces a strong cookie secret: rejects
SESSION_SECRETshorter than 32 chars - Wires cookie sessions and telemetry middleware before the router
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
- Inputs:
Dream.request - Storage: cookie session fields (user id, created_at, last_activity, device id, IP)
- Timeouts: 24h absolute, 1h idle
- Renewal: write
last_activityon valid access -
Error modes:
Expired|IdleTimeout|Invalid of string -
Success criteria:
Valid→ downstream handler executes
Edge cases covered:
-
Missing session fields →
Invalid "No active session" -
Malformed timestamps → coerced via
float_of_string_optwith safe defaults -
Device fingerprint mismatch → immediate invalidation +
Invalidresult -
Proxy chains → first IP in
X-Forwarded-Forused when present
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
- Strong cookie secret enforcement prevents weak session MACs
- Absolute and idle timeouts cap exposure and reduce hijacking windows
- Device mismatch invalidates suspicious sessions
- Masking of DB connection strings avoids accidental credential leakage in logs/pages
- Centralized helpers reduce handler‑level mistakes around session state
Developer experience
-
Consistent, tagged logs via
Dream.error/warning/info, with backtraces captured on unexpected exceptions - JSON response shape standardized across auth/session failure paths
- Encapsulated helpers for challenge/user management simplify handler code
Nintai: Courage to keep going when time gets tough.