Skip to content
5 changes: 5 additions & 0 deletions app/.well-known/oauth-authorization-server/api/auth/route.ts
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
Expand Down
14 changes: 9 additions & 5 deletions app/api/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ export async function POST(request: Request): Promise<Response> {
}
}

// 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<Response> {
const ctx = await authenticate(request);
if (!ctx) return unauthorizedResponse();
return methodNotAllowed();
}

export const GET = challengeOrNotAllowed;
export const DELETE = challengeOrNotAllowed;
1 change: 0 additions & 1 deletion lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export const auth = betterAuth({
return orgId ? { org_id: orgId } : {};
},
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
Expand Down
8 changes: 5 additions & 3 deletions lib/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
55 changes: 53 additions & 2 deletions tests/mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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)
Expand Down Expand Up @@ -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");
});
});
Loading