diff --git a/src/lib/oauth-discovery.ts b/src/lib/oauth-discovery.ts index eb03315..7eaba89 100644 --- a/src/lib/oauth-discovery.ts +++ b/src/lib/oauth-discovery.ts @@ -22,13 +22,20 @@ export function oauthResourceMetadataUrl(request: Request): string { return `${oauthRequestOrigin(request)}${OAUTH_RESOURCE_METADATA_PATH}`; } +// Returns the hosted Go authorization server for the production and dev MCP +// origins, or null where this app still serves OAuth itself. +export function hostedAuthorizationServer(request: Request): string | null { + const origin = oauthRequestOrigin(request); + if (origin === MCP_ORIGIN) return OAUTH_ORIGIN; + if (origin === DEV_MCP_ORIGIN) return DEV_OAUTH_ORIGIN; + return null; +} + export function oauthResourceMetadata( request: Request, ): Record { const origin = oauthRequestOrigin(request); - let authorizationServer = origin; - if (origin === MCP_ORIGIN) authorizationServer = OAUTH_ORIGIN; - if (origin === DEV_MCP_ORIGIN) authorizationServer = DEV_OAUTH_ORIGIN; + const authorizationServer = hostedAuthorizationServer(request) ?? origin; return { resource: `${origin}/mcp`, diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 78d6647..98ea1f3 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from "next/server"; let handleRequest: ( auth: { protect: () => Promise }, request: NextRequest, -) => Promise; +) => Promise; const clerk = await import("@clerk/nextjs/server"); mock.module("@clerk/nextjs/server", () => ({ ...clerk, @@ -64,3 +64,73 @@ describe("MCP discovery through middleware", () => { expect(protect).toHaveBeenCalledTimes(1); }); }); + +describe("OAuth routes on hosted MCP origins", () => { + test.each([ + ["mcp.onkernel.com", "auth.onkernel.com"], + ["mcp.dev.onkernel.com", "auth.dev.onkernel.com"], + ])("redirects %s OAuth routes to %s", async (mcpHost, authHost) => { + for (const [method, path] of [ + ["GET", "/.well-known/oauth-authorization-server"], + ["GET", "/authorize?client_id=abc&state=xyz"], + ["POST", "/register"], + ["POST", "/token"], + ]) { + const protect = mock(async () => {}); + const response = await handleRequest( + { protect }, + new NextRequest(`https://${mcpHost}${path}`, { method }), + ); + expect(response?.status).toBe(308); + expect(response?.headers.get("location")).toBe( + `https://${authHost}${path}`, + ); + expect(protect).not.toHaveBeenCalled(); + } + }); + + test("redirects using the public Host behind Next", async () => { + const response = await handleRequest( + { protect: mock(async () => {}) }, + new NextRequest("http://localhost:3002/token", { + method: "POST", + headers: { Host: "mcp.onkernel.com" }, + }), + ); + expect(response?.headers.get("location")).toBe( + "https://auth.onkernel.com/token", + ); + }); + + test("lets CORS preflight reach the route handlers", async () => { + const response = await handleRequest( + { protect: mock(async () => {}) }, + new NextRequest("https://mcp.onkernel.com/token", { method: "OPTIONS" }), + ); + expect(response).toBeUndefined(); + }); + + test.each([ + "http://localhost:3002", + "https://preview.example", + "https://mcp.onkernel.com.evil.example", + ])("keeps serving OAuth on %s", async (origin) => { + for (const path of ["/authorize", "/token", "/register"]) { + const response = await handleRequest( + { protect: mock(async () => {}) }, + new NextRequest(`${origin}${path}`), + ); + expect(response).toBeUndefined(); + } + }); + + test("does not redirect the MCP endpoint or its resource metadata", async () => { + for (const path of ["/mcp", "/.well-known/oauth-protected-resource/mcp"]) { + const response = await handleRequest( + { protect: mock(async () => {}) }, + new NextRequest(`https://mcp.onkernel.com${path}`, { method: "POST" }), + ); + expect(response).toBeUndefined(); + } + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index cc5852a..0ceb50f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,6 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import { hostedAuthorizationServer } from "@/lib/oauth-discovery"; // Public routes that don't require authentication const isPublicRoute = createRouteMatcher([ @@ -16,7 +18,29 @@ const isPublicRoute = createRouteMatcher([ "/managed-auth-proxy/auth/connections/(.*)", ]); +// Clients that cached this host's OAuth endpoints are sent to the hosted +// authorization server, which issues the tokens the Kernel API accepts. +// Credentials issued here earlier are not migrated: the hosted server rejects +// them and the client registers again. +const HOSTED_OAUTH_PATHS = new Set([ + "/.well-known/oauth-authorization-server", + "/authorize", + "/register", + "/token", +]); + export default clerkMiddleware(async (auth, req) => { + const authorizationServer = hostedAuthorizationServer(req); + if ( + authorizationServer && + req.method !== "OPTIONS" && + HOSTED_OAUTH_PATHS.has(req.nextUrl.pathname) + ) { + return NextResponse.redirect( + `${authorizationServer}${req.nextUrl.pathname}${req.nextUrl.search}`, + 308, + ); + } if (isPublicRoute(req)) return; await auth.protect(); });