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

Merged opened this

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

Merged

`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

opened this

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 token and needs no session. It always answers an empty 200 with no-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/token has 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 added revocation_endpoint_auth_methods_supported: ["none"].
  • Audit: a revocation writes a token.deleted event for the token's owner, with via: "oauth_revoke" in the payload.
  • Migration: nothing in the database marked a token as OAuth-minted, so I added a new oauth_token table (migration 20261102160000-oauth-token, with its pin). redeemCode writes a row for each token it mints.
  • Tests: test/oauth-revoke.test.ts covers revoke → 401 from /mcp and /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_hint is 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

commented

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 token and needs no session. It always answers an empty 200 with no-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/token has 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 added revocation_endpoint_auth_methods_supported: ["none"].
  • Audit: a revocation writes a token.deleted event for the token's owner, with via: "oauth_revoke" in the payload.
  • Migration: nothing in the database marked a token as OAuth-minted, so I added a new oauth_token table (migration 20261102160000-oauth-token, with its pin). redeemCode writes a row for each token it mints.
  • Tests: test/oauth-revoke.test.ts covers revoke → 401 from /mcp and /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_hint is 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.

Changes requested — before the latest push

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:

  1. (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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.

commented

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.md covers how to call it, what it answers (200, 413, 429 with Retry-After), and that tokens minted before the migration can't be revoked. README.md is 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 (d deletes) already delete any token, and the delete removes its oauth_token row 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_token row going away on revoke, and a settings-style delete cascading to it. The 429 test described above is also new.
  • Clean-up: revokeLimiterOf and limiterOf are replaced by one limiterIn(store, app, perMinute) helper, and the long import in src/http/oauth.ts is 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.

commented

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.

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs