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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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 generated scaffold files and it was clean. The repo tsc --noEmit is also clean. I haven't seen the forge's own check result.

What changed

  • Sign-in flow: the new src/signin.ts in the scaffold implements the OIDC code flow with PKCE against FORGE_URL. GET /signin sends the person to the forge, and GET /signin/callback checks state against the signin cookie and a single-use row. It then trades the code for an id_token and 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.
  • Registration: on first boot, or at the first sign-in if the forge isn't up yet, the app registers <FORGE_APP_URL>/signin/callback. It keeps the client id in FORGE_APP_DATA/oidc-client.json, keyed by redirect URI, so production and each preview get their own client.
  • Page: "Sign in" now links to /signin. "Sign out" is a POST form to /signout, which clears the session and then goes on to /__forge/signout.
  • Tests: test/scaffold.test.ts now 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-scaffold src/signin.test.ts. I updated the two link assertions in test/apps.test.ts that expected /__forge/signin.
  • Docs: docs/APPS.md and the scaffold README describe the OIDC sign-in.

Decisions to check

  • Headers kept: the identity headers and FORGE_APP_IDENTITY_KEY are 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.md names that follow-up.
  • admin role: someone signed in through OIDC is always a user, because an id_token carries no role. admin still comes only from the headers. The follow-up has to decide where admin comes from.
  • Leftover forge token: the forge's consent page always mints a forge access token, which shows in the person's Settings. The app discards it, but the person sees it and can revoke it.
  • Redirect URI limits: http redirect URIs are only accepted for localhost, so the forge rejects the registration for any app whose FORGE_APP_URL is 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 with FORGE_URL, FORGE_APP_URL and FORGE_APP_DATA set, then open /signin.

🤖 Generated with Claude Code

Closes #749

Changes requested — before the latest push

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.

  1. 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 asserts verify returns undefined for each tampered claim. Also test that an expired login row and a replayed state are refused in finish.
  1. 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 that admin has 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 its id matches 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 login row 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 sure door rations 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.
commented

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

  • verify and finish tests: the new SIGNIN_TEST signs tokens with a throwaway ES256 key and serves its public key as /oauth/jwks from 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, the SERVER_TS template). 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 /signin rationing: door now rations it per socket address with a new SIGNINS rate (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.md and the scaffold README now say a signed header outranks a session. docs/APPS.md also notes that the /__forge/signout sign-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

Approved — before the latest push

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:

  1. 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.
  2. 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.
commented

The full gate on land was red and named no test; the pull is open again.

commented

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.ts says 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.ts says so and exits 0 [15060.37ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [15712.69ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [18036.56ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [18892.15ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [14842.89ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [16645.02ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [24513.34ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [22187.86ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [21681.22ms] [shard 2]
commented

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]
commented

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.ts says 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.ts says so and exits 0 [29699.30ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [28414.42ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [24239.56ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [25294.85ms] [shard 2]
commented

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.ts says 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.ts says so and exits 0 [25650.51ms] [shard 2]
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs