tur-tourist-session

Swappable-store session middleware for the tur-tourist HTTP micro-framework.

The API is modelled on Rack's session middleware, adapted to Turmeric's ownership model and tourist's context pattern.

Quick start

(import tourist/app          :refer [tourist])
(import tourist/dsl          :refer [get! post!])
(import tourist/helpers      :refer [text redirect])
(import session/mw           :refer [session-mw])
(import session/config       :refer [dev-session-config])
(import session/memory-store :refer [memory-store-new])
(import session/ctx          :refer [session-get session-set! session-destroy!])

(defn dashboard [ctx : Ctx] : Response
  (let [uid (session-get ctx "user_id")]
    (if (.is-ok uid)
      (text (str-concat "Hello, user " (.ok-val uid)))
      (redirect "/login"))))

(defn main [] : int
  (let [store (memory-store-new)
        srv   (tourist 3000
                (session-mw store (dev-session-config))   ;; drop it in the list
                (get! "/dashboard" dashboard))]
    (server-stop srv)
    0))

Swapping the backend is a one-line change:

(session-mw (file-store-new "/var/lib/app/sessions") (default-session-config))

A complete login/logout flow lives in examples/login_app.tur:

tur run spices/tourist-session/examples/login_app.tur

How it works

session-mw does two things: it installs the store and cookie config into a process-global (read once per request, set once before the server starts, just like tourist's own dispatch state) and returns a tourist after-middleware item. There is no separate "before" middleware — the session is loaded lazily the first time a handler calls a session-* helper:

  1. the configured cookie is read from the request (tourist's req-cookie);
  2. if it names a session the store knows, that ID + map are adopted;
  3. otherwise a fresh 256-bit ID and an empty map are minted.

On the way out, the after-middleware inspects the per-request session state and writes the cookie with tourist's set-cookie! -- both are stdlib httpd's cookie parser and formatter, so the session spice no longer has its own (session/cookie is down to signing since v0.4.0):

Situation Action
session-destroy! was called store-delete + an expiring Set-Cookie
the session was written (dirty) store-save + a fresh Set-Cookie
rolling? on, established session store-save + Set-Cookie (slide expiry)
read-only / never touched nothing — no needless writes or cookies

Because loading is lazy, a request whose handler never touches the session pays nothing and is handed no cookie.

Public API

Middleware

Context helpers (call from handlers)

Configuration

Stores

Signed cookies

By default the cookie is an unsigned opaque bearer token: a 256-bit random ID the store maps to data, so a tampered or guessed cookie simply fails to look up. Turn on HMAC-SHA256 signing to reject a tampered or forged cookie at parse time, before any store lookup:

(import session/config :refer [default-session-config with-signing-key])

(session-mw store (with-signing-key (default-session-config) my-secret-key))

The cookie value becomes <id>.<base64url-hmac>; on the way in the MAC is recomputed and constant-time compared. A mismatch is treated exactly like an unknown ID — a fresh session is minted. Signing is fail-closed: turning it on rejects every previously-issued unsigned cookie, so existing sessions are invalidated on deploy. The key is a single key ("" disables signing); a "current + N previous" key set for rotation is a later follow-up. No formal timing claim is made beyond the constant-time MAC compare.

session-rotate! — session-fixation defense

Rotate the session ID at a privilege boundary (e.g. right after a successful login) without losing the data the user just keyed in:

(defn do-login [ctx : Ctx] : Response
  (let [uid (authenticate ctx)]
    (do
      (session-set! ctx "user_id" uid)
      (session-rotate! ctx)            ;; new ID, same data
      (redirect "/dashboard"))))

session-rotate! mints a fresh ID, keeps the live session map, and at flush saves under the new ID, deletes the old store entry, and Set-Cookies the new ID. Calling it more than once in a request is safe (only the original entry is deleted). With signing on, the cookie's MAC updates automatically.

CSRF synchronizer tokens

session/csrf adds per-session CSRF tokens on top of the session machinery (no new deps). Mint/read the token in a handler and guard state-changing requests with the middleware:

(import tourist/middleware :refer [use!])
(import session/csrf       :refer [csrf-token csrf-mw default-csrf-opts])

(tourist 3000
  (session-mw (memory-store-new) (default-session-config))
  (use! (csrf-mw (default-csrf-opts)))     ;; 403s POST/PUT/PATCH/DELETE w/o a valid token
  (get!  "/form" (fn [ctx] (html (form-with-token (csrf-token ctx)))))
  (post! "/save" save-handler))

The token lives in the session under the reserved __csrf key. The middleware accepts it as an X-Csrf-Token header (SPAs) or a configured form field (classic forms — (csrf-opts "field_name"), default csrf_token); the compare is constant-time. Safe methods (GET/HEAD/OPTIONS) always pass.

Writing a custom store

The store is a small vtable, not a typeclass. The middleware reads its store from a process-global -- because the session-* helpers your routes call read it too, not because tourist middleware cannot capture: tourist's use!, use-around! and use-after! all accept capturing closures (tourist v0.3.0 pins it with a concurrent test). A global cannot hold a value of an erased typeclass-instance type, so a backend provides three functions and an opaque state pointer and calls store-new:

;; load   : (c-fn [state:int id:cstr] int)            -> SessMap handle, 0 = miss
;; save   : (c-fn [state:int id:cstr map:int ttl:int] int)
;; delete : (c-fn [state:int id:cstr] int)
(store-new my-load my-save my-delete my-state)

Session data is a SessMap (see session/data) — a compact owned string→string map. Stores clone on the way in and out so a request's working copy never aliases the master copy. This is the same open-set extensibility a typeclass would give, expressed as a plain function-pointer vtable.

Security notes

Status

Backend State
memory store ✅ implemented + tested
file store ✅ implemented + tested
Valkey/Redis store ✅ sibling spice tur-tourist-session-valkey

The Valkey store lives in a sibling spice so the valkey/hiredis native dependency stays optional — apps using the memory or file store don't link it. Its round-trip test is self-skipping when no Valkey server is reachable, so CI stays green without one. All three backends sit behind one Store vtable, and the JSON envelope is shared via session/serde.

Tests

tur test spices/tourist-session/tests/session

Covers session ID generation, cookie parse/build, signed-cookie round-trip and tamper rejection (HMAC against the RFC 4231 vector), the SessMap, the shared JSON serde codec, both built-in stores through the Store vtable, session-rotate!, the CSRF token + middleware decision, and the full ctx + middleware request lifecycle (lazy load, dirty persist, destroy) driven against fabricated tourist contexts.

See also