Skip to content

fix(oauth): rescue legacy grants via grant.scope fallback (closes #29)#33

Open
stackbilt-admin wants to merge 1 commit intomainfrom
fix/29-legacy-grant-scope-fallback
Open

fix(oauth): rescue legacy grants via grant.scope fallback (closes #29)#33
stackbilt-admin wants to merge 1 commit intomainfrom
fix/29-legacy-grant-scope-fallback

Conversation

@stackbilt-admin
Copy link
Copy Markdown
Member

Summary

Closes #29. This is the read-side half of the scope cluster — #32 already fixed grant creation so empty-scope OAuth initiates get routed through the consent page. This PR fixes grant reads so the legacy cohort (grants minted before the C-1a remediation on 2026-04-10) no longer hits (none) scopes at tool dispatch.

  • resolveAuth calls a new resolveLegacyGrantScopes helper whenever oauthProps.scopes is empty. The helper runs unwrapToken on the bearer and reads the denormalized top-level grant.scope off the token record.
  • Rescue hits are logged (legacy-grant scope fallback rescued grant=...) so we can measure the legacy cohort shrinking over time and know when to retire the fallback + backfill.
  • 6 new tests (131/131 passing), tsc --noEmit clean, no eslint config in this repo.

What this does NOT rescue

Grants minted between C-1a (2026-04-10) and #32 deploy (2026-04-11 20:43Z) have empty grant.scope AND empty props.scopes — nothing to fall back to. Those users must disconnect and reauth through the #32 consent page to mint a fresh grant. That's unavoidable given the data loss at grant-creation time.

In-flight deploy safety (READ FIRST)

Deploy this PR BEFORE running any backfill script. A partial backfill that rewrites stale grant:* / mcp_session:* KV records while workers are still running pre-fallback code will 500 in-flight sessions on mixed blob versions. Sequence:

  1. Merge + wrangler deploy this PR
  2. Watch [gateway] legacy-grant scope fallback rescued logs for 24h to confirm the rescue path is actually firing for real users
  3. Separate PR: backfill script to walk KV and populate encryptedProps.scopes from grant.scope so the fallback becomes dead code
  4. Retire this fallback in a cleanup PR after a week of warn-log silence

Architecture note

@cloudflare/workers-oauth-provider transitively imports cloudflare:* protocol modules that vitest's node ESM loader cannot resolve. Eagerly importing getOAuthApi at the top of gateway.ts breaks every unit test that transitively imports it, even tests that never hit the fallback. Fix: dynamic import() inside the helper, confined to the runtime path. Tests that need to stub it (the new gateway-legacy-scope.test.ts) do so via vi.mock.

OAuthProvider configuration was extracted to src/oauth-config.ts so both index.ts (real provider instantiation) and gateway.ts (getOAuthApi for helpers) can reference the same options without a circular import.

Rejected alternatives

  • Thread grantId into props at creation time + lookup grant by ID on every request. Doesn't help the legacy cohort — their existing props are frozen without a grantId. Would only benefit future grants, which don't need the rescue.
  • Return authenticated: false with a reauth_required error code when both scope sources are empty. Considered but rejected — keeping authenticated: true with empty scopes preserves audit trail shape and lets the existing scope enforcement at tool dispatch emit the familiar (none) message that users can already map to "need to reconnect."

Test plan

Related: #28 (closed by #32), #30 (should resolve after this deploys + users reconnect), future backfill PR.

…uth (#29)

The C-1a remediation (commit 256ba06, 2026-04-10) removed the hardcoded
['generate','read'] path in resolveAuth and started enforcing the actual
scopes threaded through props.scopes. PR #32 fixed the grant-creation
side so empty-scope OAuth initiates get routed through the consent page
and mint grants with populated scopes.

This closes the last gap: the READ side. Any grant minted before C-1a
carries empty props.scopes (the pre-C-1a path wrote the grant top-level
scope but never populated props), so every authenticated request from
that legacy cohort still hits a (none) scopes error at tool dispatch
- exactly the UX symptom #29 and #30 describe.

resolveAuth now calls a new resolveLegacyGrantScopes helper when
oauthProps.scopes is empty. The helper extracts the bearer, calls
the OAuth provider's unwrapToken (which denormalizes the top-level
grant.scope onto the token record), and returns it as the effective
scope set. Successful rescues are logged so we can measure the
legacy cohort shrinking over time and know when to retire both
this fallback and the future backfill script.

What this does NOT rescue:

- Grants minted between C-1a (2026-04-10) and #32 (2026-04-11 20:43Z)
  where grant.scope and props.scopes are both empty. These users must
  disconnect and reauth through the #32 consent page - nothing exists
  on the server to fall back to.

Architecture:

The @cloudflare/workers-oauth-provider library transitively imports
cloudflare:* protocol modules that vitest's node ESM loader cannot
resolve, so getOAuthApi is dynamically imported inside the fallback
helper. Every other unit test that transitively imports gateway.ts
continues to work without touching the library.

OAuthProvider configuration was extracted to src/oauth-config.ts so
gateway.ts can pass the same options to getOAuthApi without a
circular import back through index.ts.

In-flight safety: deploy this READ fallback first. The backfill
script that rewrites stale KV grants comes in a separate PR and
must run AFTER this deploy is live - a partial backfill against
workers running old code will 500 in-flight sessions on mixed blob
versions.

Tests (6 new, 131/131 total):

- props.scopes present -> uses them directly, never touches unwrapToken
- props.scopes empty + grant.scope populated -> fallback rescues,
  session created with rescued scopes, tool call succeeds
- both empty -> scopes stay empty, tool call rejects with (none)
  error, which is the UX signal pointing users to reauth
- unwrapToken returns null -> graceful empty fallback
- unwrapToken throws (KV outage) -> caught, logged, empty fallback
- no bearer header -> fallback is inert

Related: #28 (closed by #32), #29 (this PR), #30 (should resolve
after this deploys + users reconnect).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.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.

oauth: fall back to grant.scope when ctx.props.scopes is empty (fixes legacy grants)

1 participant