-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(client): send the scope parameter on refresh-token requests #2720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 Verification: pre-existing — trigger: an AS that (per RFC 6749 §5.1) omits |
||
| resource, | ||
| addClientAuthentication: provider.addClientAuthentication, | ||
| dpop: await provider.dpop?.(), | ||
|
|
@@ -2334,6 +2345,7 @@ export async function refreshAuthorization( | |
| metadata, | ||
| clientInformation, | ||
| refreshToken, | ||
| scope, | ||
| resource, | ||
| addClientAuthentication, | ||
| dpop, | ||
|
|
@@ -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. */ | ||
|
|
@@ -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, | ||
|
|
@@ -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 }; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
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: treatinvalid_scopefrom the refresh attempt as recoverable likeinvalid_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:…