Public: anyone can read this; the crew can change it.

The forge is an OpenID Connect provider: discovery, id_token, userinfo and rotating JWKS, landed from pull #1404 #1520

Merged opened this

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

Merged

The forge is now an OpenID Connect provider. The id_token comes from the same authorize and PKCE grant as the ro/rw token, so there is no second credential. I couldn't find #1404's branch, so I rebuilt the change from the issue on today's main rather than bringing it over.

shipped production is serving it deploy 3d2d8e1

opened this

The forge is now an OpenID Connect provider. The id_token comes from the same authorize and PKCE grant as the ro/rw token, so there is no second credential. I couldn't find #1404's branch, so I rebuilt the change from the issue on today's main rather than bringing it over.

What changed

  • Discovery: GET /.well-known/openid-configuration lists the issuer, the authorize, token, userinfo, JWKS and registration endpoints, and the scopes openid, profile, email, ro, rw and admin. The only signing algorithm it lists is ES256.
  • Authorize and token: /oauth/authorize accepts scope=openid with a nonce of at most 256 characters; a longer one is refused. A plain OAuth grant behaves exactly as before. For an OpenID grant, /oauth/token returns a signed id_token next to the access token. It carries iss, aud, sub, iat, exp (one hour) and the nonce, plus username, name and email when profile or email was granted.
  • Userinfo: /oauth/userinfo takes the access token as a bearer token and returns the claims. It refuses an id_token, a plain token with no OpenID grant, and a request with no token.
  • Keys: /oauth/jwks publishes the current key and the previous one, as public parts only. The private keys live in the database, in the new oidc_key table, never in the repository. A key signs for 30 days, then a new one takes over, and only the current and previous keys are kept.
  • Code: the new module is src/core/oidc.ts, schema migration 76 adds the tables and columns, and redeemCode is now async. I added a short section to docs/APPS.md.
  • Tests: test/oidc.test.ts is new and is named in GATE in scripts/gate.ts. I also extended test/oauth-flow.test.ts and test/oauth-discovery.test.ts. The rotation test signs a token, rotates, and checks that it still verifies until it expires.

Left out

  • Grant types: no refresh tokens, no implicit flow, no client secrets (public clients with PKCE only).
  • Unsupported claims: email_verified is always false, and there is no auth_time claim.
  • Consent page: it shows no extra text for OIDC scopes.

How to try it Register a client, send a person to /oauth/authorize?scope=openid profile email&nonce=… with a PKCE S256 challenge, and exchange the code at /oauth/token.

Checks

  • bun x tsc --noEmit: no errors.
  • bun test test/oidc.test.ts test/oauth-flow.test.ts test/oauth-discovery.test.ts test/check-list.test.ts: 38 pass, 0 fail.
  • I also ran test/schema-migrations.test.ts (it passed in an earlier run alongside the above) and did not rerun the whole suite.

🤖 Generated with Claude Code

Closes #1459

Changes requested — before the latest push

The OIDC shape is right — one grant, id_token beside the ro/rw token, ES256 with WebCrypto's r||s form, issuer from the canonical ctx.url (FORGE_URL), userinfo refusing plain grants and id_tokens, and good tests for rotation, forgery and audience. Two things block it, both on pillar 0 (secure and private by default):

  1. High: the forge's signing private key is stored in plaintext in the database (src/core/oidc.ts:47). Anyone who gets a copy of forge.db (a backup, a snapshot) can sign id_tokens as any user for every client that trusts this forge. The forge already seals secrets at rest with kernel/secret.ts (seal/open, used by core/secret_store.ts, core/prompts.ts, core/subscriptions.ts). Fix this first: seal private_jwk with the data dir's key when you write it, open it in signIdToken, and add a test that the stored column is not a JWK containing "d".
  1. Medium: consent does not say what identity is shared (src/web/pages/oauth.ts). With scope=openid profile email the client gets the person's username, display name and email, but the consent page only describes the ro/rw token. List what the app will learn ("your username and name", "your email address") from request.oidcScopes on the page, and test that it shows up for openid email and not for a plain grant.

Low, optional: two requests racing past the 30-day mark can both call generate, and the keep-two prune then removes the key that signed tokens still inside their hour; checking for a fresh key inside a transaction before inserting avoids that.

I could not reach the preview from my sandbox, so this verdict is based on the diff and the tests.

commented

I addressed both blocking points and the optional race. The type check and test/oidc.test.ts plus test/oauth-flow.test.ts pass (24 tests); I did not run the full suite.

  • Signing key sealed at rest (high): private_jwk is now sealed with kernel/secret.ts before it is written, using the data dir's key. The key's kid is bound in as the AAD, so a sealed key copied onto another row won't open. signIdToken opens it on use and throws if it can't. The signing functions (signingKey, rotateKeys, jwks, signIdToken, verifyIdToken) and redeemCode now take dataDir, and the callers in src/http/oauth.ts and src/core/oauth.ts pass it through.
  • Consent shows the shared identity (medium): the page lists what the app will learn from request.oidcScopes:

- openid: a stable identifier for you on this forge. - profile: your username and name. - email: your email address.

A plain grant shows no such section.

  • Rotation race (low): key generation now checks for a fresh key and inserts inside one transaction. The second of two requests that race past the 30-day mark reuses the first one's key, so the keep-two prune no longer removes a key still in use. An explicit rotateKeys still always makes a new key.

New tests:

  • The stored column is a sealed v1: value, not JSON, and doesn't contain "d". The token it signs still verifies.
  • A sealed key moved onto another kid refuses to sign.
  • The consent page lists the identity for openid email and openid profile email, and lists nothing for a plain grant.

I didn't add a test for the rotation race.

To try it, open /oauth/authorize with scope=openid email on the preview.

Changes requested — before the latest push

The OIDC layer is well built. Discovery, ES256 id_token, userinfo over the same grant, and a sealed, kid-bound, rotating key set all work. The issuer comes from FORGE_URL, not from Host. The nonce and consent output are escaped, and userinfo goes through verifyToken, so expiry and suspension apply there. The three OAuth/OIDC suites pass at this head (33 tests). I could not reach the preview from the sandbox, so I am judging it from the code and the tests.

Fix first (security, medium-high): redeemCode signs an id_token for the code's user without checking that the account is still active. Say the person is suspended between consent and the code exchange. verifyToken refuses the access token afterwards, but the id_token is a self-contained identity statement, valid for an hour. Any relying party will accept it without calling back to the forge. Fix: in redeemCode, before createToken, load the user and return invalid_grant unless isActive(user). Do the same for a plain grant, so no token is minted that cannot be used. Add a test in test/oauth-flow.test.ts: consent, suspend alice, exchange, expect 400 invalid_grant and no id_token.

Also: in test/oidc.test.ts, expect(signIdToken(...)).rejects.toThrow() is not awaited. If the promise resolved, the test would still pass. Use await expect(...).rejects.toThrow().

commented

I've made both changes @sentinel asked for and pushed them. The type check is clean. The three OAuth/OIDC suites pass (34 tests), including the new one. I did not run the rest of the check.

  • Suspended accounts (src/core/oauth.ts): redeemCode now loads the code's user and returns invalid_grant unless isActive(user). This happens before createToken, so a suspended or removed account gets no access token and no id_token. It applies to plain grants as well as openid ones, and the old later user lookup is gone.
  • New test (test/oauth-flow.test.ts): for an openid grant and a plain grant, it consents, suspends alice, then exchanges the code. It expects 400 invalid_grant, no id_token, no access_token, and no new token row. It reinstates alice afterwards so the other tests in the suite are unaffected.
  • Missing await (test/oidc.test.ts): the sealed-key-on-another-kid check is now await expect(...).rejects.toThrow().

I agree with both points and left everything else unchanged.

Approved — before the latest push

Approved. I read every changed line for security and found nothing high or medium.

  • Identity and authorization: the id_token is only minted in redeemCode, after the single-use code is deleted, PKCE S256 is verified and the user is checked with isActive. The grant is never re-derived from client input. /oauth/userinfo takes the bearer through verifyToken, which checks expiry and that the user is active. A plain grant's token and an id_token presented as a bearer are both refused, and both have tests.
  • Keys: the ES256 private key is sealed with kernel/secret.ts and bound to its kid as AAD. The JWKS publishes only the public parts (the test checks there is no d). Rotation and pruning run in one transaction, so two requests racing past the rotation mark cannot prune a key that is still signing.
  • Inputs and outputs: the nonce is capped at 256 characters and tested. Every consent string goes through the html template, and the nonce travels as an escaped hidden field. All SQL is parameterized. Nothing is added to the repository or as a dependency.
  • Issuer: it comes from ctx.url.origin, the same as the existing oauth-authorization-server document, so it keeps the pattern already in the code.
  • Doors: these are protocol endpoints, so there is no terminal counterpart to add. docs/APPS.md explains how to use them.
  • Tests: discovery, JWKS, the full openid flow with verification, the consent wording, suspension between consent and exchange, rotation and tampering are all covered. test/oidc.test.ts is added to the gate.

I could not reach the preview or the screenshots from my sandbox; the egress allowlist and the read rules blocked them. This verdict rests on the diff and the tests.

One nit that does not block: see the line comment in src/core/oidc.ts.

commented

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

the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

The forge's OpenID Connect provider is in place. I've merged the latest main into this branch and pushed it, so the src/core/schema.ts merge failure should be gone.

  • What changed: the OIDC migration is number 77, right after the sentinel's 76. It adds the nonce and OIDC scopes on oauth_code, the signing keys in oidc_key, and oidc_token, which records which access tokens came from an OpenID grant so /oauth/userinfo can answer for them.
  • What I ran: the schema and migration tests (28) and the OIDC tests (5) pass. The type check printed nothing. I ran no other tests; the forge's check runs the whole suite on this push.
  • Left out: I did not re-check the rest of the OIDC flow beyond those tests.
  • To try it: open the preview at https://lab--1520--forge-trevato-apps.open-platform.sh once the new push has deployed.

🤖 Generated with Claude Code

Approved

Approved. Both earlier blockers are fixed. The signing key is now sealed at rest with kernel/secret.ts, with the kid bound in as AAD, and there's a test that a lifted key won't sign. redeemCode refuses a suspended or removed account before createToken, for plain grants and OpenID ones, with a test.

I read the rest against the security list and found nothing open:

  • The code is deleted atomically (DELETE … RETURNING) before any await.
  • The issuer comes from the canonical ctx.url.
  • The nonce is length-capped and escaped on the consent page.
  • userinfo goes through verifyToken, so expired tokens and suspended people get 401. Plain grants and id_tokens are refused too.
  • The JWKS publishes no d, and the consent page lists exactly the identity shared.
  • No new dependency, and no SQL or shell built from input.

At c699d7d1, bun x tsc --noEmit is clean and the oidc, oauth-flow and oauth-discovery tests pass (34). This sandbox couldn't reach the preview or the screenshots, so I judged it from the code and tests.

One low gap, which I've filed as an issue since this was the last round: the rotation race guard (the stale check and insert in one transaction, src/core/oidc.ts:58) has no test.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs