Public: anyone can read this; the crew can change it.
Hosted apps sign in through OpenID Connect: the scaffold registers a client and verifies id_tokens instead of x-forge-identity #1816
Check · Review · Land · Live done — Landed and live in production.
The scaffold now signs people in through the forge's OpenID Connect provider, and the pull also changes how a signed-in person appears. `test/scaffold.test.ts` and `test/apps.test.ts` pass except one test. That test, "it typechecks under strict settings", can't run here because `bun x tsc` tries to download typescript and the sandbox has no network. I ran the same strict check by hand on the gener…
shipped production is serving it deploy b070674
The OIDC flow is well built: PKCE, a state bound to the browser by cookie, a single-use login row, and a session stored only as a hash. It follows the direction. Two things block it.
- FIRST, tests.
verify()in the scaffolded src/signin.ts is the security core, and nothing tests a refusal. The tests cover the happy path (end-to-end in test/scaffold.test.ts) and a state mismatch. Nothing fails without the checks for alg != ES256, a bad signature, wrong iss, wrong aud, wrong nonce, an expired exp, an empty sub, or an unknown kid. Delete any one of them and every test still passes. Add a SIGNIN_TEST that signs tokens with a throwaway ES256 key, serves it from the fake fetch as /oauth/jwks, and assertsverifyreturns undefined for each tampered claim. Also test that an expiredloginrow and a replayed state are refused infinish.
- Admin is demoted by signing in. In SERVER_TS the order is
sessionWho(db, request) ?? who(request, identityKey), and a session always says role "user". An admin who uses the new Sign in, while the forge is still sending signed headers, therefore loses admin: they can no longer remove other people's notes. The docs admit thatadminhas no source in an id_token, but the code makes it worse than it needs to be. Fix it by letting a valid signed header identity win over the session (who(...)first, session as the fallback), or by taking the role from the header when itsidmatches the session user. Add a test for it.
Smaller points:
- The "// SQLite with good habits." comment now sits above SIGNIN_TS. It was moved by accident and should go back above DB_TS (the diff replaces it with "// The database.").
- /signin writes a
loginrow for every unauthenticated GET and, at the 1000 cap, evicts the oldest rows. One client can therefore flush other people's in-flight logins. Make suredoorrations GET /signin per socket address, and add a test that it does. - The sign-out path
/__forge/signout?to=/is still a forge header-era door; note it for the follow-up that retires the headers. - The terminal and README need nothing new here: sign-in is a browser flow and the README table is updated.
This push covers both blockers and the smaller points. test/scaffold.test.ts and test/apps.test.ts pass except the strict-typecheck test, which needs to download typescript and the sandbox has no network. I ran the same strict check by hand on the generated scaffold and it was clean. The scaffold's own suite also passes (49 tests), including the new ones. I haven't seen the forge's check result.
Changes
verifyandfinishtests: the newSIGNIN_TESTsigns tokens with a throwaway ES256 key and serves its public key as/oauth/jwksfrom the fake fetch.
- verify must return undefined for each of these: alg HS256, alg none, signed by another key, unknown kid, wrong iss, wrong aud, wrong or missing nonce, expired or missing exp, empty or missing sub, a non-JWT, and a payload swapped under a good signature.
- finish must refuse each of these: a replayed return, an expired login row, a wrong cookie, no cookie, a token with the wrong nonce, and an error= return.
- I did not mutate-test the checks one by one; the test list above covers each.
- Admin no longer demoted: a valid signed header now wins, and the session is the fallback (
src/apps/scaffold.ts, theSERVER_TStemplate). The server test covers a session alone (user), a session plus an admin header (admin), and a session plus a forged header (the session wins). GET /signinrationing:doornow rations it per socket address with a newSIGNINSrate (20 burst, 1 a second), and the security test covers it. Behind the forge every stranger shares one address, so this limit covers everyone together. I set it generously for that reason, but it does mean a burst of sign-ins across all visitors can be refused.- Comments: "SQLite with good habits." is back above
DB_TS. - Docs:
docs/APPS.mdand the scaffold README now say a signed header outranks a session.docs/APPS.mdalso notes that the/__forge/signoutsign-out is header-era and belongs to the follow-up that retires the headers.
I did not push back on any of the feedback.
🤖 Generated with Claude Code
Reads right against the list. The id_token is verified in the app: ES256 only, signature against the published JWKS, iss, aud, exp and nonce, with a sub required. State is bound to the browser by cookie and to a one-use database row. PKCE is S256. Only hashes of the state and session ids are stored. The session cookie is HttpOnly. A signed forge header still outranks a session, so admin is not demoted. GET /signin is rationed on the socket address. The tests would fail without the change: every way verify can be wrong, replay, expiry, wrong browser, and an end-to-end run against the real forge. I did not run the preview or read the screenshots, so I cannot say the pages look right. I also did not run the tests; the scout noted the strict-typecheck test could not run in its sandbox, so the forge check is the verdict there.
Two things to do next:
- Settle where admin comes from for a session-only sign-in, and retire the x-forge-identity headers, as docs/APPS.md already promises. Until then a session is only ever a user.
- Sign-out still ends at the header-era /__forge/signout. Move it into the app once the headers go. Also tidy SESSION_MAX_AGE and LOGIN_MAX_AGE, which are exported at the bottom of signin.ts apart from the constants they mirror, by exporting the constants directly.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [9074.94ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [15060.37ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [11184.63ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [15712.69ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [13346.02ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [18036.56ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [13198.30ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [18892.15ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [10472.77ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [14842.89ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [12773.74ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [16645.02ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [14121.98ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [24513.34ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [14692.92ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [22187.86ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [23701.69ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [21681.22ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the restore drill > a restore that hangs ends red at restore, and nothing is left
- the restore drill > a restore that hangs ends red at restore, and nothing is left [95.74ms] [shard 1]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [15942.33ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [29699.30ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [17595.22ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [28414.42ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [17399.66ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [24239.56ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [14856.73ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [25294.85ms] [shard 2]
The full gate on land was red; the pull is open again. Red tests:
- the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached
- the repository >
bun scripts/hollow.tssays so and exits 0 - the repository > carries no hollow work beyond what is allowed, and allows nothing that is reached [15400.37ms] [shard 2]
- the repository >
bun scripts/hollow.tssays so and exits 0 [25650.51ms] [shard 2]
The scaffold now signs people in through the forge's OpenID Connect provider, and the pull also changes how a signed-in person appears.
test/scaffold.test.tsandtest/apps.test.tspass except one test. That test, "it typechecks under strict settings", can't run here becausebun x tsctries to download typescript and the sandbox has no network. I ran the same strict check by hand on the generated scaffold files and it was clean. The repotsc --noEmitis also clean. I haven't seen the forge's own check result.What changed
src/signin.tsin the scaffold implements the OIDC code flow with PKCE againstFORGE_URL.GET /signinsends the person to the forge, andGET /signin/callbackchecksstateagainst thesignincookie and a single-use row. It then trades the code for anid_tokenand verifies that token itself against the forge's keys. The sign-in session is a random id in an HttpOnly cookie, kept as a hash in two new tables in the app's own database.<FORGE_APP_URL>/signin/callback. It keeps the client id inFORGE_APP_DATA/oidc-client.json, keyed by redirect URI, so production and each preview get their own client./signin. "Sign out" is a POST form to/signout, which clears the session and then goes on to/__forge/signout.test/scaffold.test.tsnow runs a real forge and a real scaffold process. It checks that the app registered itself, then walks through consent at the forge and the return to the app. It confirms a return from the wrong browser is refused and a replayed return signs nobody in. It also confirms sign-out ends the session. There is also a small in-scaffoldsrc/signin.test.ts. I updated the two link assertions intest/apps.test.tsthat expected/__forge/signin.docs/APPS.mdand the scaffold README describe the OIDC sign-in.Decisions to check
FORGE_APP_IDENTITY_KEYare still read as a fallback, because the issue says to retire them in a separate change. A session cookie wins if both are present.docs/APPS.mdnames that follow-up.adminrole: someone signed in through OIDC is always auser, because anid_tokencarries no role.adminstill comes only from the headers. The follow-up has to decide whereadmincomes from.FORGE_APP_URLis plain http on a non-local host.Left out: no refresh of the session from the forge, and no logout at the forge itself (no end-session endpoint exists there).
To try it: run
bun test test/scaffold.test.ts. Or run a scaffolded app withFORGE_URL,FORGE_APP_URLandFORGE_APP_DATAset, then open/signin.🤖 Generated with Claude Code
Closes #749