diff --git a/apps/mcp/src/server/auth/index.test.ts b/apps/mcp/src/server/auth/index.test.ts index 62698e81..26f4a449 100644 --- a/apps/mcp/src/server/auth/index.test.ts +++ b/apps/mcp/src/server/auth/index.test.ts @@ -212,6 +212,34 @@ describe("MCP authentication", () => { expect(error).not.toHaveBeenCalled() }) + it("rejects an API key whose session has no organization", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = vi.spyOn(console, "error").mockImplementation(() => {}) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + user: { id: "user_test", email: "test@example.com", name: "Test" }, + org: null, + session: null, + role: null, + accessType: "full", + containerTags: null, + scope: { type: "full", permission: "write" }, + }), + ), + ) + + await expect( + validateApiKey("sm_noorg_key_0123456789abcdef", API_URL), + ).resolves.toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + "API key rejected: session has no organization", + ) + expect(error).not.toHaveBeenCalled() + }) + it("rejects malformed API keys without an API request", async () => { const fetchSpy = vi.fn() vi.stubGlobal("fetch", fetchSpy) @@ -239,48 +267,6 @@ describe("MCP authentication", () => { expect(warn).not.toHaveBeenCalled() }) - it("surfaces a 200 session body that fails the session contract as TransientAuthError", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - const error = vi.spyOn(console, "error").mockImplementation(() => {}) - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response(JSON.stringify({}), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ) - - await expect( - validateApiKey("sm_badbody_key_0123456789abcd", API_URL), - ).rejects.toThrow(TransientAuthError) - expect(error).toHaveBeenCalledTimes(1) - expect(error.mock.calls[0]?.[0]).toBe( - "Auth backend unavailable during API key validation: Invalid session response", - ) - expect(warn).not.toHaveBeenCalled() - }) - - it("surfaces a non-JSON 200 session body as TransientAuthError", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - const error = vi.spyOn(console, "error").mockImplementation(() => {}) - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue( - new Response("upstream error", { status: 200 }), - ), - ) - - await expect( - validateApiKey("sm_htmlbody_key_0123456789abc", API_URL), - ).rejects.toThrow(TransientAuthError) - expect(error).toHaveBeenCalledTimes(1) - expect(warn).not.toHaveBeenCalled() - }) - it("surfaces a session-endpoint timeout as TransientAuthError", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) const error = vi.spyOn(console, "error").mockImplementation(() => {}) diff --git a/apps/mcp/src/server/auth/index.ts b/apps/mcp/src/server/auth/index.ts index a68d7c9f..819e1f31 100644 --- a/apps/mcp/src/server/auth/index.ts +++ b/apps/mcp/src/server/auth/index.ts @@ -44,13 +44,9 @@ export async function fetchSession( ) } - const body = await response.json().catch((error: unknown) => { - if (error instanceof SyntaxError) return undefined - throw error - }) - const result = sessionInfoSchema.safeParse(body) + const result = sessionInfoSchema.safeParse(await response.json()) if (!result.success) { - throw new TransientAuthError("Invalid session response") + throw new Error("Invalid session response") } return result.data @@ -90,7 +86,6 @@ const TRANSIENT_ERROR_NAMES = new Set([ const TRANSIENT_JOSE_CODES = new Set(["ERR_JWKS_TIMEOUT", "ERR_JOSE_GENERIC"]) function transientAuthErrorFor(error: unknown): TransientAuthError | null { - if (error instanceof TransientAuthError) return error const status = (error as { status?: unknown } | null)?.status if (typeof status === "number" && status !== 401 && status !== 403) { return new TransientAuthError(`Session endpoint returned ${status}`, status) @@ -122,7 +117,10 @@ export async function validateApiKey( try { const session = await fetchSession(token, apiUrl) const organizationId = session.org?.id - if (!organizationId) return null + if (!organizationId) { + console.warn("API key rejected: session has no organization") + return null + } const user: AuthUser = { userId: session.user.id, diff --git a/apps/mcp/src/shared/types.ts b/apps/mcp/src/shared/types.ts index ad59a02b..67914630 100644 --- a/apps/mcp/src/shared/types.ts +++ b/apps/mcp/src/shared/types.ts @@ -37,8 +37,8 @@ export const sessionInfoSchema = z email: z.string().optional(), name: z.string().optional(), }), - org: z.looseObject({ id: z.string().min(1) }).optional(), - role: z.string().optional(), + org: z.looseObject({ id: z.string().min(1) }).nullish(), + role: z.string().nullish(), accessType: z.enum(["full", "restricted"]).optional(), containerTags: z.array(sessionNamespaceAccessSchema).nullable().optional(), scope: sessionScopeSchema.optional(),