toiljs 0.0.85 → 0.0.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ # Extending & integrating auth
2
+
3
+ Built-in auth is deliberately opinionated so the common case is one line. This page covers the identity
4
+ you build ON — `ToilUserId` — and how to go beyond the defaults: keying your own data on a user, a custom
5
+ user shape, and hand-writing auth from the same primitives.
6
+
7
+ ## `ToilUserId`
8
+
9
+ The stable, tenant-scoped user identity: `sha256(mldsaPublicKey ‖ identifier ‖ domain)`, a 256-bit value.
10
+ It's a **global** (no import), like `crypto`.
11
+
12
+ ```ts
13
+ // Read the current user's id in any handler (gate on hasSession() — see the null note below).
14
+ const id: ToilUserId = AuthService.userId()!;
15
+
16
+ // Or derive one yourself.
17
+ const id2 = ToilUserId.derive(mldsaPublicKey, 'alice@example.com', 'acme.dacely.com');
18
+ ```
19
+
20
+ | Member | Description |
21
+ | --- | --- |
22
+ | `ToilUserId.derive(pk, identifier, domain)` | Derive from an ML-DSA public key + email/username + tenant domain. Deterministic. |
23
+ | `ToilUserId.fromBytes(b)` | Rebuild from a 32-byte digest (from `toBytes()` or storage). |
24
+ | `toBytes(): Uint8Array` | The 32 identity bytes. |
25
+ | `toHex(): string` | Lowercase 64-char hex — a convenient string key. |
26
+ | `isZero(): bool` | True for the unset / anonymous id. |
27
+ | `equals(other): bool` | Value equality. |
28
+ | `a == b` / `a != b` | Overloaded value comparison — **O(1)** (four `u64` word compares, no byte loop, no allocation). |
29
+
30
+ ```ts
31
+ const a = ToilUserId.derive(pk, 'alice', 'acme.com');
32
+ const b = ToilUserId.derive(pk, 'alice', 'acme.com');
33
+ const c = ToilUserId.derive(pk, 'bob', 'acme.com');
34
+ a == b; // true — same inputs, same id
35
+ a != c; // true — different user
36
+ ```
37
+
38
+ > **Null-check gotcha:** because `ToilUserId` overloads `==`, `AuthService.userId() == null` does NOT
39
+ > type-check (`==` expects a `ToilUserId`). Gate with `AuthService.hasSession()` and then `userId()!`, or
40
+ > compare with `getUser()` (a plain nullable). `===` is reference identity in AssemblyScript and is not
41
+ > overloadable — use `==` for value equality.
42
+
43
+ ## Keying your own data on the user
44
+
45
+ `toilUserId` is the right key for per-user data — it's stable across sessions/devices and opaque. Use the
46
+ hex as a string key, or the bytes in a `@data` key class:
47
+
48
+ ```ts
49
+ @data
50
+ class UserKey {
51
+ id: Uint8Array = new Uint8Array(0); // toilUserId bytes
52
+ constructor(id: Uint8Array = new Uint8Array(0)) { this.id = id; }
53
+ }
54
+
55
+ @data class Profile { displayName: string = ''; bio: string = ''; }
56
+
57
+ @database
58
+ class AppDb {
59
+ @collection static profiles: Documents<UserKey, Profile>;
60
+ }
61
+
62
+ @rest('profile')
63
+ class ProfileApi {
64
+ @auth
65
+ @post('/')
66
+ public save(ctx: RouteContext): Response {
67
+ const key = new UserKey(AuthService.userId()!.toBytes());
68
+ const p = Profile.decode(ctx.request.body);
69
+ AppDb.profiles.enqueue(key, p); // upsert this user's profile
70
+ return Response.text('saved\n');
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## A custom user shape — opt out and hand-write
76
+
77
+ Built-in auth ships the single `@user` (`{ toilUserId, username }`), and there is exactly **one `@user` per
78
+ program**. If you need a richer authenticated user (roles, a display name, a tenant), do **not** enable
79
+ `server.auth`; hand-write a controller and your own `@user` using the same primitives. The
80
+ `examples/basic` app does exactly this — copy `server/routes/Auth.ts` + `server/routes/Session.ts` as your
81
+ starting point. The shape:
82
+
83
+ ```ts
84
+ @user
85
+ class Account {
86
+ username: string = '';
87
+ admin: bool = false;
88
+ score: u64 = 0;
89
+ }
90
+
91
+ // After verifyLogin succeeds, mint your own session payload:
92
+ resp.setCookie(AuthService.mintSession(account.encode(), 3600));
93
+ resp.setCookie(AuthService.userCookie(account.encode(), 3600));
94
+ ```
95
+
96
+ You still get `@auth`, `AuthService.getUser()` (typed to YOUR `@user`), and every crypto primitive — you're
97
+ just choosing your own routes and user fields. You can still derive a `ToilUserId` yourself and put it in
98
+ your `@user` if you want the stable id.
99
+
100
+ ## Adding email verification / 2FA
101
+
102
+ Layer a second factor on top of the session with `TwoFactor` (stateless email codes — no DB; see
103
+ [email](../email.md)). Typical flow: after login, require a verified email before granting access to
104
+ sensitive routes.
105
+
106
+ ```ts
107
+ @rest('2fa')
108
+ class TwoFactorApi {
109
+ // Step 1: email a code to the logged-in user, hand back the signed token.
110
+ @auth @post('/send')
111
+ public send(): Response {
112
+ const email = /* the user's email — e.g. their username, or a stored profile field */;
113
+ const ch = TwoFactor.send(email, 'login'); // emails the code, returns { token, status }
114
+ return Response.bytes(new DataWriter().writeString(ch.token).toBytes());
115
+ }
116
+
117
+ // Step 2: verify the code the user typed against the token.
118
+ @auth @post('/verify')
119
+ public verify(ctx: RouteContext): Response {
120
+ const r = new DataReader(ctx.request.body);
121
+ const token = r.readString(); const email = r.readString(); const code = r.readString();
122
+ if (!TwoFactor.verify(token, email, code)) return Response.text('bad code\n', 401);
123
+ // Mark this session 2FA-verified: re-mint the session with a flag in your own @user, or store a
124
+ // per-user "verified" record keyed on AuthService.userId().
125
+ return Response.text('verified\n');
126
+ }
127
+ }
128
+ ```
129
+
130
+ `TwoFactor` gives integrity + expiry but not single-use (a code re-verifies within its TTL); keep the TTL
131
+ short. For a branded email, use `TwoFactor.issue(...)` (returns the code without sending) + your own
132
+ `Emails.*` template. Call `TwoFactor.setSecret(...)` once at startup in production.
133
+
134
+ ## The `AuthService` primitive reference
135
+
136
+ Everything the built-in controller is built from, available for hand-written auth. All are ambient globals
137
+ (no import).
138
+
139
+ **Sessions & cookies**
140
+ - `mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie` — the signed `__Host-toil_sess` cookie.
141
+ - `userCookie(userData, ttlSecs?): Cookie` — the readable `__Secure-toil_user` companion.
142
+ - `clearSession(): Cookie` / `clearUserCookie(): Cookie`.
143
+ - `hasSession(): bool` — the `@auth` predicate.
144
+ - `getSessionBytes(): Uint8Array | null` — the verified `@user` payload bytes.
145
+ - `getUser(): <your @user> | null` — decoded, typed to your `@user`.
146
+ - `userId(): ToilUserId | null` — the stable id (built-in `@user` layout).
147
+ - `setSecret(secret: Uint8Array)` — override the session HMAC key programmatically.
148
+
149
+ **Post-quantum login crypto**
150
+ - `oprfEvaluate(username, blinded): Uint8Array` — server-keyed OPRF eval.
151
+ - `buildRegisterMessage(username, pk)` / `verifyRegister(pk, msg, sig): bool` — proof-of-possession.
152
+ - `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)`
153
+ / `verifyLogin(pk, msg, sig): bool`.
154
+ - `mlkemDecapsulate(ct): Uint8Array` · `serverKemKeyId(): Uint8Array`.
155
+ - `deriveSessionKey(sharedSecret, transcriptHash)` · `serverConfirmTag(sessionKey, transcriptHash)` — the
156
+ mutual-auth confirmation.
157
+ - `sha256(data): Uint8Array`.
158
+ - `setOprfSeed(seed)` · `setServerKemSecretKey(sk)` · `setServerKemPublicKey(pk)` — override the seeds/keys.
159
+ - Sizes: `PUBLIC_KEY_LEN`, `SIGNATURE_LEN`, `OPRF_ELEMENT_LEN`, `KEM_CIPHERTEXT_LEN`, `SHARED_SECRET_LEN`, …
160
+
161
+ **Related globals** — `TwoFactor` (email codes; see [email](../email.md)), `RateLimitService` (used by
162
+ `@ratelimit`), `Environment` (the secret store).
163
+
164
+ ## Two ways in, one behavior
165
+
166
+ The config flag and the import are identical at build time — both make the build append the shipped
167
+ `@user` + `@rest('auth')` controller to the toilscript **entry** set, where their decorators weave and the
168
+ `@rest` class self-mounts. (A framework decorator source only weaves as an entry; that's why a plain
169
+ `import` of the controller isn't enough on its own — the marker import is detected by the build, which does
170
+ the entry injection.) This is why built-in auth needs no runtime registration call.
@@ -0,0 +1,138 @@
1
+ # How Toil auth works
2
+
3
+ Toil auth is a **post-quantum, password-based, mutually-authenticated** login. The design goal: the user
4
+ types a password, but the server never sees it, never stores it, and can't be phished into leaking a
5
+ verifier — and every primitive is quantum-resistant.
6
+
7
+ This page explains the protocol. You do not need to understand it to use auth (see [Usage](./usage.md)),
8
+ but you should understand the guarantees before you ship.
9
+
10
+ ## The building blocks
11
+
12
+ | Primitive | Role |
13
+ | --- | --- |
14
+ | **OPRF** (RFC 9497, ristretto255-SHA512) | A *server-keyed* salt. The client blinds its password, the server evaluates it under a per-user key, the client unblinds. The result can't be computed without the server, so a stolen database can't be brute-forced offline. |
15
+ | **Argon2id** | Stretches the OPRF output into key material (memory-hard, GPU/ASIC-resistant). Runs in the browser. |
16
+ | **ML-DSA-44** (FIPS 204) | The user's identity key pair is *derived* from the Argon2id output. The server stores only the **public** key. Login is proved by an ML-DSA signature. |
17
+ | **ML-KEM-768** (FIPS 203) | Key encapsulation on login: the server proves it holds its KEM secret by returning a confirmation tag only derivable from the decapsulated shared secret → **mutual** auth (anti-phishing). |
18
+ | **HMAC-SHA256** | Signs the session cookie (`AUTH_SESSION_SECRET`). |
19
+
20
+ > The whole thing is sometimes called an *aPAKE* (asymmetric password-authenticated key exchange). Do not
21
+ > call it OPAQUE — this is Toil's own OPRF + KEM + signature construction.
22
+
23
+ ## What the server stores
24
+
25
+ Per account, in ToilDB (`@database AuthDb`):
26
+
27
+ - `username`, a deterministic `salt`, the **ML-DSA public key**, and the Argon2id params.
28
+
29
+ That's it. **No password, no password hash, no verifier that can be brute-forced without the OPRF key.**
30
+ Login challenges are a second collection, consumed exactly once (atomic `getDelete`).
31
+
32
+ ## Registration
33
+
34
+ ```
35
+ Browser (client) Toil edge (server)
36
+ ───────────────── ──────────────────
37
+ blind(password) OPRF key = f(seed, username)
38
+ │ username, blinded │
39
+ │ ───────── POST /auth/register/start ───────────► │
40
+ │ │ evaluated = OPRF(blinded)
41
+ │ ◄──── {mem,iters,par, salt, evaluated} ───────── │ (salt is deterministic per user)
42
+ │ │
43
+ unblind → oprfOut │
44
+ seed = Argon2id(oprfOut, salt, params) │
45
+ (pk, sk) = ML-DSA-44.keygen(seed) │
46
+ proof = ML-DSA.sign(sk, "register|username|pk") │
47
+ │ username, pk, proof │
48
+ │ ───────── POST /auth/register/finish ──────────► │
49
+ │ │ verifyRegister(pk, msg, proof) ✓ proof-of-possession
50
+ │ ◄──────────── {status: 0 ok | 1 taken} ──────── │ store AuthAccount{username, salt, pk, params}
51
+ ```
52
+
53
+ The server never learns the password or the secret key `sk` — only the public key `pk` and a proof the
54
+ client holds the matching `sk`. A duplicate username returns a **distinguishable** `status = 1` (so the UI
55
+ can say "taken, log in instead"); everything else fails generically.
56
+
57
+ ## Login (with mutual auth)
58
+
59
+ ```
60
+ Browser (client) Toil edge (server)
61
+ ───────────────── ──────────────────
62
+ blind(password) │
63
+ │ username, blinded │
64
+ │ ───────── POST /auth/login/start ──────────────► │ evaluated = OPRF(blinded) (ALWAYS, even unknown user)
65
+ │ │ if known: store Challenge{cid, nonce, iat, exp}
66
+ │ ◄─ {cid, aud, params, salt, nonce, iat, exp, ── │ (identical response whether the user exists or not)
67
+ │ evaluated} │
68
+ unblind → Argon2id → (pk, sk) = ML-DSA.keygen │
69
+ (ct, ssС) = ML-KEM-768.encapsulate(serverKemPublicKey) │
70
+ msg = "login|username|aud|cid|nonce|iat|exp|ct|params|kid" │
71
+ sig = ML-DSA.sign(sk, msg) │
72
+ │ cid, ct, sig │
73
+ │ ───────── POST /auth/login/finish ─────────────► │ ch = challenges.getDelete(cid) (consume once; check exp)
74
+ │ │ rebuild msg from OUR stored values + ct
75
+ │ │ verifyLogin(acct.pk, msg, sig) ✓ it's really this user
76
+ │ │ ssS = ML-KEM.decapsulate(ct) ✓ we hold the KEM key
77
+ │ │ K = deriveSessionKey(ssS, H(msg))
78
+ │ ◄──── {0, sessionToken, serverConfirm} ──────── │ serverConfirm = tag(K, H(msg))
79
+ │ + Set-Cookie: __Host-toil_sess=… │ mint session cookie
80
+ verify serverConfirm using ssC ✓ the server is genuine │
81
+ ```
82
+
83
+ Two verifications, both required:
84
+ 1. **The server verifies the client** — the ML-DSA signature over a message bound to the challenge, the
85
+ Argon2id params, and the server's KEM key id. Replays fail (the challenge is consumed).
86
+ 2. **The client verifies the server** — the `serverConfirm` tag is derivable only from the ML-KEM shared
87
+ secret, which only the holder of the KEM secret key can decapsulate. A phishing site can't forge it.
88
+
89
+ ## Anti-enumeration
90
+
91
+ `/login/start` behaves identically for a known and an unknown user: it always runs the OPRF (a decoy key
92
+ for unknown users), returns a **deterministic** per-user salt and constant params, and a fresh challenge.
93
+ The challenge is persisted only for a real account, and `/login/finish` fails generically at consume for an
94
+ unknown user. So an attacker can't probe which usernames exist. Every failure path returns the same
95
+ `401 auth: request failed`.
96
+
97
+ ## Sessions & cookies
98
+
99
+ On successful login the server mints **two** cookies:
100
+
101
+ - **`__Host-toil_sess`** — the authoritative session. HMAC-SHA256 signed with `AUTH_SESSION_SECRET`,
102
+ `HttpOnly`, `Secure`, `SameSite=Lax`. It carries the `@user` codec payload. `@auth` and
103
+ `AuthService.getUser()` open + verify this cookie server-side; a forged or tampered cookie fails.
104
+ - **`__Secure-toil_user`** — a **readable** companion carrying the same payload, so the browser can show
105
+ "logged in as …" via the client's `getUser()`. The server **never trusts it** — it is display-only.
106
+
107
+ Each request runs in a fresh wasm instance, but the signed cookie is self-contained, so no server-side
108
+ session store is needed. `AUTH_SESSION_SECRET` must be identical across every edge instance (it is, via the
109
+ env store) so a cookie minted anywhere verifies everywhere.
110
+
111
+ ## The stable user identity — `ToilUserId`
112
+
113
+ At login the server derives a stable, tenant-scoped id and stores it in the session (the first field of the
114
+ built-in `@user`):
115
+
116
+ ```
117
+ toilUserId = SHA-256( mldsaPublicKey ‖ username ‖ domain ) // 256 bits
118
+ ```
119
+
120
+ - **Stable:** same login key + username on the same tenant `domain` → same id, forever, across sessions
121
+ and devices. Key your own data on it.
122
+ - **Opaque + one-way:** it's a hash — safe to store, log, or expose without leaking the key or the address.
123
+ - Read it anywhere with `AuthService.userId()`. See [Extending](./extending.md#toiluserid) for the
124
+ `ToilUserId` API (O(1) `==` / `!=`, `toHex()`, …).
125
+
126
+ ## Threat-model summary
127
+
128
+ - **Server database stolen** → attacker gets public keys + salts, not passwords. Brute-forcing needs the
129
+ OPRF key (server-side) *and* Argon2id work per guess.
130
+ - **Server compromised / malicious** → still can't recover passwords (only public keys) and can't forge a
131
+ past session without `AUTH_SESSION_SECRET`.
132
+ - **Phishing site** → can't produce the `serverConfirm` tag (no KEM secret), so a correct client aborts.
133
+ - **Quantum adversary** → ML-DSA + ML-KEM are post-quantum; the OPRF/Argon2id/HMAC pieces are classical but
134
+ not the long-term identity or key-exchange.
135
+ - **Replay** → challenges are single-use (`getDelete`) with a short TTL.
136
+
137
+ Residual responsibilities are yours: set the [secrets](./configuration.md), pin your deployment's KEM
138
+ public key in the client, and raise the Argon2id params for production.
@@ -0,0 +1,102 @@
1
+ # Authentication
2
+
3
+ Toil ships a complete, **post-quantum password login** you turn on with one line. No passwords on your
4
+ server, no third-party identity provider, no hand-written crypto: enable it and you get a `/auth/*` API,
5
+ signed sessions, `@auth`-guarded routes, and a stable per-user identity.
6
+
7
+ ```ts
8
+ // toil.config.ts
9
+ import { defineConfig } from 'toiljs/compiler';
10
+
11
+ export default defineConfig({
12
+ server: { auth: true }, // ← mounts the full /auth/* API + sessions
13
+ });
14
+ ```
15
+
16
+ That is the whole setup. The build appends the framework's auth controller to your server, so `/auth/*`
17
+ is live and `@auth` works everywhere.
18
+
19
+ ## What you get
20
+
21
+ | Endpoint | Purpose |
22
+ | --- | --- |
23
+ | `POST /auth/register/start` · `/register/finish` | Create an account (password never leaves the browser) |
24
+ | `POST /auth/login/start` · `/login/finish` | Log in; sets a signed session cookie |
25
+ | `GET /auth/me` *(`@auth`)* | The current user (`toilUserId` + `username`) |
26
+ | `POST /auth/logout` *(`@auth`)* | Clear the session |
27
+
28
+ Plus, in your own code:
29
+
30
+ - **`@auth`** on any route (or a whole `@rest` class) → 401 unless there's a valid session.
31
+ - **`AuthService.getUser()`** → the typed logged-in user.
32
+ - **`AuthService.userId()`** → the stable [`ToilUserId`](./extending.md#toiluserid) (a 256-bit id you can
33
+ key your data on).
34
+ - **The client** — `import { Auth } from 'toiljs/client'`, then `Auth.register(username, password)` /
35
+ `Auth.login(username, password)`. It does all the browser-side crypto and talks to `/auth/*` for you.
36
+
37
+ ## A login page in full
38
+
39
+ ```tsx
40
+ // client/routes/login.tsx
41
+ import { useState } from 'react';
42
+ import { Auth } from 'toiljs/client';
43
+
44
+ export default function Login() {
45
+ const [u, setU] = useState('');
46
+ const [p, setP] = useState('');
47
+ const [msg, setMsg] = useState('');
48
+
49
+ const register = async () => {
50
+ try { await Auth.register(u, p); setMsg('registered — now log in'); }
51
+ catch (e) { setMsg(parseError(e)); }
52
+ };
53
+ const login = async () => {
54
+ try { await Auth.login(u, p); setMsg('logged in'); } // sets the session cookie
55
+ catch (e) { setMsg(parseError(e)); }
56
+ };
57
+
58
+ return (
59
+ <main>
60
+ <input value={u} onChange={(e) => setU(e.currentTarget.value)} placeholder="username" />
61
+ <input value={p} type="password" onChange={(e) => setP(e.currentTarget.value)} placeholder="password" />
62
+ <button onClick={register}>Register</button>
63
+ <button onClick={login}>Log in</button>
64
+ <p>{msg}</p>
65
+ </main>
66
+ );
67
+ }
68
+ ```
69
+
70
+ ```ts
71
+ // server/routes/Secret.ts — a route only a logged-in user can reach
72
+ import { Response } from 'toiljs/server/runtime';
73
+
74
+ @rest('secret')
75
+ class Secret {
76
+ @auth // 401 without a valid session
77
+ @get('/')
78
+ public secret(): Response {
79
+ const user = AuthService.getUser()!; // typed: { toilUserId, username }
80
+ return Response.text('hello ' + user.username + '\n');
81
+ }
82
+ }
83
+ ```
84
+
85
+ That's a real, production-grade auth system — the password is stretched with Argon2id in the browser into
86
+ an ML-DSA-44 key pair, your server only ever stores a public key, and login is a mutually-authenticated
87
+ ML-KEM-768 challenge. You didn't write any of it.
88
+
89
+ ## Where to go next
90
+
91
+ - **[How it works](./how-it-works.md)** — the protocol (OPRF + Argon2id + ML-DSA + ML-KEM), sessions,
92
+ cookies, and the `ToilUserId`, with sequence diagrams.
93
+ - **[Usage](./usage.md)** — enabling it, the client API, guarding routes, reading the user, the full wire
94
+ contract of each endpoint.
95
+ - **[Configuration](./configuration.md)** — the secrets a deployment MUST set, the audience/domain, tuning
96
+ Argon2id, and the deploy checklist.
97
+ - **[Extending & integrating](./extending.md)** — `ToilUserId`, keying your own data on a user, a custom
98
+ user shape / opting out, and the `AuthService` primitive reference.
99
+
100
+ > **One rule before you ship:** built-in auth runs with **insecure DEV fallback secrets** so it Just Works
101
+ > locally. A deployment MUST set `AUTH_SESSION_SECRET`, `AUTH_OPRF_SEED`, and `AUTH_KEM_SK`. See
102
+ > [Configuration](./configuration.md).
@@ -0,0 +1,188 @@
1
+ # Using auth
2
+
3
+ ## 1. Enable it
4
+
5
+ Either the config flag (canonical) or a one-line import — both do the same thing (the build appends the
6
+ framework's auth controller + user shape to your server as compiled entries, so `/auth/*` self-mounts):
7
+
8
+ ```ts
9
+ // toil.config.ts — canonical
10
+ export default defineConfig({ server: { auth: true } });
11
+ ```
12
+
13
+ ```ts
14
+ // server/main.ts — escape hatch (equivalent)
15
+ import 'toiljs/server/auth';
16
+ ```
17
+
18
+ There is also an `AuthService.enable()` no-op you can call for discoverability, but enabling is done by the
19
+ flag/import above (it happens at build time). Turn it off by removing the flag/import — with neither, no
20
+ `/auth` routes and no accounts collection are generated. It is strictly opt-in.
21
+
22
+ ## 2. The client
23
+
24
+ `toiljs/client` ships the browser half — it runs the OPRF blinding, Argon2id, ML-DSA keygen, and ML-KEM
25
+ encapsulation, and talks to `/auth/*`. You never touch the crypto.
26
+
27
+ ```ts
28
+ import { Auth } from 'toiljs/client';
29
+
30
+ // Create an account. Throws on a taken username or a transport error.
31
+ await Auth.register(username, password);
32
+
33
+ // Log in. On success the browser holds the signed session cookie; subsequent
34
+ // requests to @auth routes are authorized automatically.
35
+ await Auth.login(username, password);
36
+ ```
37
+
38
+ Options (second argument, `AuthOptions`):
39
+
40
+ ```ts
41
+ await Auth.login(username, password, {
42
+ baseUrl: '/auth', // default; change if you mount elsewhere
43
+ serverKemPublicKey: MY_KEM_PUB, // REQUIRED in production — pin your deployment's KEM key
44
+ });
45
+ ```
46
+
47
+ > **Production:** the client ships with the DEV KEM public key pinned. A real deployment MUST pass its own
48
+ > `serverKemPublicKey` (derived from `AUTH_KEM_SK`). See [Configuration](./configuration.md).
49
+
50
+ ## 2b. Client-side: who's logged in, and protecting pages
51
+
52
+ `register`/`login` set the session cookies; to *render* login state on the client, use the generated
53
+ `getUser()` (emitted from the built-in `@user`). It reads the **readable** `__Secure-toil_user` companion
54
+ cookie and returns the typed user or `null` — instant, no network call. It is **display-only**; the server
55
+ still enforces access via `@auth`, so never trust it for authorization.
56
+
57
+ ```tsx
58
+ import { getUser } from 'shared/server'; // generated; typed to the built-in @user
59
+
60
+ function Nav() {
61
+ const user = getUser(); // { toilUserId, username } | null
62
+ return user
63
+ ? <span>Hi {user.username} <button onClick={logout}>Log out</button></span>
64
+ : <a href="/login">Log in</a>;
65
+ }
66
+
67
+ async function logout() {
68
+ await fetch('/auth/logout', { method: 'POST' });
69
+ location.href = '/login'; // cookies are cleared; bounce to login
70
+ }
71
+ ```
72
+
73
+ Gate a whole client route by redirecting when there's no session:
74
+
75
+ ```tsx
76
+ export default function Dashboard() {
77
+ const user = getUser();
78
+ if (user == null) { location.href = '/login'; return null; } // not logged in -> to /login
79
+ return <main>Welcome, {user.username}</main>;
80
+ }
81
+ ```
82
+
83
+ The authoritative check is still the server: any data this page fetches should come from an `@auth` route,
84
+ so a user who forged/deleted the readable cookie sees the redirect OR a `401`, never real data.
85
+
86
+ ## 2c. Handling errors
87
+
88
+ `Auth.register` / `Auth.login` reject with a message you can show. The important distinguishable cases:
89
+
90
+ ```ts
91
+ try {
92
+ await Auth.register(username, password);
93
+ } catch (e) {
94
+ const m = String(e);
95
+ if (m.includes('already registered')) setError('That username is taken — log in instead.');
96
+ else setError('Could not register, try again.');
97
+ }
98
+
99
+ try {
100
+ await Auth.login(username, password);
101
+ } catch {
102
+ // Wrong password OR unknown user both fail generically (anti-enumeration) — one message.
103
+ setError('Incorrect username or password.');
104
+ }
105
+ ```
106
+
107
+ - **Username taken** → `register` throws `auth: username already registered (log in instead)` (a
108
+ distinguishable case so you can guide the user).
109
+ - **Wrong password / unknown user** → `login` throws generically (`auth: request failed`) — by design,
110
+ the two are indistinguishable, so use ONE "incorrect username or password" message.
111
+ - **Rate limited** → after 5 attempts / 60s a `429` surfaces as the same generic throw; back off and tell
112
+ the user to wait.
113
+
114
+ ## 3. Guard your routes — `@auth`
115
+
116
+ Put `@auth` on a route method or a whole `@rest` class. The generated dispatcher checks for a valid signed
117
+ session **before** your handler runs and returns `401` otherwise. `@auth` is unchanged by built-in auth —
118
+ it's the same decorator you'd use with hand-written auth.
119
+
120
+ ```ts
121
+ @rest('account')
122
+ class AccountApi {
123
+ @auth // this route needs a session
124
+ @get('/settings')
125
+ public settings(): Response { /* … */ }
126
+
127
+ @get('/public') // this one is open
128
+ public open(): Response { /* … */ }
129
+ }
130
+
131
+ @auth // …or guard the ENTIRE class
132
+ @rest('admin')
133
+ class AdminApi { /* every route requires a session */ }
134
+ ```
135
+
136
+ ## 4. Read the current user
137
+
138
+ Inside any handler:
139
+
140
+ ```ts
141
+ // The typed logged-in user (the built-in `@user`: toilUserId + username), or null.
142
+ const user = AuthService.getUser();
143
+ if (user != null) {
144
+ user.username; // string
145
+ user.toilUserId; // Uint8Array(32) — the stable id bytes
146
+ }
147
+
148
+ // The stable identity as a ToilUserId (gate on hasSession() first — see note).
149
+ if (AuthService.hasSession()) {
150
+ const id = AuthService.userId()!; // ToilUserId
151
+ // key your own data on id (see Extending)
152
+ }
153
+ ```
154
+
155
+ > `ToilUserId` overloads `==`, so `AuthService.userId() == null` does not type-check. Gate with
156
+ > `AuthService.hasSession()` and then use `userId()!`, or use `getUser()` and null-check that.
157
+
158
+ ## 5. The endpoint wire contract
159
+
160
+ You normally use the `Auth` client, but the raw endpoints are binary (`DataWriter`/`DataReader`, never
161
+ JSON):
162
+
163
+ | Route | Request body | Response |
164
+ | --- | --- | --- |
165
+ | `POST /auth/register/start` | `str(username) bytes(blinded)` | `u8(0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)` |
166
+ | `POST /auth/register/finish` | `str(username) bytes(pubkey) bytes(proof)` | `u8(status)` — `0` ok, `1` username taken |
167
+ | `POST /auth/login/start` | `str(username) bytes(blinded)` | `bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) bytes(evaluated)` |
168
+ | `POST /auth/login/finish` | `bytes(cid) bytes(ct) bytes(sig)` | `u8(0) bytes(sessionToken) bytes(serverConfirm)` + `Set-Cookie`, or `u8(≠0)` on failure |
169
+ | `GET /auth/me` *(`@auth`)* | — | `bytes(toilUserId) str(username)` |
170
+ | `POST /auth/logout` *(`@auth`)* | — | `200` + cookie-clearing `Set-Cookie` |
171
+
172
+ Rate limiting: every register/login POST carries `@ratelimit(SlidingWindow, 5, 60)` (5 requests / 60s per
173
+ client) out of the box, so brute-force is throttled before it reaches the crypto.
174
+
175
+ ## 6. Under `toiljs dev`
176
+
177
+ Everything runs locally with **zero setup**: the dev server emulates the ToilDB account/challenge storage
178
+ and the ML-DSA/ML-KEM/OPRF host functions in process, and the auth secrets fall back to insecure DEV
179
+ values. Register and login span requests (the accounts persist for the dev session). You'll see a warning
180
+ that `AUTH_SESSION_SECRET` is unset — that's expected in dev; set it before you deploy (see
181
+ [Configuration](./configuration.md)).
182
+
183
+ ## 7. `Server.REST.auth.*`
184
+
185
+ Because the controller is a normal `@rest('auth')` class, a typed `Server.REST.auth.*` fetch client is
186
+ generated for free (`me`, `logout`, and the register/login methods). Use it for `/me` and `/logout`; but
187
+ **drive register/login through `toiljs/client` `Auth`**, not the generated client — only the `Auth` helper
188
+ runs the required browser-side OPRF/Argon2id/ML-DSA/ML-KEM crypto.
package/docs/auth.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Auth, sessions, and `@user`
2
2
 
3
+ > **Just want login working?** Enable **built-in auth** with one line — `server: { auth: true }` — and
4
+ > you get the whole `/auth/*` API + sessions with no code. Start at **[the auth guide](./auth/index.md)**
5
+ > ([how it works](./auth/how-it-works.md) · [usage](./auth/usage.md) · [configuration](./auth/configuration.md) ·
6
+ > [extending](./auth/extending.md)). This page is the deeper reference on the underlying primitives, used
7
+ > both by built-in auth and when you hand-write your own.
8
+
3
9
  toiljs ships **Toil PQ-Auth**: a post-quantum password login where the password
4
10
  never leaves the browser and the server stores only public verifier material.
5
11
  On top of it sit HMAC-signed session cookies, a `@auth` route guard, and a
@@ -25,8 +25,10 @@ const METRICS = [
25
25
  { id: 13, label: 'DB ops', unit: 'count' },
26
26
  { id: 26, label: 'Stream bytes out', unit: 'bytes' },
27
27
  { id: 39, label: 'Memory bandwidth', unit: 'bytes' },
28
- { id: 41, label: 'Connected streams', unit: 'count' },
29
- { id: 43, label: 'Committed memory', unit: 'bytes' }
28
+ { id: 41, label: 'Cache hits', unit: 'count' },
29
+ { id: 42, label: 'Cache misses', unit: 'count' },
30
+ { id: 43, label: 'Connected streams', unit: 'count' },
31
+ { id: 45, label: 'Committed memory', unit: 'bytes' }
30
32
  ] as const;
31
33
 
32
34
  // AnalyticsRange (mirrors the server enum).
@@ -157,20 +159,28 @@ export default function AnalyticsDemo() {
157
159
 
158
160
  <section style={{ marginTop: 24 }}>
159
161
  <h2>Snapshot</h2>
162
+ <p style={{ color: '#64748b', marginTop: 0 }}>
163
+ All <code>total*</code> values are all-time cumulative totals (since the site was first seen);
164
+ the <code>live</code> gauges are the current level, and the windows are the current rate-limit
165
+ usage vs the plan cap (cap ∞ = unlimited).
166
+ </p>
160
167
  <button onClick={loadStats}>Load my site analytics</button>
161
168
  {err && <p style={{ color: '#f87171' }}>{err}</p>}
162
169
  {stats && (
163
170
  <ul>
164
- <li>requests: {String(stats.requests)}</li>
165
- <li>bytes out (L1): {String(stats.bytesOutL1)}</li>
166
- <li>bytes in (L1): {String(stats.bytesInL1)}</li>
167
- <li>gas used: {String(stats.gasUsed)}</li>
168
- <li>2xx responses: {String(stats.status2xx)}</li>
169
- <li>db ops: {String(stats.dbOps)}</li>
170
- <li>connected streams (live): {String(stats.connectedStreams)}</li>
171
- <li>committed memory (live): {fmt(Number(stats.committedMemory), 'bytes')}</li>
172
- <li>requests this minute: {cap(stats.reqMinuteUsed, stats.reqMinuteCap)}</li>
173
- <li>requests today: {cap(stats.reqDayUsed, stats.reqDayCap)}</li>
171
+ <li>total requests: {String(stats.totalRequests)}</li>
172
+ <li>total bytes out (L1): {String(stats.totalBytesOutL1)}</li>
173
+ <li>total bytes in (L1): {String(stats.totalBytesInL1)}</li>
174
+ <li>total gas used: {String(stats.totalGasUsed)}</li>
175
+ <li>total 2xx responses: {String(stats.totalStatus2xx)}</li>
176
+ <li>total db ops: {String(stats.totalDbOps)}</li>
177
+ <li>total cache hits: {String(stats.totalCacheHits)}</li>
178
+ <li>total cache misses: {String(stats.totalCacheMisses)}</li>
179
+ <li>cache hit ratio: {(stats.cacheHitRatio * 100).toFixed(1)}%</li>
180
+ <li>connected streams (live): {String(stats.liveConnectedStreams)}</li>
181
+ <li>committed memory (live): {fmt(Number(stats.liveCommittedMemoryBytes), 'bytes')}</li>
182
+ <li>requests this minute: {cap(stats.requestsThisMinute, stats.requestsThisMinuteCap)}</li>
183
+ <li>requests today: {cap(stats.requestsToday, stats.requestsTodayCap)}</li>
174
184
  </ul>
175
185
  )}
176
186
  </section>