diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index f2ae41d4b..881b1aacc 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 URL used for OAuth metadata and redirects. Set this when Homarr is behind a reverse proxy that does not preserve the public host and protocol. | Any valid absolute URL | - | | `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 668031d9d..a24d77b5e 100644 --- a/apps/docs/docs/advanced/proxy/index.mdx +++ b/apps/docs/docs/advanced/proxy/index.mdx @@ -38,7 +38,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/nextjs/next.config.ts b/apps/nextjs/next.config.ts index b8c3c5084..256b44426 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -67,7 +67,10 @@ 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*" }, + { + source: "/api/mcp/:transport/.well-known/:path*", + destination: "/well-known/:path*/api/mcp/:transport", + }, ]; }, diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 6d4635b10..6bedffbc7 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -7,12 +7,13 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; -import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common"; +import { removeTrailingSlash } from "@homarr/common"; import { ipAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { db } from "@homarr/db"; import { getPackageVersion } from "~/versions/package-reader"; +import { getMcpBaseUrl } from "../_base-url"; import { extractMcpTools } from "../_extract-tools"; const logger = createLogger({ module: "mcpRoute" }); @@ -264,7 +265,7 @@ const handler = async (req: NextRequest) => { if (!apiKeyValue) { recordAuthFailure(ipAddress); - const baseUrl = extractBaseUrlFromHeaders(req.headers); + const baseUrl = getMcpBaseUrl(req.headers); const resourcePath = removeTrailingSlash(new URL(req.url).pathname); return jsonErrorResponse( 401, 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..6fd9d9fc7 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_base-url.ts @@ -0,0 +1,8 @@ +import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; + +import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common"; + +import { env } from "~/env"; + +export const getMcpBaseUrl = (headers: ReadonlyHeaders) => + removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers)); 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 dfc171000..4ace2d736 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 { extractBaseUrlFromHeaders } from "@homarr/common"; import { auth } from "@homarr/auth/next"; +import { getMcpBaseUrl } from "../../_base-url"; import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store"; export async function GET(req: NextRequest) { @@ -138,7 +138,7 @@ export async function GET(req: NextRequest) { ); } - const baseUrl = extractBaseUrlFromHeaders(req.headers); + const baseUrl = getMcpBaseUrl(req.headers); 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.spec.ts b/apps/nextjs/src/app/well-known/[...path]/route.spec.ts new file mode 100644 index 000000000..f38567f04 --- /dev/null +++ b/apps/nextjs/src/app/well-known/[...path]/route.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("~/env", () => ({ env: { BASE_URL: "https://homarr.example.com/" } })); + +import { GET } from "./route"; + +const request = new NextRequest("http://internal-container:3000/well-known/oauth-protected-resource", { + headers: { + host: "internal-container:3000", + "x-forwarded-host": "untrusted.example.com", + "x-forwarded-proto": "http", + }, +}); + +describe("MCP OAuth metadata", () => { + test("uses the configured public base URL for authorization server metadata", async () => { + const response = await GET(request, { + params: Promise.resolve({ path: ["oauth-authorization-server"] }), + }); + + await expect(response.json()).resolves.toMatchObject({ + issuer: "https://homarr.example.com", + authorization_endpoint: "https://homarr.example.com/api/mcp/oauth/authorize", + }); + }); + + test("preserves a path-inserted authorization server issuer", async () => { + const response = await GET(request, { + params: Promise.resolve({ path: ["oauth-authorization-server", "api", "mcp", "mcp"] }), + }); + + await expect(response.json()).resolves.toMatchObject({ + issuer: "https://homarr.example.com/api/mcp/mcp", + authorization_endpoint: "https://homarr.example.com/api/mcp/oauth/authorize", + }); + }); + + test("preserves the transport path in protected resource metadata", async () => { + const response = await GET(request, { + params: Promise.resolve({ path: ["oauth-protected-resource", "api", "mcp", "mcp"] }), + }); + + await expect(response.json()).resolves.toEqual({ + resource: "https://homarr.example.com/api/mcp/mcp", + authorization_servers: ["https://homarr.example.com"], + }); + }); + + test("returns the public origin for root protected resource metadata", async () => { + const response = await GET(request, { + params: Promise.resolve({ path: ["oauth-protected-resource"] }), + }); + + await expect(response.json()).resolves.toEqual({ + resource: "https://homarr.example.com", + authorization_servers: ["https://homarr.example.com"], + }); + }); +}); diff --git a/apps/nextjs/src/app/well-known/[...path]/route.ts b/apps/nextjs/src/app/well-known/[...path]/route.ts index f1ade29a7..060933c36 100644 --- a/apps/nextjs/src/app/well-known/[...path]/route.ts +++ b/apps/nextjs/src/app/well-known/[...path]/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; -import { extractBaseUrlFromHeaders } from "@homarr/common"; +import { getMcpBaseUrl } from "~/app/api/mcp/_base-url"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -8,14 +8,16 @@ const corsHeaders = { "Access-Control-Allow-Headers": "*", }; -export async function GET(_req: NextRequest, props: { params: Promise<{ path: string[] }> }) { +export async function GET(req: NextRequest, props: { params: Promise<{ path: string[] }> }) { const [kind, ...rest] = (await props.params).path; - const baseUrl = extractBaseUrlFromHeaders(_req.headers); + const baseUrl = getMcpBaseUrl(req.headers); + const identifierPath = rest.join("/"); + const identifier = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl; if (kind === "oauth-authorization-server") { return Response.json( { - issuer: baseUrl, + issuer: identifier, authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`, token_endpoint: `${baseUrl}/api/mcp/oauth/token`, registration_endpoint: `${baseUrl}/api/mcp/oauth/register`, @@ -30,10 +32,9 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ path: st } if (kind === "oauth-protected-resource") { - const resourcePath = rest.join("/"); return Response.json( { - resource: resourcePath ? `${baseUrl}/${resourcePath}` : baseUrl, + resource: identifier, authorization_servers: [baseUrl], }, { headers: corsHeaders }, diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts index d285a8393..c47f4b66f 100644 --- a/apps/nextjs/src/env.ts +++ b/apps/nextjs/src/env.ts @@ -1,7 +1,9 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"; +import { z } from "zod/v4"; export const env = createEnv({ server: { + BASE_URL: z.string().url().optional(), UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false), DEMO_READ_ONLY: createBooleanSchema(true),