Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/refresh-token-scope-parameter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Send the granted `scope` on refresh-token requests (RFC 6749 §6). `refreshAuthorization()` accepts a new optional `scope` and preserves the granted scope on its result when the response omits it (RFC 6749 §5.1); the built-in `auth()` flow sends exactly the granted scope recorded on the stored tokens (omitting the parameter when none is recorded). Fixes token refresh against authorization servers that require the parameter, e.g. Microsoft Entra ID rejecting scope-less refreshes with `AADSTS90009` when the client application is also the resource (#2718).
39 changes: 37 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,17 @@ async function authInternal(
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
// RFC 6749 §6: the refresh-request scope must not exceed the originally
// granted scope, so send exactly the scope the AS recorded on the token
// response (RFC 6749 §5.1) and nothing else — a recomputed value (e.g.
// determineScope()'s output) could have widened since the grant and a
// strict AS would reject the refresh with invalid_scope. When no granted
// scope is recorded (absent or empty), omit the parameter, which the AS
// treats as the originally granted scope. Sending the granted scope keeps
// refresh working on servers that require the parameter — e.g. Microsoft
// Entra ID rejects a scope-less refresh with AADSTS90009 when the client
// application is also the resource (#2718).
scope: tokens.scope || undefined,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Sending the stored granted scope on refresh creates a new hard-fail path when that scope has gone stale on the AS (e.g. a Keycloak client scope detached, or a scope renamed, after the grant): the AS answers invalid_scope, authInternal rethrows it (lines 1401/1413), and auth()'s recovery (lines 1050-1067) doesn't cover it — so every attempt throws with no re-auth fallback and no token invalidation, where base's scope-less refresh succeeded with the remaining scopes. Fix: treat invalid_scope from the refresh attempt as recoverable like invalid_grant — invalidate tokens and/or fall through to fresh authorization instead of rethrowing.

Extended reasoning...

Distinct from the resolved resolvedScope-fallback finding: that defect sent a scope wider than the grant and its fix was scope selection (granted-only, now in place); this one fires with the exact granted scope and needs an error-recovery fix, which the prior fix does not provide. Trigger condition (external, stated per the conditional-finding rule): after the grant, the AS's configuration changes so one of the granted scope tokens is no longer valid for the client — Keycloak returns 400 {"error":"invalid_scope"} when a requested client scope is not attached to the client; admins detaching an optional scope is routine ops. Trace: line 1387 now sends tokens.scope on the refresh; on base no scope is ever sent on refresh, so per RFC 6749 §6 the AS issues tokens for (the still-valid subset of) the original grant and auth() succeeds. After merge: executeTokenRequest gets the 400, parseErrorResponse (lines 948-960) yields OAuthError with code invalid_scope; in authInternal's catch, it is not InsecureTokenEndpointError (1397), it IS an OAuthError and not ServerError, so line 1413…

Verification: normal — triggered when the AS's scope configuration drifts after the grant so the stored granted scope is no longer valid for the client (e.g. a Keycloak client scope detached/renamed during the refresh token's lifetime) and the AS rejects the now-explicit scope with invalid_scope while it would have accepted (and silently narrowed, RFC 6749 §5.1) base's scope-less refresh. Mechanism verified:…

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Pre-existing (partial fix): the #2718 failure persists for any AS that compliantly omits scope from the authorization-code token response (RFC 6749 §5.1 allows omission when granted == requested) — the tokens saved after the initial exchange (line 1351) get no scope back-fill, so tokens.scope || undefined here is always undefined and every refresh stays scope-less, wedging exactly as on base when that AS also requires scope on refresh. Fix: back-fill resolvedScope onto the tokens saved after the initial exchange when the response omits scope, mirroring the back-fill added in refreshAuthorization().
A small fix can ride a push you are already making; otherwise a short reply is enough.

Extended reasoning...

Condition that cannot be ruled out from here: an AS that requires the scope parameter on refresh (the #2718 class) AND follows RFC 6749 §5.1's default of omitting scope from the token response when granted == requested (Auth0, for example, documents omitting scope when it matches the request). Path: authInternal callback leg calls fetchToken at lines 1342-1349 and saves its result verbatim at line 1351 — refreshAuthorization's new back-fill (line 2403) applies only to the refresh grant, and neither fetchToken nor exchangeAuthorization (line 2319) back-fills the requested scope. So provider.tokens() returns a token set with no scope key, line 1387 evaluates tokens.scope || undefined to undefined, the if (scope) guard at line 2385 skips the parameter, and the refresh request is byte-identical to base — the AS rejects it (e.g. AADSTS90009-style) and the client falls into the same failed-refresh/re-auth wedge the PR set out to fix. Per §5.1, an omitted response scope means granted == requested, so back-filling resolvedScope at the line 1351 save is spec-correct and closes the…

Verification: pre-existing — trigger: an AS that (per RFC 6749 §5.1) omits scope from the authorization-code token response when granted == requested AND requires scope on refresh requests (the #2718 class). Mechanism verified: packages/client/src/client/auth.ts:1342-1352 — the code-exchange leg calls fetchToken(..., { scope: resolvedScope }) and saves the response verbatim (`await provider.saveTokens({…

resource,
addClientAuthentication: provider.addClientAuthentication,
dpop: await provider.dpop?.(),
Expand Down Expand Up @@ -2334,6 +2345,7 @@ export async function refreshAuthorization(
metadata,
clientInformation,
refreshToken,
scope,
resource,
addClientAuthentication,
dpop,
Expand All @@ -2342,6 +2354,18 @@ export async function refreshAuthorization(
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
refreshToken: string;
/**
* Scope to request on the refresh, per RFC 6749 §6. MUST NOT include any scope
* not originally granted by the resource owner; when omitted (or empty), the
* authorization server treats the request as asking for the originally granted
* scope, so pass only the granted scope recorded on the token response
* (RFC 6749 §5.1) — never a recomputed or widened value.
*
* Some authorization servers require the parameter on refresh requests — e.g.
* Microsoft Entra ID (AAD) rejects a scope-less refresh with `AADSTS90009` when
* the client application is also the resource (#2718).
*/
scope?: string;
resource?: string | URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
Expand All @@ -2354,6 +2378,14 @@ export async function refreshAuthorization(
refresh_token: refreshToken
});

// Truthiness deliberate: an empty scope string means "the originally granted
// scope" exactly like an absent one, and a literal empty `scope=` parameter is
// syntactically invalid per RFC 6749 §3.3 (some ASes, e.g. GitHub, do record
// `"scope": ""` on token responses).
if (scope) {
tokenRequestParams.set('scope', scope);
}

const tokens = await executeTokenRequest(authorizationServerUrl, {
metadata,
tokenRequestParams,
Expand All @@ -2364,8 +2396,11 @@ export async function refreshAuthorization(
fetchFn
});

// Preserve original refresh token if server didn't return a new one
return { refresh_token: refreshToken, ...tokens };
// Preserve the original refresh token if the server didn't return a new one, and
// the granted scope when the response omits it — RFC 6749 §5.1 lets the AS omit
// `scope` when it is identical to the requested scope, and dropping it here would
// strand the next refresh without the granted scope to send.
return { refresh_token: refreshToken, ...(scope ? { scope } : {}), ...tokens };
}

/**
Expand Down
170 changes: 170 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2271,6 +2271,91 @@ describe('OAuth Authorization', () => {
expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens });
});

it('includes scope in the refresh request when provided (RFC 6749 §6, #2718)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokensWithNewRefreshToken
});

await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: 'openid profile offline_access api://client123/access_as_user'
});

const body = mockFetch.mock.calls[0]![1].body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('scope')).toBe('openid profile offline_access api://client123/access_as_user');
});

it('omits the scope parameter when none is provided', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokensWithNewRefreshToken
});

await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123'
});

const body = mockFetch.mock.calls[0]![1].body as URLSearchParams;
expect(body.get('scope')).toBeNull();
});

it('omits the scope parameter when the granted scope is the empty string (RFC 6749 §3.3)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validTokensWithNewRefreshToken
});

// e.g. GitHub records `"scope": ""` on token responses with no scopes
await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: ''
});

const body = mockFetch.mock.calls[0]![1].body as URLSearchParams;
expect(body.get('scope')).toBeNull();
});

it('preserves the granted scope when the refresh response omits it (RFC 6749 §5.1)', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
// No `scope` on the response: granted == requested
json: async () => validTokens
});

const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: 'api://client123/access_as_user'
});

expect(tokens.scope).toBe('api://client123/access_as_user');
});

it('prefers the scope from the refresh response over the requested one', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ ...validTokens, scope: 'narrowed' })
});

const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: 'api://client123/access_as_user'
});

expect(tokens.scope).toBe('narrowed');
});

it('validates token response schema', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -3047,6 +3132,91 @@ describe('OAuth Authorization', () => {
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('refresh123');
// No granted scope was stored and none is resolvable — the parameter stays off the wire
expect(body.get('scope')).toBeNull();
});

it('sends the granted scope from the stored tokens on refresh (#2718)', async () => {
mockDiscoveryWithTokenEndpoint(() => ({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-access123',
token_type: 'Bearer',
expires_in: 3600
})
}));

(mockProvider.clientInformation as Mock).mockResolvedValue({
client_id: 'test-client',
client_secret: 'test-secret'
});
(mockProvider.tokens as Mock).mockResolvedValue({
access_token: 'old-access',
refresh_token: 'refresh123',
scope: 'api://test-client/access_as_user'
});
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);

const result = await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server'
});

expect(result).toBe('AUTHORIZED');

const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
expect(tokenCall).toBeDefined();

const body = tokenCall![1].body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('scope')).toBe('api://test-client/access_as_user');

// The mocked refresh response omits `scope` (granted == requested per
// RFC 6749 §5.1) — the granted scope must survive into the saved tokens
// so the NEXT refresh can send it too.
expect(mockProvider.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({ scope: 'api://test-client/access_as_user' }),
expect.anything()
);
});

it('omits scope on refresh when the stored tokens carry no granted scope, even if a scope was requested (RFC 6749 §6)', async () => {
mockDiscoveryWithTokenEndpoint(() => ({
ok: true,
status: 200,
json: async () => ({
access_token: 'new-access123',
token_type: 'Bearer',
expires_in: 3600
})
}));

(mockProvider.clientInformation as Mock).mockResolvedValue({
client_id: 'test-client',
client_secret: 'test-secret'
});
(mockProvider.tokens as Mock).mockResolvedValue({
access_token: 'old-access',
refresh_token: 'refresh123'
});
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);

const result = await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server',
scope: 'mcp:tools'
});

expect(result).toBe('AUTHORIZED');

const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
expect(tokenCall).toBeDefined();

// The requested scope could exceed the original grant, so the refresh
// request must not carry a recomputed scope — omitted means "the
// originally granted scope" per RFC 6749 §6.
const body = tokenCall![1].body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('scope')).toBeNull();
});

// The #2034 tests below differ only in how the token endpoint answers, so the
Expand Down
Loading