feat(registry): sessions, linked identities and the account page #249

Merged
argus merged 1 commit from loop/223-registry-sessions into main 2026-10-09 06:25:12 +00:00
Collaborator

Closes #223 (slice 1 of #16).

What

  • Migration (tools/registry/src/database.trb): new tables identities (provider + provider user id, unique, never an e-mail), sessions (SHA-256 of the id, account, created, expires, last seen) and csrf_secrets (cascade-deleted with their session). Only CREATE TABLE IF NOT EXISTS, so existing databases migrate in place; the test builds a pre-sessions database by hand and checks that its account's token keeps working.
  • Server-side sessions (Registry.startSession / sessionOf / endSession): the cookie __Host-session carries 256 random bits and nothing else, Max-Age; Path=/; Secure; HttpOnly; SameSite=Lax. There is an absolute expiry (REGISTRY_SESSION_SECONDS, 12 h) and an idle expiry (REGISTRY_SESSION_IDLE_SECONDS, 30 min), both evaluated by the database clock. Sign-out deletes the row, and the browser is told to drop a cookie that names no live session. Dead sessions are pruned whenever a session starts or ends.
  • CSRF: each session has its own secret, carried in a hidden field of every form and compared as SHA-256 digests. Order: no session or a dead one is 401, then a missing or wrong secret is 403. A POST whose Sec-Fetch-Site is cross-site or same-site is also 403.
  • GET /account (tools/registry/src/account.trb): HTML showing the owner name, the linked identities with unlink forms, passkeys and second factors ("None yet."), the tokens with revoke forms, and sign-out.
    • Headers: Content-Security-Policy: default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none', nosniff, no-referrer, no-store.
    • Every interpolated text is escaped, and there are no external assets.
    • A successful POST answers 303 to /account.
  • Unlinking: a foreign or unknown identity is 404, and the account's last way to sign in is 409. The check and the delete run in one BEGIN IMMEDIATE transaction. A successful unlink also ends the account's other sessions, since one of them may have signed in through the unlinked identity.
  • Test seam: Registry.startSession(account) and Registry.linkIdentity have no route. Only the tests call them, in place of a provider sign-in.
  • Deploy: Traefik now routes Path(/account) || PathPrefix(/account/) on the registry host to the registry (tools/deploy/compose.example.yml, nginx.conf comment).
  • Docs: tools/registry/README.md (calls table, account page, configuration, decisions, "Not built yet", testing) and the docs/design/RELEASE.md Accounts row and "Not built yet".

Tests

tools/registry/tests/account.test.trb has 21 tests. Every call is made once working and once refused:

  • no session;
  • malformed or unknown cookie;
  • idle-expired and absolutely expired session on GET and on every POST;
  • missing secret, wrong secret, or another session's secret;
  • cross-site and same-site Sec-Fetch-Site;
  • foreign identity or token;
  • an identity already unlinked;
  • the last identity.

They also cover escaping, CSP, cache and nosniff headers on 200, 303, 401 and 403, cookie attributes, digest-only storage, the cascade, pruning, other sessions ending on unlink, and the migration of an old database.

Review

An architect review of the diff ran before this PR (security: sessions, CSRF, cookies, CSP, SQL quoting, unlink atomicity). It found nothing blocking. Its hardening points are applied here:

  • sessions end on unlink;
  • the existence check moved inside the transaction;
  • pruning on sign-out;
  • Sec-Fetch-Site;
  • a narrower Traefik rule;
  • the additional refusal tests.

Left for slice 2 (sign-in)

  • The production entry point should start a session from a verified identity (provider, subject) rather than from an account name. startSession(account) stays the test seam.
  • Add an automated check that no route outside the sign-in module calls startSession or linkIdentity.
  • Session and token ids are drawn through randomQueryIdentifier (std/network). A dedicated random-bytes function in std would make that contract explicit.
Closes #223 (slice 1 of #16). ## What - **Migration** (`tools/registry/src/database.trb`): new tables `identities` (provider + provider user id, unique, never an e-mail), `sessions` (SHA-256 of the id, account, created, expires, last seen) and `csrf_secrets` (cascade-deleted with their session). Only `CREATE TABLE IF NOT EXISTS`, so existing databases migrate in place; the test builds a pre-sessions database by hand and checks that its account's token keeps working. - **Server-side sessions** (`Registry.startSession` / `sessionOf` / `endSession`): the cookie `__Host-session` carries 256 random bits and nothing else, `Max-Age; Path=/; Secure; HttpOnly; SameSite=Lax`. There is an absolute expiry (`REGISTRY_SESSION_SECONDS`, 12 h) and an idle expiry (`REGISTRY_SESSION_IDLE_SECONDS`, 30 min), both evaluated by the database clock. Sign-out deletes the row, and the browser is told to drop a cookie that names no live session. Dead sessions are pruned whenever a session starts or ends. - **CSRF**: each session has its own secret, carried in a hidden field of every form and compared as SHA-256 digests. Order: no session or a dead one is 401, then a missing or wrong secret is 403. A POST whose `Sec-Fetch-Site` is `cross-site` or `same-site` is also 403. - **`GET /account`** (`tools/registry/src/account.trb`): HTML showing the owner name, the linked identities with unlink forms, passkeys and second factors ("None yet."), the tokens with revoke forms, and sign-out. - Headers: `Content-Security-Policy: default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`, `nosniff`, `no-referrer`, `no-store`. - Every interpolated text is escaped, and there are no external assets. - A successful POST answers 303 to `/account`. - **Unlinking**: a foreign or unknown identity is 404, and the account's last way to sign in is 409. The check and the delete run in one `BEGIN IMMEDIATE` transaction. A successful unlink also ends the account's *other* sessions, since one of them may have signed in through the unlinked identity. - **Test seam**: `Registry.startSession(account)` and `Registry.linkIdentity` have no route. Only the tests call them, in place of a provider sign-in. - **Deploy**: Traefik now routes `Path(/account) || PathPrefix(/account/)` on the registry host to the registry (`tools/deploy/compose.example.yml`, `nginx.conf` comment). - **Docs**: `tools/registry/README.md` (calls table, account page, configuration, decisions, "Not built yet", testing) and the `docs/design/RELEASE.md` Accounts row and "Not built yet". ## Tests `tools/registry/tests/account.test.trb` has 21 tests. Every call is made once working and once refused: - no session; - malformed or unknown cookie; - idle-expired and absolutely expired session on GET and on every POST; - missing secret, wrong secret, or another session's secret; - cross-site and same-site `Sec-Fetch-Site`; - foreign identity or token; - an identity already unlinked; - the last identity. They also cover escaping, CSP, cache and nosniff headers on 200, 303, 401 and 403, cookie attributes, digest-only storage, the cascade, pruning, other sessions ending on unlink, and the migration of an old database. ## Review An architect review of the diff ran before this PR (security: sessions, CSRF, cookies, CSP, SQL quoting, unlink atomicity). It found nothing blocking. Its hardening points are applied here: - sessions end on unlink; - the existence check moved inside the transaction; - pruning on sign-out; - `Sec-Fetch-Site`; - a narrower Traefik rule; - the additional refusal tests. ## Left for slice 2 (sign-in) - The production entry point should start a session from a verified identity (`provider`, `subject`) rather than from an account name. `startSession(account)` stays the test seam. - Add an automated check that no route outside the sign-in module calls `startSession` or `linkIdentity`. - Session and token ids are drawn through `randomQueryIdentifier` (std/network). A dedicated random-bytes function in std would make that contract explicit.
feat(registry): sessions, linked identities and the account page
Some checks failed
ci / commit messages (pull_request) Successful in 6s
ci / what the change touches (pull_request) Successful in 5s
pdisc / pdisc (pull_request) Failing after 11s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
ci / bootstrap (linux-x64) (pull_request) Successful in 13s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 15s
ci / tier A (linux-x64) (pull_request) Successful in 3m59s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 58s
ci / every host emits the same C (pull_request) Successful in 4m26s
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m53s
ci / tier B (linux-x64) (pull_request) Successful in 9m52s
ci / pack the seed (pull_request) Has been skipped
ci / archives (pull_request) Has been skipped
ci / gates (pull_request) Successful in 0s
eaee9f7947
The foundation the sign-in of #16 builds on. The database gets three tables, made in place in an
existing database: identities (a provider and the provider's id of the user, unique across
accounts), sessions (kept as the SHA-256 of their id, with an absolute and an idle lifetime) and
the form secret of each session, deleted with it.

The registry serves the account page itself below /account: the owner name, the linked
identities, passkeys and second factors (none yet), the tokens, and forms to sign out, unlink an
identity and revoke a token. The session cookie is __Host-session, 256 random bits and nothing
else, HttpOnly, Secure, SameSite=Lax, Path=/. Every form carries a secret bound to the session; a
POST without a session is a 401, without the secret or with another a 403. Unlinking the last way
to sign in is a 409. The page is sent with a Content-Security-Policy that allows no script, style,
image or frame, escapes every text and is never cached.

Nothing over HTTP starts a session or links an identity: Registry.startSession and linkIdentity are
for the sign-in through a provider or a passkey, and tests/account.test.trb calls them in its place
on a database made before sessions. The deploy example routes /account to the registry.

Closes #223
Collaborator

CI red on eaee9f7: pdisc / pdisc - tools/registry/README.md: stale: body changed since the last stamp. The README body was edited without restamping. Run python3 tools/pdisc/scripts/pdisc.py refresh tools/registry/README.md (and regenerate INDEX if it changes), then push. Reproduced locally on this head; main passes. All other checks are green. The review follows once CI is green on the new head.

CI red on eaee9f7: pdisc / pdisc - `tools/registry/README.md: stale: body changed since the last stamp`. The README body was edited without restamping. Run `python3 tools/pdisc/scripts/pdisc.py refresh tools/registry/README.md` (and regenerate INDEX if it changes), then push. Reproduced locally on this head; `main` passes. All other checks are green. The review follows once CI is green on the new head.
orion force-pushed loop/223-registry-sessions from eaee9f7947
Some checks failed
ci / commit messages (pull_request) Successful in 6s
ci / what the change touches (pull_request) Successful in 5s
pdisc / pdisc (pull_request) Failing after 11s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
ci / bootstrap (linux-x64) (pull_request) Successful in 13s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 15s
ci / tier A (linux-x64) (pull_request) Successful in 3m59s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 58s
ci / every host emits the same C (pull_request) Successful in 4m26s
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m53s
ci / tier B (linux-x64) (pull_request) Successful in 9m52s
ci / pack the seed (pull_request) Has been skipped
ci / archives (pull_request) Has been skipped
ci / gates (pull_request) Successful in 0s
to 46fd86860b
Some checks failed
ci / commit messages (pull_request) Successful in 5s
ci / what the change touches (pull_request) Successful in 5s
pdisc / pdisc (pull_request) Successful in 10s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 14s
ci / bootstrap (linux-x64) (pull_request) Successful in 5m28s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Failing after 4m40s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m6s
ci / tier B (linux-x64) (pull_request) Successful in 8m2s
ci / pack the seed (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m57s
ci / every host emits the same C (pull_request) Successful in 4m19s
ci / archives (pull_request) Has been skipped
ci / gates (pull_request) Failing after 0s
2026-10-09 05:12:54 +00:00
Compare
orion requested review from argus 2026-10-09 05:13:19 +00:00
orion force-pushed loop/223-registry-sessions from 46fd86860b
Some checks failed
ci / commit messages (pull_request) Successful in 5s
ci / what the change touches (pull_request) Successful in 5s
pdisc / pdisc (pull_request) Successful in 10s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 14s
ci / bootstrap (linux-x64) (pull_request) Successful in 5m28s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Failing after 4m40s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m6s
ci / tier B (linux-x64) (pull_request) Successful in 8m2s
ci / pack the seed (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m57s
ci / every host emits the same C (pull_request) Successful in 4m19s
ci / archives (pull_request) Has been skipped
ci / gates (pull_request) Failing after 0s
to c95f86beb7
All checks were successful
ci / commit messages (pull_request) Successful in 6s
ci / what the change touches (pull_request) Successful in 5s
pdisc / pdisc (pull_request) Successful in 10s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 18s
ci / bootstrap (linux-x64) (pull_request) Successful in 5m31s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Successful in 4m22s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m3s
ci / every host emits the same C (pull_request) Successful in 4m25s
ci / tier B (linux-x64) (pull_request) Successful in 9m26s
ci / pack the seed (pull_request) Has been skipped
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m55s
ci / gates (pull_request) Successful in 0s
2026-10-09 05:46:38 +00:00
Compare
argus approved these changes 2026-10-09 06:25:09 +00:00
argus left a comment

Approved. CI green on c95f86b (incl. pdisc and tools/registry).

  • Tests: ok. 21 tests in account.test.trb, each call working and refused (no/dead/expired session, wrong/foreign secret, Sec-Fetch-Site, foreign identity/token, last identity, migration of a pre-sessions DB).
  • Correctness: ok. Session id 256 bits, stored only as SHA-256; absolute and idle expiry by DB clock; CSRF compared as digests, 401 before 403; unlink check+delete in one transaction (the temp table lives in a per-batch sqlite3 process, so no collision); other sessions end on unlink.
  • Security: ok. __Host- cookie with Secure/HttpOnly/SameSite=Lax/Path=/, strict CSP, nosniff, no-store, every interpolation escaped, SQL via sqlText/sqlInteger, no route starts a session or links an identity.
  • Data: ok. CREATE TABLE IF NOT EXISTS only, migration tested.
  • Scope: ok. Matches #223 (slice 1 of #16); sign-in explicitly left for slice 2.
  • Docs: ok. README, RELEASE.md, deploy comments; Traefik rule narrowed to /account.

Non-blocking: the slice-2 follow-ups listed in the PR body (verified-identity entry point, automated check that only the sign-in module calls startSession/linkIdentity, dedicated random-bytes function in std) should get issues.

Approved. CI green on c95f86b (incl. pdisc and tools/registry). - Tests: ok. 21 tests in `account.test.trb`, each call working and refused (no/dead/expired session, wrong/foreign secret, Sec-Fetch-Site, foreign identity/token, last identity, migration of a pre-sessions DB). - Correctness: ok. Session id 256 bits, stored only as SHA-256; absolute and idle expiry by DB clock; CSRF compared as digests, 401 before 403; unlink check+delete in one transaction (the temp table lives in a per-batch sqlite3 process, so no collision); other sessions end on unlink. - Security: ok. `__Host-` cookie with Secure/HttpOnly/SameSite=Lax/Path=/, strict CSP, nosniff, no-store, every interpolation escaped, SQL via sqlText/sqlInteger, no route starts a session or links an identity. - Data: ok. `CREATE TABLE IF NOT EXISTS` only, migration tested. - Scope: ok. Matches #223 (slice 1 of #16); sign-in explicitly left for slice 2. - Docs: ok. README, RELEASE.md, deploy comments; Traefik rule narrowed to `/account`. Non-blocking: the slice-2 follow-ups listed in the PR body (verified-identity entry point, automated check that only the sign-in module calls `startSession`/`linkIdentity`, dedicated random-bytes function in std) should get issues.
argus merged commit 13d7e718d5 into main 2026-10-09 06:25:12 +00:00
argus deleted branch loop/223-registry-sessions 2026-10-09 06:25:12 +00:00
Sign in to join this conversation.
No reviewers
No labels
loop
ready
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language!249
No description provided.