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

@@ -1,33 +0,0 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
resource_indicators_supported: true,
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -1,25 +0,0 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET() {
const baseUrl = extractBaseUrlFromHeaders(await headers());
return Response.json(
{
resource: `${baseUrl}/api/mcp`,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -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());
}
@@ -0,0 +1,51 @@
import type { NextRequest } from "next/server";
import { getMcpBaseUrl } from "~/app/api/mcp/_base-url";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
};
export async function GET(req: NextRequest, props: { params: Promise<{ path: string[] }> }) {
const [kind, ...rest] = (await props.params).path;
const baseUrl = getMcpBaseUrl(req.headers);
const identifierPath = rest.join("/");
if (kind === "oauth-authorization-server") {
const issuer = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl;
return Response.json(
{
issuer,
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp:tools"],
resource_indicators_supported: true,
},
{ headers: corsHeaders },
);
}
if (kind === "oauth-protected-resource") {
const resource = identifierPath ? `${baseUrl}/${identifierPath}` : `${baseUrl}/api/mcp`;
return Response.json(
{
resource,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
return Response.json({ error: "not_found" }, { status: 404, headers: corsHeaders });
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
+7
View File
@@ -16,8 +16,15 @@ const publicHttpUrl = (variableName: string) =>
}
});
const publicHttpOrigin = (variableName: string) =>
publicHttpUrl(variableName).refine(
(value) => value === new URL(value).origin,
`${variableName} must be an HTTP(S) origin without a path`,
);
export const env = createEnv({
server: {
BASE_URL: publicHttpOrigin("BASE_URL").optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),