Compare commits

...
Author SHA1 Message Date
ajnart f59c8a993a fix(mcp): honor public OAuth origin 2026-09-06 10:44:17 +02:00
13 changed files with 115 additions and 72 deletions

No files matched your search

+2
View File
@@ -63,6 +63,8 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials, # Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# query strings, or fragments. Trailing slashes are normalized at startup. # query strings, or fragments. Trailing slashes are normalized at startup.
# Public Homarr origin used by MCP OAuth metadata and redirects behind proxies.
# BASE_URL=https://homarr.example.com
HOMARR_WEBSITE_URL=https://homarr.dev HOMARR_WEBSITE_URL=https://homarr.dev
# PocketBase API used by Homarr and by the standalone documentation site. # PocketBase API used by Homarr and by the standalone documentation site.
# Defaults to HOMARR_WEBSITE_URL. # Defaults to HOMARR_WEBSITE_URL.
@@ -30,6 +30,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
| `PUID` | User ID to run the container as | Any valid user ID | `0` | | `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` | | `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | | `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `BASE_URL` | Public Homarr origin used by MCP OAuth metadata and redirects. Set this when a reverse proxy does not preserve the public host and protocol. | HTTP(S) origin without a path | - |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | | `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | | `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` | | `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
+1 -1
View File
@@ -40,7 +40,7 @@ services:
volumes: volumes:
- ./homarr/appdata:/appdata - ./homarr/appdata:/appdata
environment: environment:
- BASE_URL=your.internal.dns.address.here.com - BASE_URL=https://your.internal.dns.address.here.com
- SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32` - SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32`
networks: networks:
- proxy - proxy
+4
View File
@@ -54,6 +54,10 @@ The **AI / MCP** tab on Homarr's API page shows the current endpoint, configurat
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization. for clients that support dynamic authorization.
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access. Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators. Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
+9
View File
@@ -65,6 +65,15 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
async rewrites() {
return [
{ source: "/.well-known/:path*", destination: "/well-known/:path*" },
{
source: "/api/mcp/:transport/.well-known/:path*",
destination: "/well-known/:path*/api/mcp/:transport",
},
];
},
// skipcq: JS-0116 // skipcq: JS-0116
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax // eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
async headers() { async headers() {
@@ -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", API_KEY_HEADER_NAME: "ApiKey",
getSessionFromApiKeyAsync: mocks.authenticate, 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/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }), 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" }), getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
})); }));
vi.mock("@homarr/db", () => ({ db: {} })); vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/env", () => ({ env: { BASE_URL: undefined } }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" })); vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({ vi.mock("../_extract-tools", () => ({
extractMcpTools: () => [ 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 type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; 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 { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
@@ -24,6 +24,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader"; import { getPackageVersion } from "~/versions/package-reader";
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error"; import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
import { getMcpBaseUrl } from "./_base-url";
import { extractMcpTools } from "./_extract-tools"; import { extractMcpTools } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol"; 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) => { export const handleMcpRequest = async (req: NextRequest) => {
const pathname = new URL(req.url).pathname; const pathname = new URL(req.url).pathname;
if (pathname.endsWith("/sse") || pathname.endsWith("/message")) { if (pathname.endsWith("/sse") || pathname.endsWith("/message")) {
@@ -269,30 +276,37 @@ export const handleMcpRequest = async (req: NextRequest) => {
if (!apiKeyValue) { if (!apiKeyValue) {
recordAuthFailure(ipAddress); recordAuthFailure(ipAddress);
const baseUrl = extractBaseUrlFromHeaders(req.headers);
return jsonErrorResponse( return jsonErrorResponse(
401, 401,
{ {
error: "unauthorized", error: "unauthorized",
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server", 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(".")) { if (!apiKeyValue.includes(".")) {
recordAuthFailure(ipAddress); 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); const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
if (!session) { if (!session) {
recordAuthFailure(ipAddress); recordAuthFailure(ipAddress);
return jsonErrorResponse(401, { return jsonErrorResponse(
error: "invalid_token", 401,
hint: "The API key was not found or the token is incorrect.", {
}); error: "invalid_token",
hint: "The API key was not found or the token is incorrect.",
},
{ "WWW-Authenticate": getOAuthChallenge(req) },
);
} }
clearAuthFailures(ipAddress); clearAuthFailures(ipAddress);
@@ -1,8 +1,8 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getMcpBaseUrl } from "../../_base-url";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store"; import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
@@ -83,8 +83,9 @@ export async function GET(req: NextRequest) {
); );
} }
const expectedResource = `${extractBaseUrlFromHeaders(req.headers)}/api/mcp`; const baseUrl = getMcpBaseUrl(req.headers);
if (resource && resource !== expectedResource) { const allowedResources = new Set([`${baseUrl}/api/mcp`, `${baseUrl}/api/mcp/mcp`]);
if (resource && !allowedResources.has(resource)) {
return Response.json( return Response.json(
{ {
error: "invalid_target", 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}`); loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
return Response.redirect(loginUrl.toString()); 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({ export const env = createEnv({
server: { server: {
BASE_URL: publicHttpOrigin("BASE_URL").optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true), DEMO_READ_ONLY: createBooleanSchema(true),