fix(mcp): honor public OAuth origin (#6779)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-06 15:31:57 +02:00
1 parent 28e242417d
commit a62d11b5cd
13 files changed
+115 -72

No files matched your search

@@ -22,7 +22,10 @@ vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate,
}));
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
vi.mock("@homarr/common", () => ({
extractBaseUrlFromHeaders: () => "http://localhost",
removeTrailingSlash: (value: string) => value.replace(/\/+$/u, ""),
}));
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }),
@@ -42,6 +45,7 @@ vi.mock("@homarr/custom-widgets/core", () => ({
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
}));
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/env", () => ({ env: { BASE_URL: undefined } }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({
extractMcpTools: () => [
+8
View File
@@ -0,0 +1,8 @@
import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common";
import { env } from "~/env";
type RequestHeaders = Parameters<typeof extractBaseUrlFromHeaders>[0];
export const getMcpBaseUrl = (headers: RequestHeaders) =>
removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers));
+22 -8
View File
@@ -7,7 +7,7 @@ import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { removeTrailingSlash } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
@@ -24,6 +24,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
import { getMcpBaseUrl } from "./_base-url";
import { extractMcpTools } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol";
@@ -246,6 +247,12 @@ function jsonErrorResponse(status: number, body: Record<string, string>, extraHe
});
}
const getOAuthChallenge = (req: NextRequest) => {
const baseUrl = getMcpBaseUrl(req.headers);
const resourcePath = removeTrailingSlash(new URL(req.url).pathname);
return `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource${resourcePath}"`;
};
export const handleMcpRequest = async (req: NextRequest) => {
const pathname = new URL(req.url).pathname;
if (pathname.endsWith("/sse") || pathname.endsWith("/message")) {
@@ -269,30 +276,37 @@ export const handleMcpRequest = async (req: NextRequest) => {
if (!apiKeyValue) {
recordAuthFailure(ipAddress);
const baseUrl = extractBaseUrlFromHeaders(req.headers);
return jsonErrorResponse(
401,
{
error: "unauthorized",
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
},
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
{ "WWW-Authenticate": getOAuthChallenge(req) },
);
}
if (!apiKeyValue.includes(".")) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
return jsonErrorResponse(
401,
{ error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." },
{ "WWW-Authenticate": getOAuthChallenge(req) },
);
}
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
if (!session) {
recordAuthFailure(ipAddress);
return jsonErrorResponse(401, {
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
});
return jsonErrorResponse(
401,
{
error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
},
{ "WWW-Authenticate": getOAuthChallenge(req) },
);
}
clearAuthFailures(ipAddress);
@@ -1,8 +1,8 @@
import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getMcpBaseUrl } from "../../_base-url";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
export async function GET(req: NextRequest) {
@@ -83,8 +83,9 @@ export async function GET(req: NextRequest) {
);
}
const expectedResource = `${extractBaseUrlFromHeaders(req.headers)}/api/mcp`;
if (resource && resource !== expectedResource) {
const baseUrl = getMcpBaseUrl(req.headers);
const allowedResources = new Set([`${baseUrl}/api/mcp`, `${baseUrl}/api/mcp/mcp`]);
if (resource && !allowedResources.has(resource)) {
return Response.json(
{
error: "invalid_target",
@@ -152,7 +153,7 @@ export async function GET(req: NextRequest) {
);
}
const loginUrl = new URL("/auth/login", url.origin);
const loginUrl = new URL("/auth/login", baseUrl);
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
return Response.redirect(loginUrl.toString());
}