-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Harden MCP portal authentication changes #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e68befc
8624ef7
79bf391
024b51b
80b6558
ca83ee2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<readonly [string, unknown]> = [], | ||
| overrides: Partial<TestEnv> = {}, | ||
| ) { | ||
| const values = new Map<string, unknown>(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<ConnectOutcome>; | ||
| }; | ||
| 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"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this test is very light and doesn't really exercise the riskiest parts of this
|
||
| }); | ||
|
|
||
| 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<typeof globalThis.fetch>(); | ||
| 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(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export default ""; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The doc-comment above (lines 165-168) is now stale relative to this new logic. It says probing may legitimately move between |
||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. per sol
|
||
| 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)), | ||
| }, | ||
| }, | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.