Compare commits

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

No files matched your search

+4 -2
View File
@@ -58,11 +58,13 @@ TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
# Enable the development-only mock integration. Never enable this in production.
# UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# 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
# PocketBase API used by Homarr and by the standalone documentation site.
# 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` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `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` |
| `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` |
@@ -133,10 +134,9 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing.
:::
| Environment Variable | Description | Default |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------- |
| `DB_MIGRATIONS_DISABLED` | Disable database migrations. For example, when migrations are managed separately in Helm deployments. | `false` |
| `UNSAFE_ENABLE_MOCK_INTEGRATION` | Enable the development-only Mock integration. This exposes fabricated data and must not be used in production. | `false` |
| Environment Variable | Description |
| ------------------------ | --------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts.
+1 -1
View File
@@ -40,7 +40,7 @@ services:
volumes:
- ./homarr/appdata:/appdata
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`
networks:
- 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
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.
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
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
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 });
}
@@ -47,9 +47,6 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
const tCreate = await getI18n("integration.page.create");
const currentKind = result.data;
if (currentKind === "mock" && !env.UNSAFE_ENABLE_MOCK_INTEGRATION) {
notFound();
}
return (
<>
@@ -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),
-2
View File
@@ -19,7 +19,6 @@ const publicHttpUrl = (variableName: string) =>
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
@@ -28,7 +27,6 @@ export const env = createEnv({
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
UNSAFE_ENABLE_MOCK_INTEGRATION: process.env.UNSAFE_ENABLE_MOCK_INTEGRATION,
DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
@@ -18,12 +18,7 @@ import {
integrationSecrets,
integrationUserPermissions,
} from "@homarr/db/schema";
import type {
GroupPermissionKey,
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
} from "@homarr/definitions";
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
import {
getIntegrationKindsByCategory,
getPermissionsWithParents,
@@ -44,7 +39,6 @@ import {
} from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { env } from "../../env";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
@@ -54,15 +48,6 @@ import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
const throwIfMockIntegrationIsDisabled = (kind: IntegrationKind) => {
if (kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "Mock integrations are disabled",
});
};
export const integrationRouter = createTRPCRouter({
getKinds: publicProcedure
.meta({
@@ -73,14 +58,12 @@ export const integrationRouter = createTRPCRouter({
},
})
.query(() => {
return objectEntries(integrationDefs)
.filter(([kind]) => kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION)
.map(([kind, def]) => ({
kind,
name: def.name,
category: def.category,
requiredSecrets: def.secretKinds,
}));
return objectEntries(integrationDefs).map(([kind, def]) => ({
kind,
name: def.name,
category: def.category,
requiredSecrets: def.secretKinds,
}));
}),
all: protectedProcedure
.meta({
@@ -246,8 +229,6 @@ export const integrationRouter = createTRPCRouter({
})
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
throwIfMockIntegrationIsDisabled(input.kind);
logger.info("Creating integration", {
name: input.name,
kind: input.kind,
@@ -321,13 +321,6 @@ export const onboardRouter = createTRPCRouter({
.requiresStep("setup")
.input(onboardingCompleteSetupSchema)
.mutation(async ({ ctx, input }) => {
if (
!env.UNSAFE_ENABLE_MOCK_INTEGRATION &&
input.integrations.some((integration) => integration.kind === "mock")
) {
throw new TRPCError({ code: "FORBIDDEN", message: "Mock integrations are disabled" });
}
const boardList = await ctx.db.query.boards.findMany({
with: {
sections: { with: { layouts: true } },