fix(oauth): honor a newly configured offline_access against stale storage

Copilot review round 6 on #2114.

The early return handed back the persisted scope unchanged, so an
`offline_access` the user had just typed into Scopes never reached the
request when storage predated the change — and storage routinely does,
since `createOAuthProvider` preserves a stored scope rather than
reseeding from current settings. The docs promised configured scopes
are honored and the form warned that `offline_access` "is still
requested"; both were false in exactly that state.

Honoring it means adding it, not merely declining to strip it.

Only the token itself is added, not the rest of the configured scope:
unioning unrelated configured tokens into the request is outside what
this setting governs and could push an otherwise-satisfied connection
into a step-up authorization.

Three tests, all verified to fail against the previous behavior.

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-24 22:03:28 -04:00
commit f153591042
3 changed files with 47 additions and 4 deletions

View file

@ -89,6 +89,30 @@ describe("scopeForDeclinedRefreshGrant (#2068)", () => {
).toBe("mcp offline_access");
});
// Storage can predate the settings change — `createOAuthProvider` preserves a
// stored scope rather than reseeding from current settings. Merely declining
// to strip would silently omit a scope the user just typed, and the form's
// "still requested" warning would be lying.
it("adds a newly configured offline_access that stale storage lacks", () => {
expect(scopeForDeclinedRefreshGrant("mcp", "mcp offline_access")).toBe(
"mcp offline_access",
);
});
// Only the token itself is added; unrelated configured scopes are not unioned
// in, since widening the request could force a step-up authorization.
it("does not union unrelated configured scopes into the request", () => {
expect(
scopeForDeclinedRefreshGrant("mcp", "tools:read offline_access"),
).toBe("mcp offline_access");
});
it("does not duplicate an offline_access already present", () => {
expect(
scopeForDeclinedRefreshGrant(" mcp offline_access ", "offline_access"),
).toBe("mcp offline_access");
});
it("leaves a scope without offline_access untouched", () => {
expect(scopeForDeclinedRefreshGrant("mcp tools:read", "mcp")).toBe(
"mcp tools:read",

View file

@ -40,8 +40,18 @@ export const OFFLINE_ACCESS_SCOPE = "offline_access";
* self-heals, while re-checking the box restores the old behavior from the
* user's own configuration rather than from a value we destroyed.
*
* A scope the user explicitly configured is left alone — they asked for it, and
* the web form warns that it keeps the consent prompt alive.
* An `offline_access` the user explicitly configured is honored — they asked
* for it, and the web form warns that it keeps the consent prompt alive. That
* means *ensuring* it is requested, not merely declining to strip it: the
* persisted scope can predate the configuration change (`createOAuthProvider`
* preserves a stored scope rather than reseeding from current settings), and
* silently omitting a scope the user typed would make both this promise and the
* form's warning false.
*
* Only `offline_access` is added, never the rest of the configured scope.
* Widening the request with unrelated configured tokens is outside what this
* setting governs and could push an otherwise-satisfied connection into a
* step-up authorization.
*/
export function scopeForDeclinedRefreshGrant(
effectiveScope: string | undefined,
@ -49,7 +59,16 @@ export function scopeForDeclinedRefreshGrant(
): string | undefined {
if (!effectiveScope) return effectiveScope;
const configured = configuredScope?.trim().split(/\s+/) ?? [];
if (configured.includes(OFFLINE_ACCESS_SCOPE)) return effectiveScope;
if (configured.includes(OFFLINE_ACCESS_SCOPE)) {
const tokens = effectiveScope
.trim()
.split(/\s+/)
.filter((token) => token !== "");
if (!tokens.includes(OFFLINE_ACCESS_SCOPE)) {
tokens.push(OFFLINE_ACCESS_SCOPE);
}
return tokens.join(" ");
}
const kept = effectiveScope
.trim()

View file

@ -205,7 +205,7 @@ The setting suppresses the grant declaration and the SDK's scope augmentation
> **It applies on the next connect, not to the live connection.** OAuth is connection-time configuration here — like the client credentials, the scopes, and the endpoint overrides beside it — so a client that is already connected keeps the metadata it was built with. Reconnect for the change to reach the authorization flow.
> **A scope inherited from an earlier grant is dropped from the request.** A successful authorization persists the scope the authorization server granted, which for a default-on client includes `offline_access`. Left alone, that value would come straight back on the next connect and re-trigger `prompt=consent` with the box unchecked. So when the grant is declined the Inspector filters `offline_access` out of what it *requests* — unless you configured it in **Scopes** yourself, which it honors. The stored scope is not rewritten: re-checking the box restores the previous behavior instead of finding the value destroyed.
> **A scope inherited from an earlier grant is dropped from the request.** A successful authorization persists the scope the authorization server granted, which for a default-on client includes `offline_access`. Left alone, that value would come straight back on the next connect and re-trigger `prompt=consent` with the box unchecked. So when the grant is declined the Inspector filters `offline_access` out of what it *requests* — unless you configured it in **Scopes** yourself, in which case it is requested regardless of what storage holds (honoring it has to mean *adding* it when the persisted scope predates your change, not merely declining to strip it). The stored scope is not rewritten: re-checking the box restores the previous behavior instead of finding the value destroyed.
> **Two pieces of state outlive the setting, and neither is cleared by unchecking it.** A refresh token issued before the opt-out stays usable — the SDK's refresh path posts the stored token with `grant_type=refresh_token` without consulting the client metadata, so such a server can keep refreshing silently and you may not observe the cost described above at all. And the registration already held at the authorization server still lists the grant, because turning the setting off changes what the Inspector *declares*, not what the AS has recorded.
>