Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/lib/oauth-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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`,
Expand Down
72 changes: 71 additions & 1 deletion src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NextRequest } from "next/server";
let handleRequest: (
auth: { protect: () => Promise<void> },
request: NextRequest,
) => Promise<void>;
) => Promise<Response | void>;
const clerk = await import("@clerk/nextjs/server");
mock.module("@clerk/nextjs/server", () => ({
...clerk,
Expand Down Expand Up @@ -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();
}
});
});
24 changes: 24 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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();
});
Expand Down