fix(oauth): persist the scope the authorize URL carried

`provider.scope` is not what the authorization request asked for. The
SDK augments it with `offline_access` when the AS advertises that scope
and the client wants a refresh token, so persisting the provider's copy
after a silent token response understates the RFC 6749 5.1 implied
grant -- the same defect this change exists to fix, one notch smaller.

Read the scope off the captured authorize URL instead, falling back to
`provider.scope` when the URL carries none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-25 00:58:07 -04:00
commit 8be48eb742

View file

@ -737,13 +737,18 @@ describe("OAuthManager", () => {
});
describe("persisting the requested scope after an ordinary grant (#2117)", () => {
const CAPTURED_URL = new URL(
"https://auth.example.com/authorize?state=abc",
);
function capturedUrlWithScope(scope?: string): URL {
const url = new URL("https://auth.example.com/authorize?state=abc");
if (scope !== undefined) {
url.searchParams.set("scope", scope);
}
return url;
}
async function authorizeThenComplete(
storedScope: string | undefined,
tokenScope: string | undefined,
authorizeUrlScope?: string,
) {
mockedMcpAuth.mockResolvedValue("REDIRECT");
const params = createMockParams();
@ -758,7 +763,7 @@ describe("OAuthManager", () => {
.BaseOAuthClientProvider.prototype,
"getCapturedAuthUrl",
)
.mockReturnValue(CAPTURED_URL);
.mockReturnValue(capturedUrlWithScope(authorizeUrlScope));
await manager.authenticate();
@ -798,6 +803,23 @@ describe("OAuthManager", () => {
expect(storageOf(params).saveScope).not.toHaveBeenCalled();
});
it("persists the scope the authorize URL actually carried, not the provider's", async () => {
// The SDK augments the request with `offline_access` when the AS
// advertises it and the client wants a refresh token, so the authorize
// URL is a superset of `provider.scope`. Persisting the provider's copy
// would understate the grant the AS implied by staying silent.
const params = await authorizeThenComplete(
"mcp",
undefined,
"mcp offline_access",
);
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
SERVER_URL,
"mcp offline_access",
);
});
});
describe("completeOAuthFlow (EMA)", () => {