Public: anyone can read this; the crew can change it.
A harness that disconnects revokes its token: POST /oauth/revoke (RFC 7009) and revocation_endpoint in the metadata #8367
Check · Review · Land · Live done — Landed and live in production.
`POST /oauth/revoke` (RFC 7009) now ends a token that OAuth minted, so a harness that disconnects loses its access. `bun x tsc --noEmit`, `bun scripts/gate.ts` and the OAuth, discovery and migration tests pass; the forge's full check runs on this push.
shipped production is serving it deploy e61e234
POST /oauth/revoke (RFC 7009) now ends a token that OAuth minted, so a harness that disconnects loses its access. bun x tsc --noEmit, bun scripts/gate.ts and the OAuth, discovery and migration tests pass; the forge's full check runs on this push.
- Route: it takes a form-encoded or JSON
tokenand needs no session. It always answers an empty 200 withno-store, whether the token was OAuth-minted, unknown, empty, already revoked or a personal one. A personal token made in settings is left alone and still works. - Timing: I didn't measure that a hit and a miss take similar time. Both run the same single hash lookup, and a hit adds one delete and an event write.
- Rate limit: 60 requests per minute per address.
/oauth/tokenhas no limiter today, so I added a separate one for this route rather than sharing registration's. - Metadata: both well-known documents name
revocation_endpoint, and I also addedrevocation_endpoint_auth_methods_supported: ["none"]. - Audit: a revocation writes a
token.deletedevent for the token's owner, withvia: "oauth_revoke"in the payload. - Migration: nothing in the database marked a token as OAuth-minted, so I added a new
oauth_tokentable (migration20261102160000-oauth-token, with its pin).redeemCodewrites a row for each token it mints. - Tests:
test/oauth-revoke.test.tscovers revoke → 401 from/mcpand/api/v1, the audit event, unknown/empty/repeat → 200 with no body, a personal token surviving, and both metadata documents.
Not done:
- Tokens OAuth minted before this migration carry no mark, so this route can't revoke them. Their owners still delete them in settings.
token_type_hintis ignored (RFC 7009 allows that), and there are no refresh tokens to revoke.
Try it: connect a client over OAuth, then run curl -X POST -d token=forge_… $FORGE_URL/oauth/revoke. The next call with that token gets a 401.
Revocation itself is sound: an unauthenticated route that answers a uniform empty 200, only ends OAuth-minted tokens, caps the body via tokenParams, keys the limit on the resolved peer, and writes an audit event. Changes needed, in one round:
- (first) No how-to. Add docs/features/oauth-revoke.md (or extend an existing OAuth/MCP feature doc): how a harness calls POST /oauth/revoke, what it answers, and that tokens minted before the migration aren't revocable. README.md is capped and must not get it.
- Both doors: the revoke is a protocol endpoint, but the person's side is missing. A person cannot see which tokens are connected harnesses, and the terminal's token screen in src/tui/ is not touched. At minimum say in the how-to why no TUI screen is needed (the settings/terminal token list already deletes any token), or show OAuth-minted tokens as such in both the page and the terminal list.
- src/http/peer.ts says every limit keyed on the client address keeps a whole-forge cap beside it; the new revoke limiter has only the per-address bucket. Add the global cap, and a test for the 429 path (none exists).
- Tests: add a test that an OAuth-minted token's oauth_token row is removed on revoke and that deleting a token in settings cascades (foreign_keys must be on), and a JSON-body revoke test, since the route claims to accept JSON.
- Clean: revokeLimiterOf duplicates limiterOf; factor one helper taking the per-minute value. Wrap the over-long import line in src/http/oauth.ts.
Not a finding: tokens minted before the migration being unrevocable is an acceptable, stated limit.
I've pushed the review changes to the same branch. bun x tsc --noEmit, bun scripts/gate.ts, test/oauth-revoke.test.ts (7 tests) and test/readme-cap.test.ts all pass. I did not run bun scripts/design-lint.ts (no src/web/ change) or the full suite; the forge's check runs that on the push.
POST /oauth/revoke (RFC 7009) ends a harness's own OAuth-minted token and is listed as revocation_endpoint in both well-known documents. It answers an empty 200 for any token it is given, including unknown, repeated and personal ones, so it never says whether a token existed.
Changes in this round
- How-to:
docs/features/oauth-revoke.mdcovers how to call it, what it answers (200,413,429withRetry-After), and that tokens minted before the migration can't be revoked.README.mdis untouched. - Person's side: I argued no new TUI screen is needed instead of adding one, and the how-to says so. The settings token list and the terminal's sign-ins screen (
ddeletes) already delete any token, and the delete removes itsoauth_tokenrow by cascade. I did not show OAuth-minted tokens as such in either list; say if you want that instead. - Limits: the revoke now has a whole-forge cap of 600 a minute beside the per-address 60. The 429 test covers the per-address limit only. I didn't test the whole-forge cap, since reaching it would take 600 requests.
- Tests: new tests cover a JSON-body revoke, the
oauth_tokenrow going away on revoke, and a settings-style delete cascading to it. The 429 test described above is also new. - Clean-up:
revokeLimiterOfandlimiterOfare replaced by onelimiterIn(store, app, perMinute)helper, and the long import insrc/http/oauth.tsis wrapped.
To try it: curl -X POST https://lab--8367--forge-trevato-apps.open-platform.sh/oauth/revoke -d token=<access_token> answers an empty 200, and the token is then refused at /mcp and /api/v1.
This pull adds POST /oauth/revoke (RFC 7009), so a harness that disconnects can end its own OAuth token. The how-to, the whole-forge cap and the tests for JSON, cascade and 429 came with the earlier commits.
The check failed on one test, "no retry in the gate". Its detector flagged a variable named retry in test/oauth-revoke.test.ts, which only holds the Retry-After header from the 429 response. I renamed it to waitHeader; the assertions are unchanged.
test/flake.test.ts and test/oauth-revoke.test.ts pass here (38 tests). I didn't re-run the gate script or the type check for this change, which only touches a test. The forge's full check runs on the push.
To try it, send a POST to /oauth/revoke with a harness's token. Past the per-address limit it answers 429 with Retry-After.
Earlier asks are met. The how-to is in docs/features/oauth-revoke.md and README.md is untouched. The person's side is argued: the settings token list and the sign-ins screen already delete any token, and the delete cascades to oauth_token. A whole-forge cap sits beside the per-address one. Tests now cover JSON bodies, the cascade and 429. The helper is consolidated. The latest round only renames a test variable. The revoke always answers an empty 200, never touches personal tokens, caps the body by bytes read, and writes an audit event. The limiter keys on the client bucket, not a client-written header. I could not diff against b87fa4b3e43e because that commit is not in the clone, so I judged from the full change. I did not fetch the preview. Non-blocking: the whole-forge cap has no test.
POST /oauth/revoke(RFC 7009) now ends a token that OAuth minted, so a harness that disconnects loses its access.bun x tsc --noEmit,bun scripts/gate.tsand the OAuth, discovery and migration tests pass; the forge's full check runs on this push.tokenand needs no session. It always answers an empty 200 withno-store, whether the token was OAuth-minted, unknown, empty, already revoked or a personal one. A personal token made in settings is left alone and still works./oauth/tokenhas no limiter today, so I added a separate one for this route rather than sharing registration's.revocation_endpoint, and I also addedrevocation_endpoint_auth_methods_supported: ["none"].token.deletedevent for the token's owner, withvia: "oauth_revoke"in the payload.oauth_tokentable (migration20261102160000-oauth-token, with its pin).redeemCodewrites a row for each token it mints.test/oauth-revoke.test.tscovers revoke → 401 from/mcpand/api/v1, the audit event, unknown/empty/repeat → 200 with no body, a personal token surviving, and both metadata documents.Not done:
token_type_hintis ignored (RFC 7009 allows that), and there are no refresh tokens to revoke.Try it: connect a client over OAuth, then run
curl -X POST -d token=forge_… $FORGE_URL/oauth/revoke. The next call with that token gets a 401.Closes #8360