Skip to content

feat: revoke existing grants on re-authorization by default#144

Merged
mattzcarey merged 9 commits intocloudflare:mainfrom
mattzcarey:fix/revoke-existing-grants-on-reauth
Feb 27, 2026
Merged

feat: revoke existing grants on re-authorization by default#144
mattzcarey merged 9 commits intocloudflare:mainfrom
mattzcarey:fix/revoke-existing-grants-on-reauth

Conversation

@mattzcarey
Copy link
Contributor

@mattzcarey mattzcarey commented Feb 23, 2026

Summary

Two related grant-security improvements, shipped as one body of work:

  1. Revoke existing grants on re-authorization — When a user goes through the OAuth flow again for the same client, all previous grants (and their tokens) for that user+client are now revoked after the new grant is stored. This fixes infinite re-auth loops caused by stale props in old grants (issue No provision to restart the authorization flow if the oauth server to the worker oauth client does not provide refresh tokens #34).

  2. Revoke tokens on authorization code reuse (RFC 6749 §10.5) — When an authorization code that has already been exchanged is presented again, the provider now revokes the grant and all tokens from the original exchange. This prevents authorization code replay attacks. (Originally PR Revoke tokens on authorization code reuse #157 by @rc4, squashed into this PR.)

Breaking behavior change

completeAuthorization() now defaults revokeExistingGrants to true. Previously, re-authorizing the same user+client created an additional grant, leaving old tokens valid. Now, old grants are revoked by default. This is an edge case but it could be hit by some MCP clients.

If your application relies on multiple concurrent grants per user+client, set revokeExistingGrants: false to preserve the old behavior:

await oauthProvider.completeAuthorization({
  request: authRequest,
  userId: user.id,
  metadata: { ... },
  scope: ['read', 'write'],
  props: { ... },
  revokeExistingGrants: false,
});

MCP servers should not need to preserve this so setting the default to true here but allowing opt out for developers who need it.

Changes

src/oauth-provider.ts

  • Added revokeExistingGrants?: boolean option to CompleteAuthorizationOptions (defaults to true)
  • Before creating the new grant, collects all existing grant IDs for the same userId + clientId (with cursor-based pagination for >1000 grants)
  • After the new grant is stored, revokes old grants in parallel via Promise.all
  • Revocation is best-effort (wrapped in try/catch) — if it fails, the new grant is already stored and the authorization response still succeeds
  • Both implicit flow and authorization code flow paths are covered
  • Added auth code reuse detection: when authCodeId is absent on a grant, calls revokeGrant() (best-effort) and returns invalid_grant

__tests__/reauth-grant-revocation.test.ts (new, 5 tests)

  • Demonstrates stale props persist when revokeExistingGrants: false (the bug)
  • Demonstrates the infinite re-auth loop scenario with tokenExchangeCallback throwing invalid_grant
  • Old access tokens return 401 after re-authorization (fix works)
  • Old refresh tokens return 400 after re-authorization (fix works)
  • Other clients' grants are unaffected

__tests__/oauth-provider.test.ts (1 new test)

  • Auth code reuse: second exchange returns invalid_grant, all tokens and the grant are revoked

.changeset/fix-revoke-existing-grants.md

  • Minor version bump (default behavior change)

RFC / spec alignment

  • RFC 6749 §10.5: Authorization code reuse detection and token revocation — now implemented
  • Grant Management for OAuth 2.0 (OpenID FAPI draft): Defines a replace action that does exactly what revokeExistingGrants does
  • Most production OAuth servers (Auth0, Okta) revoke old refresh tokens on re-authorization as standard practice
  • Neither OAuth 2.0 (RFC 6749), OAuth 2.1, nor the MCP spec mandate or prohibit revoking old grants on re-authorization — this is a defensible server-side policy choice

Test plan

  • 222 tests pass (216 existing + 5 re-auth + 1 auth code reuse, no regressions)
  • Formatting passes (prettier --check)
  • E2E validation with MCP Inspector + real agents SDK MCP server

Fixes #34 | Includes #157

@changeset-bot
Copy link

changeset-bot bot commented Feb 23, 2026

🦋 Changeset detected

Latest commit: 19bbf24

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/workers-oauth-provider Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 23, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/workers-oauth-provider/@cloudflare/workers-oauth-provider@144

commit: 19bbf24

@mattzcarey mattzcarey changed the title feat: add revokeExistingGrants option to completeAuthorization feat: revoke existing grants on re-authorization by default Feb 25, 2026
@mattzcarey
Copy link
Contributor Author

gonna release this as a minor but the last one was massive so waiting a couple of days to test this better.

When re-authorizing, clients may still hold old tokens pointing to old grants
with outdated props. This can cause infinite re-auth loops when the app throws
invalid_grant to force re-authentication.

The new revokeExistingGrants option revokes all existing grants for the same
user+client combination before creating the new grant, ensuring clients must
use the new tokens with updated props.

Fixes cloudflare#34
completeAuthorization() now revokes all existing grants for the same
user+client combination after storing the new grant. This fixes infinite
re-auth loops (issue cloudflare#34) where stale props in old grants cause clients
to endlessly re-authorize.

Changes from the original PR:
- Default flipped: revokeExistingGrants defaults to true (opt-out via false)
- Revocation happens AFTER new grant is stored (no data-loss window)
- Pagination support for users with >1000 grants
- Changeset bumped to minor (behavior change)
- Added 5 reproduction tests covering the bug and the fix
Squashed from PR cloudflare#157 by @rc4. When an authorization code is reused,
revoke all tokens issued from the first exchange as a precaution
against replay attacks. Revocation is best-effort to ensure the
invalid_grant response always returns. Includes test coverage.
@mattzcarey mattzcarey force-pushed the fix/revoke-existing-grants-on-reauth branch from db1dbaa to dd0ec4b Compare February 27, 2026 17:11
@mattzcarey mattzcarey disabled auto-merge February 27, 2026 17:11
…mpatibility

The helper functions accepted OAuthProvider (defaulting to OAuthProvider<Env>)
but tests create OAuthProvider<TestEnv>, causing type errors in CI.
@mattzcarey mattzcarey merged commit 49a1d24 into cloudflare:main Feb 27, 2026
4 checks passed
@github-actions github-actions bot mentioned this pull request Feb 27, 2026
mattzcarey pushed a commit that referenced this pull request Mar 4, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @cloudflare/workers-oauth-provider@0.3.0

### Minor Changes

- [#158](#158)
[`b26f7ff`](b26f7ff)
Thanks [@mattzcarey](https://github.com/mattzcarey)! - Add
`clientIdMetadataDocumentEnabled` option to make CIMD (Client ID
Metadata Document) support explicitly opt-in. Previously, CIMD
auto-enabled when the `global_fetch_strictly_public` compatibility flag
was present, which could cause crashes for servers where URL-shaped
client_ids hit bot-protected endpoints. When not enabled (the default),
URL-formatted client_ids now fall through to standard KV lookup instead
of throwing.

- [#144](#144)
[`49a1d24`](49a1d24)
Thanks [@mattzcarey](https://github.com/mattzcarey)! - Add
`revokeExistingGrants` option to `completeAuthorization()` that revokes
existing grants for the same user+client after creating a new one.
Defaults to `true`, fixing infinite re-auth loops when props change
between authorizations (issue #34). Set to `false` to allow multiple
concurrent grants per user+client.

Revoke tokens and grant when an authorization code is reused, per RFC
6749 §10.5. This prevents authorization code replay attacks by
invalidating all tokens issued from the first exchange.

**Breaking behavior change:** Previously, re-authorizing the same
user+client created an additional grant, leaving old tokens valid. Now,
old grants are revoked by default. If your application relies on
multiple concurrent grants per user+client, set `revokeExistingGrants:
false` to preserve the old behavior.

### Patch Changes

- [#164](#164)
[`4b640a3`](4b640a3)
Thanks [@pnguyen-atlassian](https://github.com/pnguyen-atlassian)! -
Include `client_secret_expires_at` and `client_secret_issued_at` in
dynamic client registration responses when a `client_secret` is issued,
per RFC 7591 §3.2.1.

- [#165](#165)
[`9cce070`](9cce070)
Thanks [@mattzcarey](https://github.com/mattzcarey)! - Use
`Promise.allSettled` instead of `Promise.all` for best-effort grant
revocation in `completeAuthorization()`, ensuring all grants are
attempted even if one fails.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No provision to restart the authorization flow if the oauth server to the worker oauth client does not provide refresh tokens

2 participants