fix(oauth): keep the scope filter from overwriting persisted storage

Copilot review round 5 on #2114 — a hole in last round's fix.

When the persisted scope was `offline_access` alone,
`scopeForDeclinedRefreshGrant` returned undefined.
`OAuthManager.createOAuthProvider` reads that as "nothing stored" and
seeds storage with the configured scope, so the request-only filter
silently overwrote the value it promised to leave alone — and
re-enabling the grant could no longer restore it.

It now falls back to the configured scope, which is both the right
thing to request and inert to that branch. The previous "storage is
never rewritten" test could not see this: it ran at the provider level,
while the seeding branch lives in the manager. The new regression runs
there, and fails when the fallback is removed.

Also corrects the EMA rationale, which was wrong even though its
conclusion held. `createOAuthProviderForTransport` does build a
`BaseOAuthClientProvider` and wrap it, and `EmaTransportOAuthProvider`
forwards `clientMetadata` — so the setting is not bypassed outright.
What it cannot change is the IdP authorization leg's fixed
`openid offline_access`, which is the leg the user signs in through and
therefore the reason the control cannot affect EMA's consent behavior.
Fixed in the docs, the code comment, the UI string and the test.

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 21:48:21 -04:00
commit 1bbdef03eb
2 changed files with 72 additions and 16 deletions

View file

@ -97,8 +97,23 @@ describe("scopeForDeclinedRefreshGrant (#2068)", () => {
// Requesting `scope=` empty is not the same as omitting it; collapse to
// undefined so the SDK falls back to its own resolution.
it("collapses to undefined when offline_access was the only scope", () => {
it("collapses to undefined when nothing is left and nothing is configured", () => {
expect(scopeForDeclinedRefreshGrant("offline_access", "")).toBeUndefined();
expect(
scopeForDeclinedRefreshGrant("offline_access", undefined),
).toBeUndefined();
});
// Returning undefined here would read as "nothing stored" to
// OAuthManager.createOAuthProvider, whose seeding branch writes the
// configured scope to storage — turning a request-only filter into a silent
// overwrite. The configured scope is both the right request and inert to that
// branch.
it("falls back to the configured scope rather than emptying out", () => {
expect(scopeForDeclinedRefreshGrant("offline_access", "mcp")).toBe("mcp");
expect(
scopeForDeclinedRefreshGrant(" offline_access ", " mcp tools:read "),
).toBe("mcp tools:read");
});
it("passes an absent scope through", () => {

View file

@ -1703,6 +1703,28 @@ describe("OAuthManager", () => {
]);
});
// #2068 round 5 — the provider-level test for "storage is never rewritten"
// could not see this: `createOAuthProvider` reads `provider.scope` (the
// filtered getter) and seeds storage when it comes back undefined. A
// persisted scope of `offline_access` alone therefore used to be silently
// overwritten by the configured scope, which is the one thing the filter
// promises not to do. Exercised through the manager, where the seeding
// branch actually lives.
it("does not overwrite a persisted scope that filters down to nothing", async () => {
const params = createMockParams();
const storage = params.initialConfig.storage;
if (!storage) throw new Error("expected mock storage");
vi.mocked(storage.getScope).mockResolvedValue("offline_access");
const manager = new OAuthManager(params);
manager.setOAuthConfig({ requestRefreshToken: false, scope: "mcp" });
const provider = await manager.createOAuthProviderForTransport();
expect(storage.saveScope).not.toHaveBeenCalled();
// Still requests the configured scope, without the declined token.
expect(provider.clientMetadata.scope).toBe("mcp");
});
it("declares the refresh_token grant when the opt-out is not configured", async () => {
const manager = new OAuthManager(createMockParams());