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>
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
/**
|
||
* In-memory OAuth state store (servers + IdP sessions).
|
||
* Persisted via `OAuthStorageBase` + `OAuthPersistBackend` backends.
|
||
*/
|
||
|
||
import type {
|
||
OAuthClientInformation,
|
||
OAuthTokens,
|
||
OAuthMetadata,
|
||
OAuthDiscoveryState,
|
||
} from "@modelcontextprotocol/client";
|
||
import type {
|
||
IdpSessionState,
|
||
OAuthClientRegistrationKind,
|
||
} from "./storage.js";
|
||
import type { OAuthPersistSnapshot } from "./oauth-persist.js";
|
||
|
||
/**
|
||
* OAuth credentials bound to a single authorization-server `issuer` (SEP-2352).
|
||
*
|
||
* Client identifiers are unique to the AS that issued them (RFC 6749 §2.2), and
|
||
* tokens minted by one AS must never be presented to another. The SDK v2 stamps
|
||
* `issuer` on the objects it saves and passes `ctx.issuer` on every read/write;
|
||
* this record is how the Inspector keys that state per authorization server so a
|
||
* resource that migrates between ASes re-registers/re-authorizes cleanly instead
|
||
* of reusing mismatched credentials.
|
||
*/
|
||
export interface IssuerBoundOAuthState {
|
||
clientInformation?: OAuthClientInformation;
|
||
/** Set when {@link clientInformation} is saved — DCR vs CIMD. */
|
||
clientRegistrationKind?: OAuthClientRegistrationKind;
|
||
tokens?: OAuthTokens;
|
||
}
|
||
|
||
/**
|
||
* OAuth state for a single server.
|
||
*
|
||
* Issuer-bound credentials (client info + tokens) live under {@link byIssuer},
|
||
* keyed by AS `issuer` (SEP-2352). Per-server / per-flow state (code verifier,
|
||
* discovered metadata, discovery state, static client info, EMA marker, and the
|
||
* requested-scope seed) is issuer-independent and stays at the top level.
|
||
*
|
||
* The bare top-level {@link clientInformation} / {@link tokens} fields are the
|
||
* **legacy unkeyed fallback**: a pre-1625 snapshot deserializes with credentials
|
||
* here and no `issuer` stamp. Reads fall back to them when {@link byIssuer} has
|
||
* no entry; the first issuer-stamped save promotes them into {@link byIssuer} and
|
||
* clears the fallback (lazy migration — see `OAuthStorageBase`). New saves never
|
||
* write the bare fields.
|
||
*/
|
||
export interface ServerOAuthState {
|
||
/** Per-issuer credentials (SEP-2352). */
|
||
byIssuer?: Record<string, IssuerBoundOAuthState>;
|
||
/** Most-recently-saved issuer — answers ctx-less reads (per-request bearer token). */
|
||
activeIssuer?: string;
|
||
preregisteredClientInformation?: OAuthClientInformation;
|
||
codeVerifier?: string;
|
||
scope?: string;
|
||
serverMetadata?: OAuthMetadata;
|
||
/** RFC 9728/8414 discovery cache; persisted alongside {@link codeVerifier} (SEP-2352 callback-leg binding). */
|
||
discoveryState?: OAuthDiscoveryState;
|
||
/** Set when resource tokens were minted via EMA (legs 2–3). */
|
||
enterpriseManaged?: boolean;
|
||
|
||
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
|
||
clientInformation?: OAuthClientInformation;
|
||
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
|
||
clientRegistrationKind?: OAuthClientRegistrationKind;
|
||
/** @deprecated Legacy unkeyed fallback — see {@link ServerOAuthState}. */
|
||
tokens?: OAuthTokens;
|
||
}
|
||
|
||
/**
|
||
* OAuth store state (all servers) plus mutation helpers.
|
||
*/
|
||
export interface OAuthStoreState {
|
||
servers: Record<string, ServerOAuthState>;
|
||
idpSessions: Record<string, IdpSessionState>;
|
||
getServerState: (serverUrl: string) => ServerOAuthState;
|
||
setServerState: (serverUrl: string, state: Partial<ServerOAuthState>) => void;
|
||
clearServerState: (serverUrl: string) => void;
|
||
getIdpSession: (issuer: string) => IdpSessionState;
|
||
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => void;
|
||
clearIdpSession: (issuer: string) => void;
|
||
clearEnterpriseManagedResourceServers: () => void;
|
||
}
|
||
|
||
/**
|
||
* Mutable in-memory OAuth state keyed by server URL and IdP issuer.
|
||
*/
|
||
export class OAuthMemoryStore {
|
||
private servers: Record<string, ServerOAuthState> = {};
|
||
private idpSessions: Record<string, IdpSessionState> = {};
|
||
|
||
constructor(initial?: OAuthPersistSnapshot) {
|
||
if (initial) {
|
||
this.replace(initial);
|
||
}
|
||
}
|
||
|
||
getState(): OAuthStoreState {
|
||
return {
|
||
servers: this.servers,
|
||
idpSessions: this.idpSessions,
|
||
getServerState: (serverUrl: string) => {
|
||
return this.servers[serverUrl] || {};
|
||
},
|
||
setServerState: (
|
||
serverUrl: string,
|
||
updates: Partial<ServerOAuthState>,
|
||
) => {
|
||
this.servers = {
|
||
...this.servers,
|
||
[serverUrl]: {
|
||
...this.servers[serverUrl],
|
||
...updates,
|
||
},
|
||
};
|
||
},
|
||
clearServerState: (serverUrl: string) => {
|
||
const rest = { ...this.servers };
|
||
delete rest[serverUrl];
|
||
this.servers = rest;
|
||
},
|
||
getIdpSession: (issuer: string) => {
|
||
return this.idpSessions[issuer] || {};
|
||
},
|
||
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => {
|
||
this.idpSessions = {
|
||
...this.idpSessions,
|
||
[issuer]: {
|
||
...this.idpSessions[issuer],
|
||
...updates,
|
||
},
|
||
};
|
||
},
|
||
clearIdpSession: (issuer: string) => {
|
||
const rest = { ...this.idpSessions };
|
||
delete rest[issuer];
|
||
this.idpSessions = rest;
|
||
},
|
||
clearEnterpriseManagedResourceServers: () => {
|
||
const rest = { ...this.servers };
|
||
for (const [url, serverState] of Object.entries(this.servers)) {
|
||
if (serverState.enterpriseManaged === true) {
|
||
delete rest[url];
|
||
}
|
||
}
|
||
this.servers = rest;
|
||
},
|
||
};
|
||
}
|
||
|
||
snapshot(): OAuthPersistSnapshot {
|
||
return {
|
||
servers: { ...this.servers },
|
||
idpSessions: { ...this.idpSessions },
|
||
};
|
||
}
|
||
|
||
replace(snapshot: OAuthPersistSnapshot): void {
|
||
this.servers = { ...(snapshot.servers ?? {}) };
|
||
this.idpSessions = { ...(snapshot.idpSessions ?? {}) };
|
||
}
|
||
}
|