From 03a64b07f963c4450fcf0ae5b5629bca5ca60539 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:13:33 -0700 Subject: [PATCH] wire into next auth --- docs/configs/mcp.md | 5 ++- src/middleware.js | 11 ++++++ src/middleware.test.js | 22 ++++++++++- src/pages/api/auth/[...nextauth].js | 6 ++- src/pages/api/mcp/index.js | 10 ++++- src/pages/api/mcp/index.test.js | 60 +++++++++++++++++++++++++++++ src/utils/mcp/homepage-mcp.js | 12 +++++- 7 files changed, 118 insertions(+), 8 deletions(-) diff --git a/docs/configs/mcp.md b/docs/configs/mcp.md index d6c4cd86b..c482a5fec 100644 --- a/docs/configs/mcp.md +++ b/docs/configs/mcp.md @@ -22,7 +22,9 @@ http://your-homepage-instance/api/mcp ## Authentication -If `HOMEPAGE_MCP_TOKEN` is set, MCP requests must include either of the following headers: +If Homepage auth is enabled with `HOMEPAGE_AUTH_ENABLED`, requests from an authenticated Homepage session are allowed. + +For MCP clients that cannot use the browser session, set `HOMEPAGE_MCP_TOKEN`. Requests can then include either of the following headers: ```txt Authorization: Bearer your-token @@ -39,6 +41,7 @@ Example Docker Compose environment block: ```yaml environment: HOMEPAGE_MCP_ENABLED: "true" + HOMEPAGE_AUTH_ENABLED: "true" HOMEPAGE_MCP_TOKEN: "change-me" ``` diff --git a/src/middleware.js b/src/middleware.js index 7d4052692..6f1e7edfd 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -4,6 +4,13 @@ import { NextResponse } from "next/server"; const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET; +function hasMcpToken(req) { + const token = process.env.HOMEPAGE_MCP_TOKEN; + if (!token) return false; + + return req.headers.get("authorization") === `Bearer ${token}` || req.headers.get("x-homepage-mcp-token") === token; +} + export async function middleware(req) { // Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set const host = req.headers.get("host"); @@ -21,6 +28,10 @@ export async function middleware(req) { } if (authEnabled) { + if (new URL(req.url).pathname === "/api/mcp" && hasMcpToken(req)) { + return NextResponse.next(); + } + const token = await getToken({ req, secret: authSecret }); if (!token) { const signInUrl = new URL("/auth/signin", req.url); diff --git a/src/middleware.test.js b/src/middleware.test.js index 5f24c0c23..a795f88d1 100644 --- a/src/middleware.test.js +++ b/src/middleware.test.js @@ -18,11 +18,14 @@ async function loadMiddleware() { return mod.middleware; } -function createReq(host = "localhost:3000", url = "http://localhost:3000/") { +function createReq(host = "localhost:3000", url = "http://localhost:3000/", headers = {}) { return { url, headers: { - get: (key) => (key === "host" ? host : null), + get: (key) => { + if (key === "host") return host; + return headers[key] ?? null; + }, }, }; } @@ -115,4 +118,19 @@ describe("middleware", () => { expect(NextResponse.next).toHaveBeenCalled(); expect(res).toEqual({ type: "next" }); }); + + it("allows MCP requests with a bearer token when auth is enabled", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_SECRET = "secret"; + process.env.HOMEPAGE_MCP_TOKEN = "mcp-secret"; + + const middleware = await loadMiddleware(); + const res = await middleware( + createReq("localhost:3000", "http://localhost:3000/api/mcp", { authorization: "Bearer mcp-secret" }), + ); + + expect(getToken).not.toHaveBeenCalled(); + expect(NextResponse.next).toHaveBeenCalled(); + expect(res).toEqual({ type: "next" }); + }); }); diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index a6c3f652c..1b29f81a4 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -91,7 +91,7 @@ if (authEnabled) { } } -export default NextAuth({ +export const authOptions = { providers, session: { strategy: "jwt", @@ -111,4 +111,6 @@ export default NextAuth({ signOut: async (message) => console.debug("[nextauth][event][signOut]", message), error: async (message) => console.error("[nextauth][event][error]", message), }, -}); +}; + +export default NextAuth(authOptions); diff --git a/src/pages/api/mcp/index.js b/src/pages/api/mcp/index.js index bb77fff86..b7971a432 100644 --- a/src/pages/api/mcp/index.js +++ b/src/pages/api/mcp/index.js @@ -1,11 +1,19 @@ +import { getServerSession } from "next-auth/next"; + +import { authOptions } from "pages/api/auth/[...nextauth]"; import { handleMcpRequest, mcpAuthorized, mcpEnabled } from "utils/mcp/homepage-mcp"; +async function hasHomepageSession(req, res) { + if (!process.env.HOMEPAGE_AUTH_ENABLED) return false; + return Boolean(await getServerSession(req, res, authOptions)); +} + export default async function handler(req, res) { if (!mcpEnabled()) { return res.status(404).end("Not Found"); } - if (!mcpAuthorized(req)) { + if (!mcpAuthorized(req) && !(await hasHomepageSession(req, res))) { return res.status(401).json({ error: "Unauthorized" }); } diff --git a/src/pages/api/mcp/index.test.js b/src/pages/api/mcp/index.test.js index f3ed88d92..12d6bb668 100644 --- a/src/pages/api/mcp/index.test.js +++ b/src/pages/api/mcp/index.test.js @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { getServerSession } = vi.hoisted(() => ({ + getServerSession: vi.fn(), +})); + +vi.mock("next-auth/next", () => ({ getServerSession })); + function mockResponse() { const res = { statusCode: 200, @@ -34,6 +40,7 @@ describe("pages/api/mcp", () => { beforeEach(() => { vi.resetModules(); + getServerSession.mockReset(); process.env = { ...originalEnv }; }); @@ -81,6 +88,59 @@ describe("pages/api/mcp", () => { expect(res.body.result.tools.length).toBeGreaterThan(0); }); + it("allows requests with a NextAuth session when Homepage auth is enabled", async () => { + process.env.HOMEPAGE_MCP_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "password"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + getServerSession.mockResolvedValueOnce({ user: { name: "Homepage" } }); + const handler = await loadHandler(); + const res = mockResponse(); + + await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, res); + + expect(getServerSession).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.body.result.tools.length).toBeGreaterThan(0); + }); + + it("rejects requests without a token or session when Homepage auth is enabled", async () => { + process.env.HOMEPAGE_MCP_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "password"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + getServerSession.mockResolvedValueOnce(null); + const handler = await loadHandler(); + const res = mockResponse(); + + await handler({ method: "POST", headers: {}, body: { jsonrpc: "2.0", id: 1, method: "tools/list" } }, res); + + expect(getServerSession).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it("allows bearer token requests when Homepage auth is enabled", async () => { + process.env.HOMEPAGE_MCP_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "password"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_MCP_TOKEN = "secret"; + const handler = await loadHandler(); + const res = mockResponse(); + + await handler( + { + method: "POST", + headers: { authorization: "Bearer secret" }, + body: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }, + res, + ); + + expect(getServerSession).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + it("rejects non-POST requests", async () => { process.env.HOMEPAGE_MCP_ENABLED = "true"; const handler = await loadHandler(); diff --git a/src/utils/mcp/homepage-mcp.js b/src/utils/mcp/homepage-mcp.js index 5726a82ae..aa8eba466 100644 --- a/src/utils/mcp/homepage-mcp.js +++ b/src/utils/mcp/homepage-mcp.js @@ -61,6 +61,10 @@ function requiredToken() { return process.env.HOMEPAGE_MCP_TOKEN; } +function authEnabled() { + return Boolean(process.env.HOMEPAGE_AUTH_ENABLED); +} + function jsonRpcResult(id, result) { return { jsonrpc: "2.0", id, result }; } @@ -435,14 +439,18 @@ export function mcpEnabled() { return enabled(); } -export function mcpAuthorized(req) { +export function mcpTokenAuthorized(req) { const token = requiredToken(); - if (!token) return true; + if (!token) return false; const authHeader = req.headers.authorization; return authHeader === `Bearer ${token}` || req.headers["x-homepage-mcp-token"] === token; } +export function mcpAuthorized(req) { + return mcpTokenAuthorized(req) || (!requiredToken() && !authEnabled()); +} + export function handleMcpRequest(message) { if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") { return jsonRpcError(message?.id, -32600, "Invalid JSON-RPC request");