diff --git a/.env.example b/.env.example index e05862c3c..6f5e1a9a1 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,8 @@ 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. diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index 364545e27..d9aa03901 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -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` | diff --git a/apps/docs/docs/advanced/proxy/index.mdx b/apps/docs/docs/advanced/proxy/index.mdx index b1e15a6fb..3ab2cecf5 100644 --- a/apps/docs/docs/advanced/proxy/index.mdx +++ b/apps/docs/docs/advanced/proxy/index.mdx @@ -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 diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 79c999c68..36683a39f 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -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. diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 71e31402c..2d3f0f439 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -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() { diff --git a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts deleted file mode 100644 index 43b107e2c..000000000 --- a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts +++ /dev/null @@ -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 }); -} diff --git a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts deleted file mode 100644 index eb5f22e5a..000000000 --- a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts +++ /dev/null @@ -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 }); -} diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index fa3695bf0..0debedc95 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -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: () => [ diff --git a/apps/nextjs/src/app/api/mcp/_base-url.ts b/apps/nextjs/src/app/api/mcp/_base-url.ts new file mode 100644 index 000000000..0ca537fd3 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_base-url.ts @@ -0,0 +1,8 @@ +import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common"; + +import { env } from "~/env"; + +type RequestHeaders = Parameters[0]; + +export const getMcpBaseUrl = (headers: RequestHeaders) => + removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers)); diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts index 05ae7380a..d057d8b4e 100644 --- a/apps/nextjs/src/app/api/mcp/_handler.ts +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -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, 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 '.'." }); + return jsonErrorResponse( + 401, + { error: "invalid_token", hint: "The token must be in the format '.'." }, + { "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); diff --git a/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts b/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts index ec9974cb5..e295427b1 100644 --- a/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts +++ b/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts @@ -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()); } diff --git a/apps/nextjs/src/app/well-known/[...path]/route.ts b/apps/nextjs/src/app/well-known/[...path]/route.ts new file mode 100644 index 000000000..488c7d769 --- /dev/null +++ b/apps/nextjs/src/app/well-known/[...path]/route.ts @@ -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 }); +} diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts index 1ba8efaed..b7f764cc3 100644 --- a/apps/nextjs/src/env.ts +++ b/apps/nextjs/src/env.ts @@ -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),