Compare commits

...
12 changed files with 172 additions and 62 deletions

No files matched your search

@@ -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 HTTP(S) origin used for OAuth metadata and redirects. Set this when Homarr is behind a reverse proxy that does not preserve the public host and protocol. Paths, credentials, query parameters, and fragments are not supported. | For example `https://homarr.example.com` | - |
| `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` |
+1 -1
View File
@@ -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
+10
View File
@@ -64,6 +64,16 @@ 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,32 +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"],
},
{ 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,
authorization_servers: [baseUrl],
},
{ headers: corsHeaders },
);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -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 } 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,14 +265,17 @@ 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,
{
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": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource${resourcePath}"`,
},
);
}
+8
View File
@@ -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));
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
import { auth } from "@homarr/auth/next";
import { getMcpBaseUrl } from "../../_base-url";
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
export async function GET(req: NextRequest) {
@@ -137,7 +138,8 @@ export async function GET(req: NextRequest) {
);
}
const loginUrl = new URL("/auth/login", url.origin);
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());
}
@@ -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"],
});
});
});
@@ -0,0 +1,49 @@
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("/");
const identifier = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl;
if (kind === "oauth-authorization-server") {
return Response.json(
{
issuer: identifier,
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"],
},
{ headers: corsHeaders },
);
}
if (kind === "oauth-protected-resource") {
return Response.json(
{
resource: identifier,
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 });
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { baseUrlSchema } from "./env";
describe("BASE_URL", () => {
test.each(["http://localhost:7575", "https://homarr.example.com", "https://homarr.example.com/"])(
"accepts the HTTP(S) origin %s",
(value) => {
expect(baseUrlSchema.safeParse(value).success).toBe(true);
},
);
test.each([
"ftp://homarr.example.com",
"https://user:password@homarr.example.com",
"https://homarr.example.com/prefix",
"https://homarr.example.com/.",
"https://homarr.example.com/a/..",
"https://homarr.example.com?preview=1",
"https://homarr.example.com#preview",
])("rejects the non-origin URL %s", (value) => {
expect(baseUrlSchema.safeParse(value).success).toBe(false);
});
});
+9
View File
@@ -1,7 +1,16 @@
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
import { z } from "zod/v4";
const httpOriginRegex = /^https?:\/\/[^/?#\\]+\/?$/i;
export const baseUrlSchema = z.url({ protocol: /^https?$/ }).refine((value) => {
const url = new URL(value);
return httpOriginRegex.test(value) && !url.username && !url.password;
}, "BASE_URL must be an HTTP(S) origin without credentials, a path, query parameters, or a fragment");
export const env = createEnv({
server: {
BASE_URL: baseUrlSchema.optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),