fix(mcp): honor public OAuth origin (#6779)
This commit is contained in:
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: () => [
|
||||
|
||||
@@ -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));
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user