diff --git a/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts new file mode 100644 index 000000000..090f57237 --- /dev/null +++ b/packages/gatekeeper-mcp-portal/__tests__/account-revision.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + McpAccountBase, + type ConnectedServer, + type ConnectOutcome, +} from "@gadgets/mcp-shared/account"; +import { McpAccount } from "../src/portal.js"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const PORTAL_ENDPOINT = "https://portal.example.com/mcp"; + +type TestEnv = { + MCP_PORTAL_URL: string; + MCP_PORTAL_AUTH: string; + MCP_PORTAL_TOKEN?: string; +}; + +function portalServer( + auth: ConnectedServer["auth"], endpoint = PORTAL_ENDPOINT, +): ConnectedServer { + return { + endpoint, + serverId: "portal", + serverName: "Portal", + provenance: "deployment", + auth, + }; +} + +function setup( + entries: Iterable = [], + overrides: Partial = {}, +) { + const values = new Map(entries); + const env: TestEnv = { + MCP_PORTAL_URL: PORTAL_ENDPOINT, + MCP_PORTAL_AUTH: "oauth", + ...overrides, + }; + const account = new McpAccount({ + id: { toString: () => "account" }, + storage: { + kv: { + get: (key: string) => values.get(key), + put: (key: string, value: unknown) => values.set(key, value), + delete: (key: string) => values.delete(key), + }, + }, + exports: {}, + } as never, env as never); + return { account, env, values }; +} + +describe("McpAccount configuration revision", () => { + it("records the current revision before handing an OAuth attempt to its callback", async () => { + const { account, env, values } = setup([["portalConfigRevision", "old"]]); + const server = portalServer("oauth", env.MCP_PORTAL_URL); + const base = McpAccountBase.prototype as unknown as { + beginConnect(nonce: string, target: ConnectedServer | null): Promise; + }; + vi.spyOn(base, "beginConnect") + .mockResolvedValue({ kind: "redirect", url: "https://login.example.com" }); + const internals = account as unknown as { + awaitingSelection(nonce: string): boolean; + server(): ConnectedServer | undefined; + }; + vi.spyOn(internals, "awaitingSelection").mockReturnValue(true); + vi.spyOn(internals, "server").mockReturnValue(server); + + await expect(account.beginConnect("nonce", server)) + .resolves.toMatchObject({ kind: "redirect" }); + + expect(values.get("portalConfigRevision")).not.toBe("old"); + }); + + it("establishes the current token revision and invalidates a legacy transport session once", async () => { + const { account, values } = setup([ + ["server", portalServer("token")], + ["connectionGeneration", 2], + ["mcpSessionId", "legacy-session"], + ], { + MCP_PORTAL_AUTH: "token", + MCP_PORTAL_TOKEN: "configured-token", + }); + + await expect(account.getConnection(PORTAL_ENDPOINT)).resolves.toMatchObject({ + authorization: "configured-token", + generation: 3, + sessionId: null, + }); + await expect(account.getConnection(PORTAL_ENDPOINT)).resolves.toMatchObject({ + generation: 3, + }); + expect(values.get("portalConfigRevision")).toEqual(expect.any(String)); + }); + + it("allows only OAuth callbacks and fallbacks still permitted by live portal configuration", () => { + const setupResult = setup(); + const { env } = setupResult; + const account = setupResult.account as unknown as { + allowsOAuthCallback(server: ConnectedServer): boolean; + allowsOAuthFallback(server: ConnectedServer): boolean; + }; + const oauthServer = portalServer("oauth", env.MCP_PORTAL_URL); + + expect(account.allowsOAuthCallback(oauthServer)).toBe(true); + expect(account.allowsOAuthFallback(oauthServer)).toBe(true); + expect(account.allowsOAuthFallback({ ...oauthServer, auth: "none" })).toBe(false); + env.MCP_PORTAL_AUTH = "none"; + expect(account.allowsOAuthCallback(oauthServer)).toBe(false); + env.MCP_PORTAL_AUTH = "oauth"; + env.MCP_PORTAL_URL = "https://replacement.example.com/mcp"; + expect(account.allowsOAuthCallback(oauthServer)).toBe(false); + }); + + it("rejects and cleans an OAuth callback after the deployment repoints the portal", async () => { + const nonce = "n".repeat(64); + const { account, values } = setup([ + ["nonce", { value: nonce, expiresAt: Date.now() + 60_000, stage: "oauth" }], + ["pendingAuth", { generation: 1 }], + ["oauthVerifier", "verifier"], + ["server", portalServer("oauth", "https://old.example.com/mcp")], + ], { MCP_PORTAL_URL: "https://new.example.com/mcp" }); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + + await expect(account.acceptAuthCode("code", nonce)).resolves.toBe(false); + + expect(values.has("nonce")).toBe(false); + expect(values.has("pendingAuth")).toBe(false); + expect(values.has("oauthVerifier")).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gatekeeper-mcp-portal/__tests__/config.test.ts b/packages/gatekeeper-mcp-portal/__tests__/config.test.ts index caa5dea59..476f9db04 100644 --- a/packages/gatekeeper-mcp-portal/__tests__/config.test.ts +++ b/packages/gatekeeper-mcp-portal/__tests__/config.test.ts @@ -136,15 +136,20 @@ describe("portalServer", () => { describe("portalAuthRequiresReconnect", () => { it("requires reconnecting when token authority changes", () => { + expect(portalAuthRequiresReconnect("token", "token")).toBe(false); expect(portalAuthRequiresReconnect("none", "token")).toBe(true); expect(portalAuthRequiresReconnect("oauth", "token")).toBe(true); expect(portalAuthRequiresReconnect("token", "none")).toBe(true); expect(portalAuthRequiresReconnect("token", "oauth")).toBe(true); }); - it("allows none and oauth to differ after probing the endpoint", () => { + it("allows an oauth-configured portal to prove public during probing", () => { expect(portalAuthRequiresReconnect("none", "oauth")).toBe(false); - expect(portalAuthRequiresReconnect("oauth", "none")).toBe(false); + }); + + it("keeps explicitly unauthenticated mode strict", () => { + expect(portalAuthRequiresReconnect("oauth", "none")).toBe(true); + expect(portalAuthRequiresReconnect("none", "none")).toBe(false); }); }); diff --git a/packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts b/packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts new file mode 100644 index 000000000..9cf3c27e7 --- /dev/null +++ b/packages/gatekeeper-mcp-portal/__tests__/stubs/configurator-html.ts @@ -0,0 +1 @@ +export default ""; diff --git a/packages/gatekeeper-mcp-portal/src/config.ts b/packages/gatekeeper-mcp-portal/src/config.ts index 2f5164fca..5bc667fb2 100644 --- a/packages/gatekeeper-mcp-portal/src/config.ts +++ b/packages/gatekeeper-mcp-portal/src/config.ts @@ -181,13 +181,14 @@ export function portalServer(config: PortalConfig): ConnectedServer { } /** - * Probing may legitimately move between none and OAuth. A preissued token is deployment authority, - * so entering or leaving that mode requires the account to reconnect against current configuration. + * An OAuth-configured portal may prove public during probing, but explicitly unauthenticated and + * preissued-token configurations are strict. Entering or leaving either strict mode requires the + * account to reconnect against current configuration. */ export function portalAuthRequiresReconnect( connected: ServerAuthKind, configured: ServerAuthKind, ): boolean { - return (connected === "token") !== (configured === "token"); + return configured === "oauth" ? connected === "token" : connected !== configured; } /** diff --git a/packages/gatekeeper-mcp-portal/src/portal.ts b/packages/gatekeeper-mcp-portal/src/portal.ts index ea7374b7a..eca0e63ed 100644 --- a/packages/gatekeeper-mcp-portal/src/portal.ts +++ b/packages/gatekeeper-mcp-portal/src/portal.ts @@ -25,13 +25,17 @@ import { } from "@gadgets/workshop-shared/gatekeeper"; import { isValidToolName } from "@gadgets/mcp-shared/client"; import { MAX_TOOLS_PER_SERVER, type ServerTrust } from "@gadgets/mcp-shared/tools"; -import { bindingNameFragment, hostOf } from "@gadgets/mcp-shared/util"; +import { bindingNameFragment, hexEncode, hostOf } from "@gadgets/mcp-shared/util"; import type { McpLog, McpLogFields } from "@gadgets/mcp-shared/log"; import { generateSessionTypes, sessionTypeName } from "@gadgets/mcp-shared/schema-to-ts"; import { McpAccountBase, type ConnectedServer, type ConnectOutcome } from "@gadgets/mcp-shared/account"; import { generateNonce } from "@gadgets/mcp-shared/connect-nonce"; -import { withClient, type ConnectionAccount } from "@gadgets/mcp-shared/connection"; +import { + withClient, + type ConnectionAccount, + type McpConnection, +} from "@gadgets/mcp-shared/connection"; import { McpSessionBase } from "@gadgets/mcp-shared/session"; import { McpFacetBase } from "@gadgets/mcp-shared/facet"; import { @@ -73,6 +77,7 @@ import { requirePortalServerScope, isPortalToolGrantable, toolGrantOptions, + type PortalConfig, } from "./config.js"; import type { ConfiguratorUIOption } from "@gadgets/configurator-ui"; import { MCP_BASE_TYPES } from "@gadgets/mcp-shared/base-types"; @@ -240,6 +245,10 @@ export class GatekeeperVendor extends WorkerEntrypoint implements Gatekeepe * real addition: a portal may be fronted by one instead of using OAuth. */ export class McpAccount extends McpAccountBase { + // Counts in-flight attempts per revision. Separate Durable Object requests can overlap at the + // digest and portal probe, so one losing or older attempt must not clear a newer attempt's guard. + #connectingRevisions = new Map(); + protected baseUrl(): string { return getBaseUrl(this.env); } @@ -260,6 +269,78 @@ export class McpAccount extends McpAccountBase { protected override staticToken(server: ConnectedServer): string | null { return portalTokenFor(this.env, server.endpoint); } + + protected override allowsOAuthFallback(server: ConnectedServer): boolean { + return server.auth !== "none"; + } + + protected override allowsOAuthCallback(server: ConnectedServer): boolean { + const config = readPortalConfig(this.env); + return config?.auth === "oauth" && sameEndpoint(config.endpoint, server.endpoint); + } + + async #configurationRevision(config: PortalConfig): Promise { + const token = config.auth === "token" ? this.env.MCP_PORTAL_TOKEN ?? "" : ""; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${config.auth}\u0000${token}`), + ); + return hexEncode(new Uint8Array(digest)); + } + + override async beginConnect( + initiationNonce: string, + target: ConnectedServer | null, + ): Promise { + const config = readPortalConfig(this.env); + const revision = config && this.awaitingSelection(initiationNonce) + ? await this.#configurationRevision(config) + : undefined; + if (revision !== undefined) { + this.#connectingRevisions.set( + revision, (this.#connectingRevisions.get(revision) ?? 0) + 1); + } + try { + const outcome = await super.beginConnect(initiationNonce, target); + if (outcome.kind !== "invalid") { + const current = readPortalConfig(this.env); + const server = this.server(); + if (current && server && sameEndpoint(current.endpoint, server.endpoint)) { + this.ctx.storage.kv.put( + "portalConfigRevision", + await this.#configurationRevision(current), + ); + } + } + return outcome; + } finally { + if (revision !== undefined) { + const remaining = this.#connectingRevisions.get(revision)! - 1; + if (remaining === 0) this.#connectingRevisions.delete(revision); + else this.#connectingRevisions.set(revision, remaining); + } + } + } + + override async getConnection(endpoint: string): Promise { + const config = readPortalConfig(this.env); + const server = this.server(); + if (!config || !server || !sameEndpoint(config.endpoint, endpoint) + || portalAuthRequiresReconnect(server.auth, config.auth)) { + throw new Error("This deployment's MCP portal configuration changed. Reconnect the account."); + } + const revision = await this.#configurationRevision(config); + const previous = this.ctx.storage.kv.get("portalConfigRevision"); + const reconnectingToCurrentRevision = this.#connectingRevisions.has(revision); + if (!reconnectingToCurrentRevision && previous !== revision) { + // Existing token accounts predate the revision marker. Their cached session may have been + // minted under a different configured token, so invalidate it once before establishing the + // current revision as the baseline. New connects record the revision in `beginConnect()`. + if (previous !== undefined || server.auth === "token") this.invalidateConnectionState(); + this.ctx.storage.kv.put("portalConfigRevision", revision); + } + return super.getConnection(endpoint); + } } // --------------------------------------------------------------------------- diff --git a/packages/gatekeeper-mcp-portal/vitest.config.ts b/packages/gatekeeper-mcp-portal/vitest.config.ts new file mode 100644 index 000000000..6ae845503 --- /dev/null +++ b/packages/gatekeeper-mcp-portal/vitest.config.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from "node:url"; +import capnwebValidate from "capnweb-validate/vite"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [capnwebValidate()], + test: { + include: ["__tests__/*.test.ts"], + environment: "node", + alias: { + "cloudflare:workers": fileURLToPath( + new URL("../mcp-shared/__tests__/stubs/cloudflare-workers.ts", import.meta.url)), + "./generated/server-configurator-ui.txt": fileURLToPath( + new URL("./__tests__/stubs/configurator-html.ts", import.meta.url)), + }, + }, +}); diff --git a/packages/mcp-shared/__tests__/account-endpoint.test.ts b/packages/mcp-shared/__tests__/account-endpoint.test.ts index 91ec6560e..248c304b3 100644 --- a/packages/mcp-shared/__tests__/account-endpoint.test.ts +++ b/packages/mcp-shared/__tests__/account-endpoint.test.ts @@ -34,8 +34,8 @@ class InterleavingAccount extends McpAccountBase { return await new Promise((_resolve, reject) => { this.#rejectProbe = reject; }); } - failProbe(): void { - this.#rejectProbe?.(new Error("stop test probe")); + rejectProbe(reason = new Error("stop test probe")): void { + this.#rejectProbe?.(reason); } isWaiting(nonce: string): boolean { @@ -104,6 +104,14 @@ class AuthChallengeAccount extends McpAccountBase { } } +class StrictUnauthenticatedAccount extends AuthChallengeAccount { + protected override allowsOAuthFallback(): boolean { return false; } + + isWaiting(nonce: string): boolean { + return this.awaitingSelection(nonce); + } +} + class OAuthFlowAccount extends McpAccountBase { protected baseUrl(): string { return "https://gatekeeper.example"; } protected log(): never { return testLog as never; } @@ -138,7 +146,7 @@ describe("connect initiation nonce", () => { await expect(account.beginConnect(nonce, server("https://a.example/mcp"))) .resolves.toEqual({ kind: "invalid" }); - account.failProbe(); + account.rejectProbe(); await expect(first).rejects.toThrow("stop test probe"); // The request still owns the claim, so a transient failure reopens the already-rendered form. expect(account.isWaiting(nonce)).toBe(true); @@ -174,10 +182,123 @@ describe("connect initiation nonce", () => { await expect(account.getConnection("https://old.example/mcp")) .rejects.toThrow(/account is now connected to new\.example/); - account.failProbe(); + account.rejectProbe(); await expect(repoint).rejects.toThrow("stop test probe"); }); + it("keeps an observed-public account usable while an OAuth-configured reconnect probes", async () => { + const context = fakeContext(); + const connected = { ...server("https://portal.example/mcp"), auth: "none" as const }; + context.storage.kv.put("server", connected); + const account = new InterleavingAccount(context as never, {}); + const credentialsExpired = vi.fn(async () => undefined); + const nonce = "1".repeat(64); + await account.setCallback({ credentialsExpired } as never, nonce); + + const reconnect = account.beginConnect(nonce, { + ...connected, + auth: "oauth", + }); + + await expect(account.getConnection(connected.endpoint)) + .resolves.toMatchObject({ authorization: null }); + expect(credentialsExpired).not.toHaveBeenCalled(); + expect(context.storage.kv.get("server")).toEqual(connected); + + account.rejectProbe(); + await expect(reconnect).rejects.toThrow("stop test probe"); + await expect(account.getConnection(connected.endpoint)) + .resolves.toMatchObject({ authorization: null }); + expect(context.storage.kv.get("server")).toEqual(connected); + }); + + it("preserves OAuth state when a same-endpoint token reconnect has no configured token", async () => { + const context = fakeContext(); + const connected = { ...server("https://portal.example/mcp"), auth: "oauth" as const }; + const tokens = { + access_token: "access", + token_type: "Bearer", + refresh_token: "refresh", + issuer: "https://auth.example", + expiresAt: Date.now() + 60_000, + }; + const oauthClient = { client_id: "client", issuer: "https://auth.example" }; + const oauthDiscovery = { + authorizationServerUrl: "https://auth.example", + authorizationServerMetadata: { + issuer: "https://auth.example", + authorization_endpoint: "https://auth.example/authorize", + token_endpoint: "https://auth.example/token", + }, + }; + context.storage.kv.put("server", connected); + context.storage.kv.put("tokens", tokens); + context.storage.kv.put("oauthClient", oauthClient); + context.storage.kv.put("oauthDiscovery", oauthDiscovery); + const account = new UnconfiguredTokenAccount(context as never, {}); + const nonce = "2".repeat(64); + await account.prepareReconnect(nonce); + + await expect(account.beginConnect(nonce, { + ...connected, + auth: "token", + provenance: "deployment", + })).rejects.toThrow(/No preissued token is configured/); + + expect(context.storage.kv.get("server")).toEqual(connected); + expect(context.storage.kv.get("tokens")).toEqual(tokens); + expect(context.storage.kv.get("oauthClient")).toEqual(oauthClient); + expect(context.storage.kv.get("oauthDiscovery")).toEqual(oauthDiscovery); + expect(account.isWaiting(nonce)).toBe(true); + }); + + it("preserves OAuth state when a same-endpoint unauthenticated reconnect is refused", async () => { + const context = fakeContext(); + const connected = { ...server("https://portal.example/mcp"), auth: "oauth" as const }; + const tokens = { access_token: "access", token_type: "Bearer" }; + context.storage.kv.put("server", connected); + context.storage.kv.put("tokens", tokens); + const account = new StrictUnauthenticatedAccount(context as never, {}); + const nonce = "3".repeat(64); + await account.prepareReconnect(nonce); + + await expect(account.beginConnect(nonce, { + ...connected, + auth: "none", + provenance: "deployment", + })).rejects.toThrow(/configured for unauthenticated access/); + + expect(context.storage.kv.get("server")).toEqual(connected); + expect(context.storage.kv.get("tokens")).toEqual(tokens); + expect(account.isWaiting(nonce)).toBe(true); + }); + + it("does not let a superseded OAuth fallback clear a newer attempt's state", async () => { + const context = fakeContext(); + const connected = { + ...server("https://portal.example/mcp"), + auth: "token" as const, + provenance: "deployment" as const, + }; + context.storage.kv.put("server", connected); + const account = new InterleavingAccount(context as never, {}); + const firstNonce = "4".repeat(64); + await account.prepareReconnect(firstNonce); + const first = account.beginConnect(firstNonce, { ...connected, auth: "oauth" }); + + const secondNonce = "5".repeat(64); + await account.prepareReconnect(secondNonce); + const pendingAuth = { generation: 3 }; + context.storage.kv.put("pendingAuth", pendingAuth); + context.storage.kv.put("oauthVerifier", "new-verifier"); + account.rejectProbe(new McpAuthRequiredError("authorization required", null)); + + await expect(first).rejects.toThrow(/replaced by a newer/); + expect(context.storage.kv.get("server")).toEqual(connected); + expect(context.storage.kv.get("pendingAuth")).toEqual(pendingAuth); + expect(context.storage.kv.get("oauthVerifier")).toBe("new-verifier"); + }); + it("ignores a transport session written by an operation from before repoint", async () => { const context = fakeContext(); const old = { ...server("https://old.example/mcp"), auth: "none" as const }; @@ -194,7 +315,7 @@ describe("connect initiation nonce", () => { old.endpoint, connection.generation, connection.sessionId, "old-session"); expect(context.storage.kv.get("mcpSessionId")).toBeUndefined(); - account.failProbe(); + account.rejectProbe(); await expect(repoint).rejects.toThrow("stop test probe"); }); @@ -257,7 +378,7 @@ describe("connect initiation nonce", () => { await expect(refreshing).rejects.toThrow(/previous MCP connection|connection changed/); expect(context.storage.kv.get("tokens")).toBeUndefined(); - account.failProbe(); + account.rejectProbe(); await expect(repoint).rejects.toThrow("stop test probe"); }); diff --git a/packages/mcp-shared/src/account.ts b/packages/mcp-shared/src/account.ts index 937396eb4..02c07b9c8 100644 --- a/packages/mcp-shared/src/account.ts +++ b/packages/mcp-shared/src/account.ts @@ -180,6 +180,16 @@ export abstract class McpAccountBase return null; } + /** Whether an unauthenticated target may follow a 401 into the standard OAuth flow. */ + protected allowsOAuthFallback(_server: ConnectedServer): boolean { + return true; + } + + /** Whether current connector configuration still permits this pending OAuth callback. */ + protected allowsOAuthCallback(_server: ConnectedServer): boolean { + return true; + } + /** Relaxes host and scheme checks for local development against an MCP server on localhost. */ protected fetchOptions(): FetchOptions { return fetchOptions(this.env); @@ -203,6 +213,13 @@ export abstract class McpAccountBase return generation; } + /** Invalidates credentials captured by facets and clears their transport session. */ + protected invalidateConnectionState(): void { + this.advanceConnectionGeneration(); + this.ctx.storage.kv.delete("mcpSessionId"); + this.ctx.storage.kv.put("expiredNotified", false); + } + private isCurrentConnection(server: ConnectedServer, generation: number): boolean { const current = this.server(); return this.connectionGeneration() === generation && current !== undefined && @@ -310,14 +327,21 @@ export abstract class McpAccountBase const generation = this.advanceConnectionGeneration(); if (existing) this.ctx.storage.kv.delete("mcpSessionId"); const endpointChanged = existing !== undefined && existing.endpoint !== server.endpoint; - if (endpointChanged) { - this.ctx.storage.kv.put("server", server); + const clearCredentialsOnCommit = existing !== undefined && !endpointChanged && + existing.auth !== server.auth && !(existing.auth === "none" && server.auth === "oauth"); + const clearCredentials = () => { for (const key of [ "tokens", "oauthClient", "oauthDiscovery", "oauthVerifier", "pendingAuth", ]) { this.ctx.storage.kv.delete(key); } this.ctx.storage.kv.put("expiredNotified", false); + }; + // `none` -> `oauth` is only configuration's guess until the probe challenges us. Publishing the + // guess here would make a previously public account look credentialed while it has no tokens. + if (endpointChanged) { + this.ctx.storage.kv.put("server", server); + clearCredentials(); this.log().info("portal repointed", { event: "connect.repointed", serverHost: hostOf(server.endpoint), @@ -356,6 +380,7 @@ export abstract class McpAccountBase // token from every later request. Only an endpoint that answered with no credential at all is. const connected: ConnectedServer = server.auth === "token" ? server : { ...server, auth: "none" }; + if (clearCredentialsOnCommit) clearCredentials(); this.ctx.storage.kv.put("server", connected); await this.complete(connected, info, generation); log.info("connected without authorization", { event: "connect.completed" }); @@ -374,11 +399,22 @@ export abstract class McpAccountBase `The MCP server "${server.serverName}" rejected this deployment's configured token.`, { cause: err }); } + if (!this.allowsOAuthFallback(server)) { + this.restoreSelection(initiationNonce); + throw new Error( + `The MCP server "${server.serverName}" requires authorization, but this connection is ` + + "configured for unauthenticated access.", + { cause: err }); + } + if (generation !== this.connectionGeneration()) { + throw new Error("This connection attempt was replaced by a newer one.", { cause: err }); + } // The endpoint answered with an authorization challenge, so OAuth is now the observed auth // mode even if deployment configuration optimistically called the portal public. Persist that // mode because `getAuthorization()` uses it to decide whether to read the tokens the callback // stores. const oauthServer: ConnectedServer = { ...server, auth: "oauth" }; + if (clearCredentialsOnCommit) clearCredentials(); this.ctx.storage.kv.put("server", oauthServer); try { return await this.beginOAuth(oauthServer, err.resourceMetadataUrl, generation); @@ -577,6 +613,12 @@ export abstract class McpAccountBase const pending = this.ctx.storage.kv.get("pendingAuth"); if (!pending) return false; const server = this.requireServer(); + if (!this.allowsOAuthCallback(server)) { + this.ctx.storage.kv.delete("nonce"); + this.ctx.storage.kv.delete("pendingAuth"); + this.ctx.storage.kv.delete("oauthVerifier"); + return false; + } // Single-use: consumed before the exchange, so a replayed callback cannot reach the token endpoint. this.ctx.storage.kv.delete("nonce"); this.ctx.storage.kv.delete("pendingAuth");