OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1694)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625) Re-key the persisted OAuth store to (server, issuer) and add the SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it and round-trip the issuer stamp. SEP-2352 — issuer-bound credentials - ServerOAuthState nests issuer-bound credentials under byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with an activeIssuer pointer answering the transport's ctx-less per-request bearer read. - Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The store re-attaches the stamp from the byIssuer key. - BaseOAuthClientProvider threads ctx.issuer and implements discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg AuthorizationServerMismatchError binding check) and invalidateCredentials(). - Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level clientInformation/tokens are returned as an unkeyed fallback until the first issuer-stamped save promotes them into byIssuer. SEP-837 — application_type - DCR declares application_type: "native" (localhost Inspector). - ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status. SEP-2350 — step-up scope union - Per-server onInsufficientScope (reauthorize | throw) setting wired ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport. - StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each scope `new` vs `already granted`. mcpAuth simplification - Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping); forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth() so both the initial and step-up paths get SEP-2352 stamping. Vocabulary + docs - OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a step-up redirect); the Network tab labels auth requests by OAuth phase. - v2_auth_hardening.md gains an as-built status section. Deferred to the era-aware card: the SdkError(EraNegotiationFailed) -> UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under non-legacy negotiation). Closes #1625 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match - SEP-2352: stamp the issuer only when the credential actually came from the byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated slot alongside a surviving legacy field would otherwise stamp a legacy (possibly cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch. - Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer` (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion). - oauthNetworkPhase matches register/token/authorize as the final path segment (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer over-match. - Tests for all three. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f915889fc4
commit
7fecebed5e
1 changed files with 1620 additions and 601 deletions
|
|
@ -7,6 +7,7 @@ import type {
|
|||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthDiscoveryState,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js";
|
||||
import { generateOAuthState } from "./utils.js";
|
||||
|
|
@ -178,6 +179,13 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
client_name: "MCP Inspector",
|
||||
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||
scope: this.scope ?? "",
|
||||
// SEP-837: the Inspector is a locally-hosted app reached over localhost, so
|
||||
// it registers as a native client. OIDC-flavored ASes default an omitted
|
||||
// `application_type` to `"web"`, which forbids loopback redirect URIs and
|
||||
// rejects DCR. (The SDK also infers `"native"` from loopback `redirect_uris`;
|
||||
// declaring it explicitly keeps the value visible and correct even when the
|
||||
// redirect host is not itself a loopback literal.)
|
||||
application_type: "native",
|
||||
};
|
||||
|
||||
// Note: clientMetadataUrl for CIMD mode is passed to registerClient() directly,
|
||||
|
|
@ -190,8 +198,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
return generateOAuthState();
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
// Try preregistered first, then dynamically registered
|
||||
async clientInformation(
|
||||
ctx?: OAuthClientInformationContext,
|
||||
): Promise<OAuthClientInformation | undefined> {
|
||||
// Try preregistered (static, issuer-independent) first, then the per-issuer
|
||||
// dynamic registration (SEP-2352 — keyed by `ctx.issuer`).
|
||||
const preregistered = await this.storage.getClientInformation(
|
||||
this.serverUrl,
|
||||
true,
|
||||
|
|
@ -199,7 +210,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
if (preregistered) {
|
||||
return preregistered;
|
||||
}
|
||||
return await this.storage.getClientInformation(this.serverUrl, false);
|
||||
return await this.storage.getClientInformation(
|
||||
this.serverUrl,
|
||||
false,
|
||||
ctx?.issuer,
|
||||
);
|
||||
}
|
||||
|
||||
async saveClientInformation(
|
||||
|
|
@ -207,19 +222,22 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
// SDK v2's `OAuthClientProvider.saveClientInformation` passes an
|
||||
// `OAuthClientInformationContext` ({ issuer }); our own DCR/CIMD callers
|
||||
// pass `SaveClientInformationOptions` ({ registrationKind }). Accept either
|
||||
// and narrow on the discriminating key. Per-issuer keying (SEP-2352) is a
|
||||
// later card — for now we only read the registration kind.
|
||||
// and read whichever keys are present: the SDK supplies `issuer` (SEP-2352
|
||||
// per-AS keying) and defaults registration kind to DCR; our callers supply
|
||||
// the registration kind and no issuer yet.
|
||||
options?: SaveClientInformationOptions | OAuthClientInformationContext,
|
||||
): Promise<void> {
|
||||
const registrationKind =
|
||||
options && "registrationKind" in options
|
||||
? options.registrationKind
|
||||
: "dcr";
|
||||
const issuer = options && "issuer" in options ? options.issuer : undefined;
|
||||
await this.storage.saveClientInformation(
|
||||
this.serverUrl,
|
||||
clientInformation,
|
||||
{
|
||||
registrationKind,
|
||||
issuer,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -238,12 +256,19 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
);
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return await this.storage.getTokens(this.serverUrl);
|
||||
async tokens(
|
||||
ctx?: OAuthClientInformationContext,
|
||||
): Promise<OAuthTokens | undefined> {
|
||||
return await this.storage.getTokens(this.serverUrl, ctx?.issuer);
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await this.storage.saveTokens(this.serverUrl, tokens);
|
||||
async saveTokens(
|
||||
tokens: OAuthTokens,
|
||||
ctx?: OAuthClientInformationContext,
|
||||
): Promise<void> {
|
||||
await this.storage.saveTokens(this.serverUrl, tokens, {
|
||||
issuer: ctx?.issuer,
|
||||
});
|
||||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL): void {
|
||||
|
|
@ -285,4 +310,47 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
async saveServerMetadata(metadata: OAuthMetadata): Promise<void> {
|
||||
await this.storage.saveServerMetadata(this.serverUrl, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEP-2352 discovery-state round-trip. The SDK persists RFC 9728/8414 discovery
|
||||
* here (alongside the code verifier) so that on the authorization-code callback
|
||||
* leg it can compare the resolved AS `issuer` against the one recorded at
|
||||
* redirect time and reject a mismatch (`AuthorizationServerMismatchError`).
|
||||
* Without these two methods the SDK only `console.warn`s and the binding check
|
||||
* is inactive.
|
||||
*/
|
||||
async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
|
||||
await this.storage.saveDiscoveryState(this.serverUrl, state);
|
||||
}
|
||||
|
||||
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
||||
return this.storage.getDiscoveryState(this.serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEP-2352 credential invalidation. The SDK calls this to drop credentials the
|
||||
* server has rejected; hosts also call `'discovery'` on repeated 401s so a
|
||||
* changed `authorization_servers` list is re-fetched.
|
||||
*/
|
||||
async invalidateCredentials(
|
||||
scope: "all" | "client" | "tokens" | "verifier" | "discovery",
|
||||
): Promise<void> {
|
||||
switch (scope) {
|
||||
case "all":
|
||||
await this.storage.clear(this.serverUrl);
|
||||
return;
|
||||
case "client":
|
||||
await this.storage.clearClientInformation(this.serverUrl);
|
||||
return;
|
||||
case "tokens":
|
||||
await this.storage.clearTokens(this.serverUrl);
|
||||
return;
|
||||
case "verifier":
|
||||
await this.storage.clearCodeVerifier(this.serverUrl);
|
||||
return;
|
||||
case "discovery":
|
||||
await this.storage.clearDiscoveryState(this.serverUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue