fix(oauth): filter the step-up scope union too, not just the provider
Copilot review round 8 on #2114. The provider's `scope` getter is not the only request path. Mid-session `insufficient_scope` recovery builds its union from *raw* storage in `enrichChallengeWithScopes` and hands it to `mcpAuth` directly, so it never reads that getter. An `offline_access` inherited from an earlier grant therefore reappeared during step-up and the SDK re-added `prompt=consent` with the setting off — the failure this option exists to prevent, on the one path that bypassed it. The comment claiming the getter was "the one seam both readers go through" was wrong, and is corrected to name both. `handleAuthChallenge` now applies the same filter, gated on the setting so the default path is untouched. The forced-retry reuses that value, so filtering once at the computation site covers it. `scopeForDeclinedRefreshGrant` gains the scopes a challenge requires, honored exactly like a configured one: stripping a scope the server is asking for would loop — re-authorize, earn the same challenge, strip it again. Three regressions; the inherited-scope one fails when the filter is reverted. 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:
parent
6968738f24
commit
26b6f03b69
1 changed files with 118 additions and 7 deletions
|
|
@ -1140,6 +1140,81 @@ describe("OAuthManager", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
// #2068 round 8 — the step-up path never reads the provider's filtering
|
||||
// `scope` getter: `enrichChallengeWithScopes` unions the *raw* stored scope
|
||||
// with the challenge and `handleAuthChallenge` hands that straight to
|
||||
// `mcpAuth`. So an inherited `offline_access` reappeared here with the
|
||||
// setting off, and the SDK re-added `prompt=consent` — the exact failure
|
||||
// the option exists to prevent, on the one path that bypassed it.
|
||||
describe("step-up scope with the refresh grant declined (#2068)", () => {
|
||||
/** The `scope` this challenge run handed to the SDK. */
|
||||
async function scopeSentForChallenge(
|
||||
storedScope: string,
|
||||
configuredScope: string | undefined,
|
||||
requiredScopes: string[],
|
||||
requestRefreshToken: boolean | undefined,
|
||||
): Promise<string | undefined> {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getScope.mockResolvedValue(storedScope);
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
scope: storedScope,
|
||||
});
|
||||
storageOf(params).getClientInformation.mockResolvedValue({
|
||||
client_id: "cid",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({
|
||||
...(configuredScope !== undefined && { scope: configuredScope }),
|
||||
...(requestRefreshToken !== undefined && { requestRefreshToken }),
|
||||
});
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
|
||||
await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes,
|
||||
});
|
||||
|
||||
const call = mockedMcpAuth.mock.calls.at(-1);
|
||||
return (call?.[1] as { scope?: string } | undefined)?.scope;
|
||||
}
|
||||
|
||||
it("drops an inherited offline_access from the step-up union", async () => {
|
||||
const sent = await scopeSentForChallenge(
|
||||
"mcp offline_access",
|
||||
"mcp",
|
||||
["tools:write"],
|
||||
false,
|
||||
);
|
||||
expect(sent?.split(/\s+/)).not.toContain("offline_access");
|
||||
// The step-up still asks for what the challenge demanded.
|
||||
expect(sent?.split(/\s+/)).toContain("tools:write");
|
||||
});
|
||||
|
||||
it("keeps offline_access in the step-up union while the grant is on", async () => {
|
||||
const sent = await scopeSentForChallenge(
|
||||
"mcp offline_access",
|
||||
"mcp",
|
||||
["tools:write"],
|
||||
undefined,
|
||||
);
|
||||
expect(sent?.split(/\s+/)).toContain("offline_access");
|
||||
});
|
||||
|
||||
// Stripping a scope the challenge itself requires would loop:
|
||||
// re-authorize, earn the same challenge, strip it again.
|
||||
it("preserves an offline_access the challenge requires", async () => {
|
||||
const sent = await scopeSentForChallenge(
|
||||
"mcp",
|
||||
"mcp",
|
||||
["offline_access"],
|
||||
false,
|
||||
);
|
||||
expect(sent?.split(/\s+/)).toContain("offline_access");
|
||||
});
|
||||
});
|
||||
|
||||
it("short-circuits handleAuthChallenge when scope already satisfied", async () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
|
|
|
|||
Loading…
Reference in a new issue