Skip to content

fix(oauth): retry a refresh grant without scope when the AS refuses it - #1982

Merged
RhysSullivan merged 1 commit into
UsefulSoftwareCo:mainfrom
mmarabel:fix/oauth-refresh-invalid-scope
Sep 13, 2026
Merged

RhysSullivan merged 1 commit into
UsefulSoftwareCo:mainfrom
mmarabel:fix/oauth-refresh-invalid-scope

Conversation

@mmarabel

Copy link
Copy Markdown
Contributor

Summary

A refresh grant that echoes the connection's recorded scope can be refused by an authorization server with invalid_scope, even though echoing the granted scope is legal under RFC 6749 §6. Railway answers every scope-bearing refresh with "refresh token missing requested scope" when the grant it stored on the refresh token is narrower than the authorization response it echoed back (#1969). That surfaced as oauth_refresh_failed with retryable: false on every call, so a connection whose refresh token was still alive stayed unusable until someone re-authorized it by hand.

refreshAccessToken now retries the grant once without the scope parameter when the AS answers invalid_scope. Omitting scope is the form the spec defines as "the scope originally granted", so it does not depend on our recorded grant being right.

Only invalid_scope qualifies for the retry: invalid_grant means the token itself is dead, and retrying that would spend a rotating refresh token to learn nothing. A scope-less request is never retried, and the retry's verdict is what surfaces when the retry is refused too.

Known trade-off: while a connection's recorded scope stays wider than the AS's grant, each refresh costs one refused request before the retry succeeds. Narrowing the recorded scope, or remembering the fallback per connection, would remove that; it is left as a follow-up rather than widening this change.

Linked issue

Fixes #1969

Verification

  • bun run format:check
  • bun run lint
  • bun run typecheck
  • bun run test
  • e2e — name the scenario, and link the recording or trace: Auth failures · a refresh refused for its scope is retried without one, so a scope-drifted connection keeps working

cd e2e && bunx vitest run --project selfhost scenarios/oauth-refresh-scope-fallback.test.ts

Run artifact: e2e/runs/selfhost/auth-failures-a-refresh-refused-for-its-scope-is-retried-without-one-so-a-scope-/

The scenario completes a real authorization-code flow against a live test AS whose refresh token carries a narrower grant than its authorization response, expires the access token immediately, and then calls the tool over the real MCP surface. It asserts the call returns the upstream payload, and — from the AS's own request ledger — that the first refresh echoed issues.read issues.write and was refused, while the retry carried no scope and the same refresh token.

Red/green: with the fallback stashed out, the same scenario fails exactly as the report describes (oauth_refresh_failed, invalid_scope, retryable: false); with it, the call succeeds.

Unit coverage in packages/core/sdk/src/oauth-helpers.test.ts: the retry and its request bodies, the JSON-format request path, no retry on invalid_grant, no retry when no scope was sent, and the retry's verdict surfacing when it is refused. The sibling scenarios (oauth-refresh-rejected, oauth-refresh-rejected-non-json, oauth-refresh-on-401, oauth-scope-insufficient) still pass.

Checklist

  • Added a changeset (bun run changeset), or this change needs none.
  • Added or updated tests for the new behaviour.
  • No secrets, credentials, or private data in the diff.

Railway answers every scope-bearing refresh with `invalid_scope: refresh
token missing requested scope` when its stored grant is narrower than the
authorization it echoed back, so a connection whose refresh token was
still live failed every call as `oauth_refresh_failed` and only a hand
re-authorization recovered it.

RFC 6749 §6 defines omitting `scope` as "the scope originally granted",
so retry the grant once without it. Only `invalid_scope` qualifies:
`invalid_grant` means the token is dead, and retrying that spends a
rotating refresh token to learn nothing.
@mmarabel mmarabel closed this Sep 12, 2026
@mmarabel mmarabel reopened this Sep 12, 2026
@RhysSullivan
RhysSullivan merged commit cc0fd8f into UsefulSoftwareCo:main Sep 13, 2026
80 of 82 checks passed
@RhysSullivan RhysSullivan mentioned this pull request Sep 13, 2026
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.

[bug] Railway OAuth refresh fails with invalid_scope: refresh token missing requested scope

2 participants