inspector/core/auth/store.ts
cliffhall 6273790077 fix: read the SDK's own discovery state instead of a marker of our own
Copilot review (#2287), two findings — and taken together they said the marker
was the wrong mechanism, so this removes it rather than patching it again:

- A transient failure in our CIMD preflight recorded no marker, so a *successful*
  SDK fallback discovery that advertised CIMD was still stored as `dcr`.
- `existing` and `existingKind` could describe different registrations, because
  `clientInformation()` answers preregistered-first while `clientRegistrationKind()`
  prefers the issuer's dynamic slot.

SDK v2 `auth()` persists the authorization-server metadata via
`saveDiscoveryState` *before* it reads or writes client information, and it takes
its URL-based-client-ID branch — rather than `registerClient` — exactly when that
metadata advertises `client_id_metadata_document_supported` and a
`clientMetadataUrl` is configured. So the branch is not inferred at all now: it is
read back from the state the SDK itself just wrote.

`resolveSdkRegistrationKind` becomes two cases. A save for an issuer that already
has a registration under this `client_id` is a back-stamp, answered by that
registration's recorded kind — so an existing DCR on the metadata URL stays
`dcr`. A save for an issuer with none is a new registration, answered by the
discovery state, guarded on the metadata describing that same issuer.

This drops `cimdClientMetadataUrl` and its storage accessors entirely, and
restores `ensureCimdClientRegistration` to a plain issuer-bound pre-registration.
Invalid-client recovery, second-issuer CIMD, second-issuer DCR minting the
metadata URL, and preflight failure are all consequences of the two cases rather
than separate mechanisms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 02:03:12 -04:00

164 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 ?? {}) };
}
}