fix(oauth): drop an inherited offline_access when the grant is declined
Copilot review round 4 on #2114 — a real gap, not a wording one. Declining the grant stops the SDK *adding* `offline_access`, but the Inspector's own storage puts it back. A successful default-on authorization persists the scope the AS granted (`resolvePersistedScopeAfterGrant`), `offline_access` included; `prepareForAuth` reloads it and `authenticate` hands it to the SDK as `scope`, which sees the token in scope and appends `prompt=consent` again. So any server that had authorized once could uncheck the box and still hit AADSTS90094 — the users most likely to reach for the setting were the ones it did not help. `scopeForDeclinedRefreshGrant` filters it out of the *request* only, applied in the provider's `scope` getter — the one seam both `clientMetadata.scope` and `authenticate`'s `scope:` argument read through. An `offline_access` the user configured is honored; storage is never rewritten, so re-checking the box restores the old behavior instead of finding the value destroyed, and scope-satisfaction checks still see the real grant. Verified by reverting the filter: the new e2e regression fails. Also from that round, both previously suppressed: The checkbox looked effective under enterprise-managed authorization, which never builds this provider at all — it authorizes against the enterprise IdP with a fixed `openid offline_access` (`ema/constants.ts:16`). It now annotates itself the way the endpoint overrides do. The existing count assertion covered two such notices and was named for the overrides alone; it is renamed and expects three, with a dedicated case pinning the notice to this control so the count cannot be met by three copies elsewhere. And "obtains no new refresh token" was another overstatement — the AS decides issuance, and a preconfigured client is reused with whatever its registration already permits. Replaced with what the setting actually controls. 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
6303f8a6ce
commit
89dedd8dea
1 changed files with 323 additions and 4 deletions
|
|
@ -12,6 +12,7 @@ import type {
|
|||
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
|
||||
import { generateOAuthState } from "./utils.js";
|
||||
import { applyAuthorizationParams } from "./authorizationParams.js";
|
||||
import { scopeForDeclinedRefreshGrant } from "./scopes.js";
|
||||
|
||||
/**
|
||||
* Redirect URL provider. Returns the redirect URL for OAuth flows.
|
||||
|
|
@ -125,6 +126,16 @@ export type OAuthProviderConfig = {
|
|||
* its own a guarantee that `prompt=consent` is absent.
|
||||
*/
|
||||
requestRefreshToken?: boolean;
|
||||
/**
|
||||
* The scope configured for this server (mcp.json / Server Settings), as
|
||||
* distinct from the scope carried in OAuth storage after a previous grant.
|
||||
*
|
||||
* Used only to decide whether an `offline_access` in the effective scope was
|
||||
* *asked for* or merely inherited — see {@link scopeForDeclinedRefreshGrant}.
|
||||
* An explicitly configured one is honored; an inherited one is dropped from
|
||||
* the request when {@link requestRefreshToken} is false. (#2068)
|
||||
*/
|
||||
configuredScope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -148,6 +159,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
protected authorizationParams?: Record<string, string>;
|
||||
/** Declare the `refresh_token` grant in {@link clientMetadata} (#2068). */
|
||||
protected requestRefreshToken: boolean;
|
||||
/** Server-configured scope, for the #2068 `offline_access` distinction. */
|
||||
protected configuredScope?: string;
|
||||
|
||||
constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) {
|
||||
this.serverUrl = serverUrl;
|
||||
|
|
@ -157,6 +170,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
|
||||
this.authorizationParams = oauthConfig.authorizationParams;
|
||||
this.requestRefreshToken = oauthConfig.requestRefreshToken ?? true;
|
||||
this.configuredScope = oauthConfig.configuredScope;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -194,6 +208,16 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
get scope(): string | undefined {
|
||||
// #2068: filter at the point of *request*, not in storage. This getter is
|
||||
// the one seam both readers go through — `clientMetadata.scope` below, and
|
||||
// the `scope:` argument `OAuthManager.authenticate` hands to the SDK — so
|
||||
// filtering here covers both without a second derivation that could drift.
|
||||
if (!this.requestRefreshToken) {
|
||||
return scopeForDeclinedRefreshGrant(
|
||||
this.cachedScope,
|
||||
this.configuredScope,
|
||||
);
|
||||
}
|
||||
return this.cachedScope;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue