Merge branch 'v2/main' into v2/fix/2117-persist-requested-scope
#2114 landed on v2/main, which is the PR this one follows up. Two conflicts, one of them silent. The marked conflict was cosmetic: keep v2/main's comment on the `provider.scope` read, which now notes the getter filters (#2068). The real one git merged cleanly and would have left dead: #2114's if (this.oauthConfig.requestRefreshToken === false && requestedScope) { this.pendingAuthorizationScope = requestedScope; } sat directly above this branch's unconditional `recordAuthorizationRequestScope`, which overwrites the same field a few lines later. Dropping that gate is what #2117 is for, so it is removed rather than kept as a no-op. The two changes compose: the SDK adds `offline_access` only when `clientMetadata.grant_types` includes `refresh_token`, which the provider drops while the opt-out is on -- so reading the authorize URL yields the filtered scope there, exactly what #2114's gated branch persisted. #2114's third case pinned the gate and inverts here, as its issue predicted. It now asserts the request is persisted even with the grant on; that records no new claim, since the request equals what storage already held. 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:
commit
71b960e078
1 changed files with 1424 additions and 8 deletions
|
|
@ -48,6 +48,7 @@ import {
|
|||
isStrictScopeSuperset,
|
||||
resolveEffectiveGrantedScope,
|
||||
resolvePersistedScopeAfterGrant,
|
||||
scopeForDeclinedRefreshGrant,
|
||||
} from "../auth/scopes.js";
|
||||
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
|
||||
import type {
|
||||
|
|
@ -105,6 +106,8 @@ export class OAuthManager {
|
|||
authorizationParams?: Record<string, string>;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
/** Declare the `refresh_token` grant in client metadata (#2068). */
|
||||
requestRefreshToken?: boolean;
|
||||
}): void {
|
||||
this.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
|
|
@ -176,6 +179,13 @@ export class OAuthManager {
|
|||
// read from mcp.json, and nothing in the flow needs to persist or union
|
||||
// them.
|
||||
authorizationParams: this.oauthConfig.authorizationParams,
|
||||
// #2068: per-server opt-out of the `refresh_token` grant. Like
|
||||
// `authorizationParams` this is pure config off mcp.json, so it lives on
|
||||
// the provider rather than in OAuth storage.
|
||||
requestRefreshToken: this.oauthConfig.requestRefreshToken,
|
||||
// #2068: lets the provider tell an `offline_access` the user asked for
|
||||
// from one inherited from a previous grant's persisted scope.
|
||||
configuredScope: this.oauthConfig.scope,
|
||||
});
|
||||
|
||||
provider.setEventTarget(this.params.getEventTarget());
|
||||
|
|
@ -305,6 +315,8 @@ export class OAuthManager {
|
|||
resourceMetadataUrl,
|
||||
});
|
||||
|
||||
// Read once: the getter filters (#2068), so this is what was actually
|
||||
// requested and what the callback leg has to persist.
|
||||
const requestedScope = provider.scope;
|
||||
const result = await mcpAuth(provider, {
|
||||
serverUrl,
|
||||
|
|
@ -356,6 +368,15 @@ export class OAuthManager {
|
|||
* and the client asks for a refresh token, so the input can be a strict
|
||||
* subset of what was actually requested. The fallback covers an authorize
|
||||
* URL that carries no `scope` parameter at all.
|
||||
*
|
||||
* This composes with the #2068 refresh-token opt-out rather than defeating
|
||||
* it. The SDK's augmentation is gated on `clientMetadata.grant_types`
|
||||
* including `refresh_token`, which the provider drops when the opt-out is
|
||||
* on — so with the grant declined the URL carries the *filtered* scope, and
|
||||
* that is what gets persisted. #2068 recorded the same value here behind a
|
||||
* `requestRefreshToken === false` gate; reading the URL makes the gate
|
||||
* unnecessary, so one wire case now has one persistence behavior instead of
|
||||
* two selected by a checkbox (#2117).
|
||||
*/
|
||||
private recordAuthorizationRequestScope(
|
||||
authorizationUrl: URL,
|
||||
|
|
@ -789,13 +810,37 @@ export class OAuthManager {
|
|||
resourceMetadataUrl,
|
||||
});
|
||||
|
||||
const scopeForAuth =
|
||||
// #2068 — the step-up union is built from *raw* storage
|
||||
// (`enrichChallengeWithScopes`) and handed to the SDK directly, so it never
|
||||
// passes through the provider's filtering `scope` getter. Without this an
|
||||
// `offline_access` inherited from an earlier grant reappears here and the
|
||||
// SDK re-adds `prompt=consent` even with the setting off — the same failure
|
||||
// the option exists to prevent, on the one path that bypasses it. A scope
|
||||
// the challenge itself requires is preserved, since stripping that would
|
||||
// just re-earn the same challenge.
|
||||
const requestedScopeForAuth =
|
||||
enriched.reason === "insufficient_scope"
|
||||
? enriched.authorizationScopes?.join(" ")
|
||||
: this.oauthConfig.scope?.trim() ||
|
||||
(enriched.requiredScopes?.length
|
||||
? enriched.requiredScopes.join(" ")
|
||||
: undefined);
|
||||
const scopeForAuth =
|
||||
this.oauthConfig.requestRefreshToken === false
|
||||
? scopeForDeclinedRefreshGrant(
|
||||
requestedScopeForAuth,
|
||||
this.oauthConfig.scope,
|
||||
// The RAW challenge scopes, not `enriched.requiredScopes`:
|
||||
// `enrichChallengeWithScopes` narrows that to the *missing* subset,
|
||||
// so a challenge requiring `offline_access tools:write` against a
|
||||
// stored scope that already has `offline_access` arrives here as
|
||||
// just `["tools:write"]`. Passing the narrowed list would read the
|
||||
// server's explicit requirement as inherited and strip it — then
|
||||
// re-authorization earns the same challenge forever, which is the
|
||||
// loop this argument exists to prevent.
|
||||
challenge.requiredScopes,
|
||||
)
|
||||
: requestedScopeForAuth;
|
||||
|
||||
provider.setSuppressAuthorizationNavigation(true);
|
||||
let result: Awaited<ReturnType<typeof mcpAuth>>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue