diff --git a/app/.well-known/oauth-authorization-server/api/auth/route.ts b/app/.well-known/oauth-authorization-server/api/auth/route.ts new file mode 100644 index 0000000..36b4e04 --- /dev/null +++ b/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -0,0 +1,5 @@ +import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; +import { auth } from "@/lib/auth"; + +// RFC 8414 path-aware metadata location; bare-root variant stays for back-compat. +export const GET = oauthProviderAuthServerMetadata(auth); diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts index d42d480..905ba6c 100644 --- a/app/.well-known/oauth-protected-resource/route.ts +++ b/app/.well-known/oauth-protected-resource/route.ts @@ -3,7 +3,7 @@ import { env } from "@/lib/env"; export function GET() { return Response.json({ resource: `${env.APP_URL}/api/mcp`, - authorization_servers: [env.APP_URL], + authorization_servers: [`${env.APP_URL}/api/auth`], bearer_methods_supported: ["header"], scopes_supported: ["mcp"], }); diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index f95f1da..1c24166 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -32,8 +32,12 @@ export async function POST(request: Request): Promise { } } -// Stateless server doesn't push notifications and has no sessions to terminate, -// so refuse the GET SSE stream and DELETE — both would only allocate without value -// and GET in particular leaks McpServer instances by holding them open indefinitely. -export const GET = methodNotAllowed; -export const DELETE = methodNotAllowed; +// Unauthed GET/DELETE emit the OAuth challenge for discovery; authed still 405 (leak-fix invariant). +async function challengeOrNotAllowed(request: Request): Promise { + const ctx = await authenticate(request); + if (!ctx) return unauthorizedResponse(); + return methodNotAllowed(); +} + +export const GET = challengeOrNotAllowed; +export const DELETE = challengeOrNotAllowed; diff --git a/lib/auth.ts b/lib/auth.ts index 95c4b1a..501829e 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -32,7 +32,6 @@ export const auth = betterAuth({ return orgId ? { org_id: orgId } : {}; }, silenceWarnings: { - oauthAuthServerConfig: true, openidConfig: true, }, }), diff --git a/lib/mcp/auth.ts b/lib/mcp/auth.ts index eb61cdf..cdd36a5 100644 --- a/lib/mcp/auth.ts +++ b/lib/mcp/auth.ts @@ -2,12 +2,14 @@ import { eq } from "drizzle-orm"; import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; import { db } from "@/db/client"; import { user } from "@/db/schema/auth"; +import { MCP_AUDIENCE } from "@/lib/auth"; import type { Actor } from "@/lib/audit"; import { env } from "@/lib/env"; import { resolveServiceToken } from "@/lib/service-tokens"; const defaultJwks = createRemoteJWKSet(new URL(`${env.APP_URL}/api/auth/jwks`)); -export const MCP_RESOURCE = `${env.APP_URL}/api/mcp`; +export const MCP_RESOURCE = MCP_AUDIENCE; +export const MCP_ISSUER = `${env.APP_URL}/api/auth`; export type McpAuthContext = { organizationId: string; actor: Actor }; export type AuthenticateOptions = { jwks?: JWTVerifyGetKey }; @@ -49,8 +51,8 @@ export async function authenticate( try { const { payload } = await jwtVerify(token, opts.jwks ?? defaultJwks, { - audience: MCP_RESOURCE, - issuer: env.APP_URL, + audience: MCP_AUDIENCE, + issuer: MCP_ISSUER, }); const orgId = payload.org_id; if (typeof orgId !== "string" || orgId.length === 0) return null; diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts index 0a641d3..8bb3273 100644 --- a/tests/mcp-oauth.test.ts +++ b/tests/mcp-oauth.test.ts @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; import { createDb } from "@/db/client"; import { organization } from "@/db/schema/auth"; -import { authenticate, MCP_RESOURCE, unauthorizedResponse } from "@/lib/mcp/auth"; +import { + authenticate, + MCP_ISSUER, + MCP_RESOURCE, + unauthorizedResponse, +} from "@/lib/mcp/auth"; import { issueServiceToken } from "@/lib/service-tokens"; import { resetDb } from "./setup"; @@ -23,7 +28,7 @@ async function mintJwt(opts: { opts.payload ?? { org_id: "org_jwt", sub: "user_1", client_id: "test_client" }, ) .setProtectedHeader({ alg: ALG, kid: opts.kid }) - .setIssuer(opts.issuer ?? APP_URL) + .setIssuer(opts.issuer ?? MCP_ISSUER) .setAudience(opts.audience ?? MCP_RESOURCE) .setIssuedAt() .setExpirationTime(exp) @@ -157,3 +162,49 @@ describe("MCP dual-auth", () => { expect(await authenticate(bearer(token), { jwks })).toBeNull(); }); }); + +describe("MCP route methods", () => { + beforeEach(async () => { + await resetDb(); + }); + + function expectOAuthChallenge(res: Response): void { + expect(res.status).toBe(401); + const wa = res.headers.get("WWW-Authenticate") ?? ""; + expect(wa).toContain("Bearer"); + expect(wa).toContain( + `resource_metadata="${APP_URL}/.well-known/oauth-protected-resource"`, + ); + } + + test("GET /api/mcp without a token returns 401 with WWW-Authenticate", async () => { + const { GET } = await import("@/app/api/mcp/route"); + const res = await GET( + new Request("http://localhost/api/mcp", { method: "GET" }), + ); + expectOAuthChallenge(res); + }); + + test("DELETE /api/mcp without a token returns 401 with WWW-Authenticate", async () => { + const { DELETE } = await import("@/app/api/mcp/route"); + const res = await DELETE( + new Request("http://localhost/api/mcp", { method: "DELETE" }), + ); + expectOAuthChallenge(res); + }); + + test("GET /api/mcp with a valid service token returns 405", async () => { + const orgId = "org_route"; + await db.insert(organization).values({ id: orgId, name: "Route", slug: "route" }); + const issued = await issueServiceToken(db, orgId, "ci-bot"); + const { GET } = await import("@/app/api/mcp/route"); + const res = await GET( + new Request("http://localhost/api/mcp", { + method: "GET", + headers: { authorization: `Bearer ${issued.token}` }, + }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + }); +});