← the tour · concept

concept · auth

Authentication, and the split that runs through everything

Who you are is a session and a hashed token; what you may do is the next page. But auth is also where orion's organizing idea was found — the belt ships mechanism, you own policy — and that split now shapes the whole toolbelt.

Authentication is the question who is this? — distinct from authorization (may they do X?), which comes after. orion answers it the way the rest of the belt answers everything: with the dangerous, easy-to-get-wrong mechanism shipped as zero-dependency primitives, and the policy — what a user is, how long a session lasts, what your login page says — emitted as a feature slice you own and edit. The security model follows pilcrow's Copenhagen Book (auth.pilcrowonpaper.com), the same source that informed Lucia — a quietly load-bearing influence on this project.

Users and sessions — the model

Two tables, both yours, both plain SQL in the scaffolded slice. The only opinionated part is the shape of a session, and it's opinionated for a reason:

// lining/auth/auth.sql.ts — what `orion gen feature auth` emits. You own it.
import type { Store } from "../../belt/store.ts"

export const migrations = [
  `create table users (
    id integer primary key,
    username text not null unique,
    password_hash text not null,        -- scrypt; never a plaintext password
    created_at text not null default (datetime('now'))
  )`,
  `create table sessions (
    id text primary key,                -- = sha256(cookie token), NOT the token
    user_id integer not null references users(id) on delete cascade,
    expires_at integer not null         -- unix seconds
  )`,
]

The load-bearing line is the comment: the cookie holds a random token; the database stores only its SHA-256 hash. So a leaked sessions table can't be replayed — there's nothing in it an attacker can put in a cookie. This is the pilcrow session model, and it costs you one sha256 per request to verify.

The split: mechanism in the belt, policy in your slice

Issuing a session touches things that are genuinely dangerous to hand-roll — token entropy, constant-time comparison, cookie flags, password hashing parameters. Those live in belt/auth.ts as primitives. Deciding things — how long a session lasts, what counts as a user, what your login page says, whether the first account is an admin — is policy, and it lives in the slice the generator emits.

belt/auth.ts

Mechanism — zero-dep, node:crypto

generateSessionToken, hashSessionToken, sessionCookie / readCookie, checkOrigin, scrypt password hash + verify (parameters encoded into the hash so they can be raised later), a rate limiter. The parts you must not get wrong.

lining/auth/

Policy — a slice you own

The users/sessions tables, login/signup commands, currentUser, session TTL and sliding refresh, first-account-is-admin bootstrap. Emitted by orion gen feature auth as editable code. No Auth class, no provider interface, no middleware.

// issuing a session — MECHANISM from the belt, POLICY (TTL, table) is yours
import { generateSessionToken, hashSessionToken, sessionCookie } from "../../belt/auth.ts"

const token = generateSessionToken()                 // 32 random bytes, base64url
sql.createSession(store, hashSessionToken(token), user.id, expiresAt)  // store the HASH
// login/signup are raw handlers (a cookie means setting headers before the
// Response exists), so the Set-Cookie goes straight on the returned Response:
headers.set("set-cookie", sessionCookie("orion_session", token, { maxAge: SESSION_TTL_S }))

// reading it back — the cookie's token is sha256'd to find the row (auth.guard.ts)
export function currentUser(ctx: Ctx): User | undefined {
  const token = readCookie(ctx.req, SESSION_COOKIE)
  if (!token) return undefined
  const row = sql.sessionWithUser(ctx.store, hashSessionToken(token))
  if (!row || row.expiresAt < now) return undefined
  // sliding expiry (pilcrow): past the half-life, extend — active users stay in
  return row.user
}

"Bring your own auth" means simply not running the generator — point currentUser at your IdP and keep the rest. There's no framework seam to satisfy, because auth was never a framework object here; it's primitives plus a slice. Passwords use scrypt from node:crypto — pilcrow's recommended choice when argon2 would mean a native dependency, and in keeping with orion's zero-dep stance.

Why this split runs through the whole belt

Mechanism-versus-policy didn't start as a manifesto — it came out of building auth, where the line between "must be correct" and "is your call" is unusually sharp. Once it was named, it turned out to describe how every good seam in the belt is drawn. The belt owns the mechanism; your code owns the meaning:

SeamBelt ships (mechanism)You own (policy)
authtokens, hashing, cookies, origin checkswhat a user is, session TTL, login UX, first-admin
jobsatomic claim, retry, backoff, dead-letterwhat the job actually does
tenancya store bound to an opaque key, leak-proofwhat a tenant is — org, workspace, team
canonthe request lifecycle + structural fieldsenrichers — tenant, plan, the flags evaluated
analyticsthe append-only sink + buffered track()the funnels and cohorts — your SQL questions

It's the same test every time: if getting it wrong is a security or correctness hole, the belt does it once, for everyone. If getting it wrong is just a different product, it's yours. That's why there are no Auth / Tenant / Job base classes to subclass — the belt hands you the sharp parts pre-sharpened and stays out of the decisions.

orion ✦ a belt of stars · built on datastar