mirror of
https://github.com/gethomepage/homepage.git
synced 2026-10-03 09:21:18 -07:00
wire into next auth
This commit is contained in:
+4
-1
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user