import type { OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthDiscoveryState, } from "@modelcontextprotocol/client"; import { OAuthClientInformationSchema, OAuthTokensSchema, } from "@modelcontextprotocol/core"; import type { OAuthStorage, RevocationSnapshot } from "./storage.js"; import { type OAuthMemoryStore, type ServerOAuthState, type IssuerBoundOAuthState, } from "./store.js"; import type { OAuthPersistBackend } from "./oauth-persist.js"; import type { IdpSessionState, OAuthClientRegistrationKind, SaveClientInformationOptions, SaveTokensOptions, } from "./storage.js"; /** * Re-attach the `issuer` stamp (SEP-2352) that `OAuthTokensSchema` / * `OAuthClientInformationSchema` strip on parse. The stamp is the `byIssuer` key, * so it is recovered from the key rather than the parsed value — this is what lets * the SDK's `discardIfIssuerMismatch` reject cross-AS credential reuse. */ function withIssuer(value: T, issuer: string | undefined): T { return issuer === undefined ? value : { ...value, issuer }; } /** * Concrete OAuthStorage implementation backed by in-memory state and an explicit * persist backend (file, remote HTTP, sessionStorage, …). */ export class OAuthStorageBase implements OAuthStorage { private loaded = false; private loadPromise: Promise | undefined; /** Serializes persist writes so concurrent mutators cannot reorder POSTs. */ private persistQueue: Promise = Promise.resolve(); private readonly memory: OAuthMemoryStore; private readonly backend: OAuthPersistBackend; constructor(memory: OAuthMemoryStore, backend: OAuthPersistBackend) { this.memory = memory; this.backend = backend; } load(): Promise { if (!this.loadPromise) { this.loadPromise = this.doLoad(); } return this.loadPromise; } private async doLoad(): Promise { if (this.loaded) { return; } const snapshot = await this.backend.read(); if (snapshot) { this.memory.replace(snapshot); } this.loaded = true; } private async ensureLoaded(): Promise { await this.load(); } private async persist(): Promise { const snapshot = this.memory.snapshot(); const prior = this.persistQueue; const tracked = prior .catch(() => {}) .then(() => this.backend.write(snapshot)); this.persistQueue = tracked; await tracked; } /** * Resolve the AS-`issuer` key whose credentials answer a read. An explicit * `issuer` (from the SDK's `ctx.issuer`) wins; otherwise the most-recently-saved * `activeIssuer` answers the transport's ctx-less per-request bearer read. */ private resolveReadIssuer( state: ServerOAuthState, issuer?: string, ): string | undefined { return issuer ?? state.activeIssuer; } /** Read the issuer-bound slot for a read, if one exists. */ private issuerSlot( state: ServerOAuthState, issuer?: string, ): IssuerBoundOAuthState | undefined { const key = this.resolveReadIssuer(state, issuer); return key ? state.byIssuer?.[key] : undefined; } /** * Read-modify-write the `byIssuer[issuer]` slot, set `activeIssuer`, and clear * the given legacy top-level fallback fields (lazy migration — the fallback is * promoted into the keyed slot on first issuer-stamped save). */ private updateIssuerSlot( serverUrl: string, issuer: string, updates: Partial, clearLegacy: Partial, // Saves promote the issuer to `activeIssuer` (it answers ctx-less reads); // clears must not — clearing one AS's credentials shouldn't make it the // active one. setActive = true, ): void { const state = this.memory.getState().getServerState(serverUrl); const byIssuer = { ...state.byIssuer, [issuer]: { ...state.byIssuer?.[issuer], ...updates }, }; this.memory.getState().setServerState(serverUrl, { byIssuer, ...(setActive && { activeIssuer: issuer }), ...clearLegacy, }); } /** Apply `fn` to every issuer slot (used by issuer-agnostic clears). */ private mapIssuerSlots( state: ServerOAuthState, fn: (slot: IssuerBoundOAuthState) => Partial, ): Record { const byIssuer: Record = {}; for (const [key, slot] of Object.entries(state.byIssuer ?? {})) { byIssuer[key] = { ...slot, ...fn(slot) }; } return byIssuer; } async getClientInformation( serverUrl: string, isPreregistered?: boolean, issuer?: string, ): Promise { await this.ensureLoaded(); const state = this.memory.getState().getServerState(serverUrl); if (isPreregistered) { const prereg = state.preregisteredClientInformation; return prereg ? await OAuthClientInformationSchema.parseAsync(prereg) : undefined; } // Per-issuer registration (SEP-2352), falling back to the legacy unkeyed slot. const slot = this.issuerSlot(state, issuer); const fromSlot = slot?.clientInformation; const clientInfo = fromSlot ?? state.clientInformation; if (!clientInfo) { return undefined; } const parsed = await OAuthClientInformationSchema.parseAsync(clientInfo); // Stamp only when the value actually came from the issuer-keyed slot. Gating // on `fromSlot` (not merely on the slot existing) keeps a legacy unkeyed // credential — potentially minted by a *different* AS — from being stamped // with this issuer, which would defeat the SDK's discardIfIssuerMismatch. return withIssuer( parsed, fromSlot ? this.resolveReadIssuer(state, issuer) : undefined, ); } async getClientRegistrationKind( serverUrl: string, issuer?: string, ): Promise { await this.ensureLoaded(); const state = this.memory.getState().getServerState(serverUrl); return ( this.issuerSlot(state, issuer)?.clientRegistrationKind ?? state.clientRegistrationKind ); } async saveClientInformation( serverUrl: string, clientInformation: OAuthClientInformation, options: SaveClientInformationOptions, ): Promise { await this.ensureLoaded(); if (options.issuer !== undefined) { this.updateIssuerSlot( serverUrl, options.issuer, { clientInformation, clientRegistrationKind: options.registrationKind, }, { clientInformation: undefined, clientRegistrationKind: undefined }, ); } else { // No issuer yet (our own DCR/CIMD pre-registration callers): write the // unkeyed slot; the SDK promotes it per-issuer on the next stamped save. this.memory.getState().setServerState(serverUrl, { clientInformation, clientRegistrationKind: options.registrationKind, }); } await this.persist(); } async savePreregisteredClientInformation( serverUrl: string, clientInformation: OAuthClientInformation, ): Promise { await this.ensureLoaded(); this.memory.getState().setServerState(serverUrl, { preregisteredClientInformation: clientInformation, clientRegistrationKind: "static", }); await this.persist(); } async clearClientInformation( serverUrl: string, isPreregistered?: boolean, issuer?: string, ): Promise { await this.ensureLoaded(); if (isPreregistered) { this.memory.getState().setServerState(serverUrl, { preregisteredClientInformation: undefined, }); await this.persist(); return; } if (issuer !== undefined) { this.updateIssuerSlot( serverUrl, issuer, { clientInformation: undefined, clientRegistrationKind: undefined }, {}, false, ); } else { // Clear every issuer's registration plus the legacy unkeyed entry. const state = this.memory.getState().getServerState(serverUrl); const byIssuer = this.mapIssuerSlots(state, () => ({ clientInformation: undefined, clientRegistrationKind: undefined, })); this.memory.getState().setServerState(serverUrl, { byIssuer, clientInformation: undefined, clientRegistrationKind: undefined, }); } await this.persist(); } async getTokens( serverUrl: string, issuer?: string, ): Promise { await this.ensureLoaded(); const state = this.memory.getState().getServerState(serverUrl); const slot = this.issuerSlot(state, issuer); const fromSlot = slot?.tokens; const tokens = fromSlot ?? state.tokens; if (!tokens) { return undefined; } const parsed = await OAuthTokensSchema.parseAsync(tokens); // Stamp only when the value came from the issuer-keyed slot (see // getClientInformation) — never stamp a legacy unkeyed token with this issuer. return withIssuer( parsed, fromSlot ? this.resolveReadIssuer(state, issuer) : undefined, ); } async saveTokens( serverUrl: string, tokens: OAuthTokens, options?: SaveTokensOptions, ): Promise { await this.ensureLoaded(); if (options?.issuer !== undefined) { this.updateIssuerSlot( serverUrl, options.issuer, { tokens }, { tokens: undefined, ...(options.enterpriseManaged === true && { enterpriseManaged: true, }), }, ); } else { this.memory.getState().setServerState(serverUrl, { tokens, ...(options?.enterpriseManaged === true && { enterpriseManaged: true }), }); } await this.persist(); } async clearTokens(serverUrl: string, issuer?: string): Promise { await this.ensureLoaded(); if (issuer !== undefined) { this.updateIssuerSlot( serverUrl, issuer, { tokens: undefined }, {}, false, ); } else { const state = this.memory.getState().getServerState(serverUrl); const byIssuer = this.mapIssuerSlots(state, () => ({ tokens: undefined, })); this.memory .getState() .setServerState(serverUrl, { byIssuer, tokens: undefined }); } await this.persist(); } async getCodeVerifier(serverUrl: string): Promise { await this.ensureLoaded(); const state = this.memory.getState().getServerState(serverUrl); return state.codeVerifier; } async saveCodeVerifier( serverUrl: string, codeVerifier: string, ): Promise { await this.ensureLoaded(); this.memory.getState().setServerState(serverUrl, { codeVerifier }); await this.persist(); } async clearCodeVerifier(serverUrl: string): Promise { await this.ensureLoaded(); this.memory .getState() .setServerState(serverUrl, { codeVerifier: undefined }); await this.persist(); } async getScope(serverUrl: string): Promise { await this.ensureLoaded(); const state = this.memory.getState().getServerState(serverUrl); return state.scope; } async saveScope(serverUrl: string, scope: string | undefined): Promise { await this.ensureLoaded(); this.memory.getState().setServerState(serverUrl, { scope }); await this.persist(); } async clearScope(serverUrl: string): Promise { await this.ensureLoaded(); this.memory.getState().setServerState(serverUrl, { scope: undefined }); await this.persist(); } async getServerMetadata(serverUrl: string): Promise { await this.ensureLoaded(); const state = this.memory.getState().getServerState(serverUrl); return state.serverMetadata || null; } async saveServerMetadata( serverUrl: string, metadata: OAuthMetadata, ): Promise { await this.ensureLoaded(); this.memory .getState() .setServerState(serverUrl, { serverMetadata: metadata }); await this.persist(); } async clearServerMetadata(serverUrl: string): Promise { await this.ensureLoaded(); this.memory .getState() .setServerState(serverUrl, { serverMetadata: undefined }); await this.persist(); } async getDiscoveryState( serverUrl: string, ): Promise { await this.ensureLoaded(); return this.memory.getState().getServerState(serverUrl).discoveryState; } async saveDiscoveryState( serverUrl: string, state: OAuthDiscoveryState, ): Promise { await this.ensureLoaded(); this.memory.getState().setServerState(serverUrl, { discoveryState: state }); await this.persist(); } async clearDiscoveryState(serverUrl: string): Promise { await this.ensureLoaded(); this.memory .getState() .setServerState(serverUrl, { discoveryState: undefined }); await this.persist(); } async takeRevocationSnapshot(serverUrl: string): Promise { await this.ensureLoaded(); // Everything below is synchronous on purpose: read and clear against one // view of the in-memory state, so nothing can land between them. The // persist is the only await, and it happens after the mutation. const state = this.memory.getState().getServerState(serverUrl); const snapshot: RevocationSnapshot = { byIssuer: Object.fromEntries( Object.entries(state.byIssuer ?? {}).map(([issuer, slot]) => [ issuer, { tokens: slot?.tokens, clientInformation: slot?.clientInformation }, ]), ), legacyTokens: state.tokens, legacyClientInformation: state.clientInformation, preregisteredClientInformation: state.preregisteredClientInformation, serverMetadata: state.serverMetadata, }; this.memory.getState().clearServerState(serverUrl); await this.persist(); return snapshot; } async clear(serverUrl: string): Promise { await this.ensureLoaded(); this.memory.getState().clearServerState(serverUrl); await this.persist(); } async getIdpSession(issuer: string): Promise { await this.ensureLoaded(); const session = this.memory.getState().getIdpSession(issuer); if ( !session.idToken && !session.refreshToken && session.idTokenExpiresAt === undefined ) { return undefined; } return session; } async saveIdpSession( issuer: string, session: Partial, ): Promise { await this.ensureLoaded(); this.memory.getState().setIdpSession(issuer, session); await this.persist(); } async clearIdpSession(issuer: string): Promise { await this.ensureLoaded(); this.memory.getState().clearIdpSession(issuer); await this.persist(); } async clearEnterpriseManagedResourceServers(): Promise { await this.ensureLoaded(); this.memory.getState().clearEnterpriseManagedResourceServers(); await this.persist(); } }