fix(oauth): retry a refresh grant without scope when the AS refuses it - #1982
Merged
RhysSullivan merged 1 commit intoSep 13, 2026
Merged
Conversation
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.
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 asoauth_refresh_failedwithretryable: falseon every call, so a connection whose refresh token was still alive stayed unusable until someone re-authorized it by hand.refreshAccessTokennow retries the grant once without thescopeparameter when the AS answersinvalid_scope. Omittingscopeis the form the spec defines as "the scope originally granted", so it does not depend on our recorded grant being right.Only
invalid_scopequalifies for the retry:invalid_grantmeans 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:checkbun run lintbun run typecheckbun run testAuth failures · a refresh refused for its scope is retried without one, so a scope-drifted connection keeps workingcd e2e && bunx vitest run --project selfhost scenarios/oauth-refresh-scope-fallback.test.tsRun 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.writeand was refused, while the retry carried noscopeand 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 oninvalid_grant, no retry when noscopewas 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
bun run changeset), or this change needs none.