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
Check · Review · Land · Live done — Landed and live in production.
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
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):
- High: the forge's signing private key is stored in plaintext in the database (
src/core/oidc.ts:47). Anyone who gets a copy offorge.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 withkernel/secret.ts(seal/open, used bycore/secret_store.ts,core/prompts.ts,core/subscriptions.ts). Fix this first: sealprivate_jwkwith the data dir's key when you write it, open it insignIdToken, and add a test that the stored column is not a JWK containing"d".
- Medium: consent does not say what identity is shared (
src/web/pages/oauth.ts). Withscope=openid profile emailthe 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") fromrequest.oidcScopeson the page, and test that it shows up foropenid emailand 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.
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_jwkis now sealed withkernel/secret.tsbefore it is written, using the data dir's key. The key'skidis bound in as the AAD, so a sealed key copied onto another row won't open.signIdTokenopens it on use and throws if it can't. The signing functions (signingKey,rotateKeys,jwks,signIdToken,verifyIdToken) andredeemCodenow takedataDir, and the callers insrc/http/oauth.tsandsrc/core/oauth.tspass 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
rotateKeysstill 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
kidrefuses to sign. - The consent page lists the identity for
openid emailandopenid 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.
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().
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):redeemCodenow loads the code's user and returnsinvalid_grantunlessisActive(user). This happens beforecreateToken, so a suspended or removed account gets no access token and no id_token. It applies to plain grants as well asopenidones, and the old later user lookup is gone. - New test (
test/oauth-flow.test.ts): for anopenidgrant and a plain grant, it consents, suspends alice, then exchanges the code. It expects 400invalid_grant, noid_token, noaccess_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 nowawait expect(...).rejects.toThrow().
I agree with both points and left everything else unchanged.
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 withisActive. The grant is never re-derived from client input./oauth/userinfotakes the bearer throughverifyToken, 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.tsand bound to its kid as AAD. The JWKS publishes only the public parts (the test checks there is nod). 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
htmltemplate, 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 existingoauth-authorization-serverdocument, so it keeps the pattern already in the code. - Doors: these are protocol endpoints, so there is no terminal counterpart to add.
docs/APPS.mdexplains 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.tsis 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.
The full gate on land was red and named no test; the pull is open again.
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 inoidc_key, andoidc_token, which records which access tokens came from an OpenID grant so/oauth/userinfocan 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. 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 anyawait. - The issuer comes from the canonical
ctx.url. - The nonce is length-capped and escaped on the consent page.
userinfogoes throughverifyToken, 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.
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
GET /.well-known/openid-configurationlists the issuer, the authorize, token, userinfo, JWKS and registration endpoints, and the scopesopenid,profile,email,ro,rwandadmin. The only signing algorithm it lists is ES256./oauth/authorizeacceptsscope=openidwith anonceof at most 256 characters; a longer one is refused. A plain OAuth grant behaves exactly as before. For an OpenID grant,/oauth/tokenreturns a signedid_tokennext to the access token. It carriesiss,aud,sub,iat,exp(one hour) and thenonce, plus username, name and email whenprofileoremailwas granted./oauth/userinfotakes 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./oauth/jwkspublishes the current key and the previous one, as public parts only. The private keys live in the database, in the newoidc_keytable, 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.src/core/oidc.ts, schema migration 76 adds the tables and columns, andredeemCodeis now async. I added a short section todocs/APPS.md.test/oidc.test.tsis new and is named inGATEinscripts/gate.ts. I also extendedtest/oauth-flow.test.tsandtest/oauth-discovery.test.ts. The rotation test signs a token, rotates, and checks that it still verifies until it expires.Left out
email_verifiedis always false, and there is noauth_timeclaim.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.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