wire into next auth

This commit is contained in:
shamoon
2026-06-13 07:13:33 -07:00
parent d96799c985
commit 03a64b07f9
7 changed files with 118 additions and 8 deletions
+4 -1
View File
@@ -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"
```
+11
View File
@@ -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);
+20 -2
View File
@@ -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" });
});
});
+4 -2
View File
@@ -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);
+9 -1
View File
@@ -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" });
}
+60
View File
@@ -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();
+10 -2
View File
@@ -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");