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>
This commit is contained in:
parent
f915889fc4
commit
5feebf25c2
1 changed files with 1527 additions and 601 deletions
|
|
@ -2,6 +2,7 @@ import type {
|
|||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
OAuthDiscoveryState,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { OAuthClientRegistrationKind } from "./types.js";
|
||||
|
||||
|
|
@ -12,12 +13,23 @@ import type { OAuthClientRegistrationKind } from "./types.js";
|
|||
export interface SaveTokensOptions {
|
||||
/** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */
|
||||
enterpriseManaged?: boolean;
|
||||
/**
|
||||
* Authorization-server `issuer` these tokens are bound to (SEP-2352). When set,
|
||||
* tokens are keyed under `(server, issuer)`; when omitted (EMA / legacy callers)
|
||||
* they write the per-server fallback slot.
|
||||
*/
|
||||
issuer?: string;
|
||||
}
|
||||
|
||||
export type { OAuthClientRegistrationKind };
|
||||
|
||||
export interface SaveClientInformationOptions {
|
||||
registrationKind: "dcr" | "cimd";
|
||||
/**
|
||||
* Authorization-server `issuer` this client registration is bound to (SEP-2352).
|
||||
* Client identifiers are unique to the AS that issued them (RFC 6749 §2.2).
|
||||
*/
|
||||
issuer?: string;
|
||||
}
|
||||
|
||||
export interface OAuthStorage {
|
||||
|
|
@ -29,11 +41,16 @@ export interface OAuthStorage {
|
|||
load(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get client information (preregistered or dynamically registered)
|
||||
* Get client information (preregistered or dynamically registered).
|
||||
*
|
||||
* @param issuer - When set, return the registration bound to this AS `issuer`
|
||||
* (SEP-2352). When omitted, return the active-issuer slot, falling back to
|
||||
* the legacy unkeyed entry.
|
||||
*/
|
||||
getClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
issuer?: string,
|
||||
): Promise<OAuthClientInformation | undefined>;
|
||||
|
||||
/**
|
||||
|
|
@ -41,6 +58,7 @@ export interface OAuthStorage {
|
|||
*/
|
||||
getClientRegistrationKind(
|
||||
serverUrl: string,
|
||||
issuer?: string,
|
||||
): Promise<OAuthClientRegistrationKind | undefined>;
|
||||
|
||||
/**
|
||||
|
|
@ -61,17 +79,25 @@ export interface OAuthStorage {
|
|||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear client information
|
||||
* Clear client information. When `issuer` is set, clear only that AS's
|
||||
* registration; when omitted, clear every issuer's registration plus the
|
||||
* legacy unkeyed entry.
|
||||
*/
|
||||
clearClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
issuer?: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get OAuth tokens
|
||||
* Get OAuth tokens. When `issuer` is set, return that AS's tokens (SEP-2352);
|
||||
* when omitted, return the active-issuer tokens, falling back to the legacy
|
||||
* unkeyed entry (the transport's per-request bearer read).
|
||||
*/
|
||||
getTokens(serverUrl: string): Promise<OAuthTokens | undefined>;
|
||||
getTokens(
|
||||
serverUrl: string,
|
||||
issuer?: string,
|
||||
): Promise<OAuthTokens | undefined>;
|
||||
|
||||
/**
|
||||
* Save OAuth tokens
|
||||
|
|
@ -83,9 +109,10 @@ export interface OAuthStorage {
|
|||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear OAuth tokens
|
||||
* Clear OAuth tokens. When `issuer` is set, clear only that AS's tokens; when
|
||||
* omitted, clear every issuer's tokens plus the legacy unkeyed entry.
|
||||
*/
|
||||
clearTokens(serverUrl: string): Promise<void>;
|
||||
clearTokens(serverUrl: string, issuer?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get code verifier (for PKCE)
|
||||
|
|
@ -132,6 +159,29 @@ export interface OAuthStorage {
|
|||
*/
|
||||
clearServerMetadata(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the cached RFC 9728/8414 discovery state (SEP-2352). The SDK restores it
|
||||
* to skip re-discovery and, on the authorization-code callback leg, to bind the
|
||||
* exchange to the AS that minted the code (`AuthorizationServerMismatchError`).
|
||||
*/
|
||||
getDiscoveryState(
|
||||
serverUrl: string,
|
||||
): Promise<OAuthDiscoveryState | undefined>;
|
||||
|
||||
/**
|
||||
* Save the RFC 9728/8414 discovery state. Persisted alongside the code verifier
|
||||
* so it survives the authorization redirect round-trip.
|
||||
*/
|
||||
saveDiscoveryState(
|
||||
serverUrl: string,
|
||||
state: OAuthDiscoveryState,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear the cached discovery state (SDK `invalidateCredentials('discovery')`).
|
||||
*/
|
||||
clearDiscoveryState(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all OAuth data for a server
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue