Skip to content

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

@stackbilt-admin

Description

@stackbilt-admin

Problem

Grants created before the C-1a remediation (commit `256ba06`, 2026-04-10) have empty `scopes` inside their `encryptedProps` blob. When Claude Code's MCP client does a refresh-token exchange against one of these older grants, the new access token inherits the stale encrypted props — so `ctx.props.scopes` arrives at the gateway as `undefined` even though the grant's top-level `scope` field is correctly populated with `["generate", "read"]`.

The gateway treats this as "no scopes granted" and denies every scope-gated tool call. This silently locks out every user whose OAuth grant predates 2026-04-10.

Evidence (2026-04-11)

Dogfood session this morning:

  • Kurt's admin grants: `grant:g5aL4ksbGjWkaG30tibfDrpsVgaVxE9Z:*` — authorizedAt 2026-03-24 through 2026-03-27, all have `scope: ["generate","read"]` at the top level ✓
  • Kurt's personal grant: `grant:HWU9BjPldLkdBMchp2BQ8bnL8QrsXdD4:Wt9W21jbypueUS5e` — authorizedAt 2026-04-08, same shape ✓
  • Every `mcp_session:*` record cached from those grants: `scopes: []` ✗

All four active sessions had to be manually patched in KV to restore the scopes and unblock MCP dogfooding. The patch has a 30-minute TTL; after that the sessions expire, `recoverSession` re-runs, `auth.scopes` re-resolves to `[]`, and we're back to square one.

Root cause

`src/gateway.ts` `resolveAuth` line 1014:

```ts
// C-1a remediation: thread through the actual scopes the OAuth token
// was issued with. Previously hardcoded to ['generate', 'read']...
scopes: oauthProps.scopes ?? [],
```

This only reads from `props.scopes` (the encrypted blob). It never falls back to the grant's top-level `scope` field, which is where the OAuth Provider library actually stores the authoritative scope list and which is always populated correctly regardless of whether the grant predates C-1a.

Proposed fix

When `oauthProps.scopes` is empty or missing but `oauthProps.userId` is set (meaning OAuth validation succeeded), look up the grant's top-level `scope` and use that. Options:

  1. Via OAuth Provider API: if `env.OAUTH_PROVIDER` exposes a `lookupGrant(grantId)` (or similar), read `grant.scope` from there. Ideal, but depends on library surface.
  2. Via direct KV read: thread `grantId` into `ctx.props` at `completeAuthorization` time, then read `grant::` from `OAUTH_KV` at request time. More brittle but works today.
  3. Backfill migration: run a one-shot script that reads every `grant:*` KV record, decrypts `encryptedProps`, injects `scopes: grant.scope` into the props blob, re-encrypts, writes back. One-time fix for existing grants; doesn't prevent the issue for any grants created between now and the code fix.

Option 3 is the cleanest one-shot remediation for the existing population. Option 1 or 2 is the code fix that prevents this from recurring.

Why this matters

The gateway's scope model is correct; the C-1a fix was correct. The bug is that legacy grants exist that the post-C-1a code can't read scopes from, and refresh-token exchange silently inherits the stale state forever. Without a fix, every pre-2026-04-10 OAuth user is broken — and they have no visible error that tells them why, because OAuth itself succeeds and the gateway only fails at tool call time.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions