mirror of
https://github.com/gethomepage/homepage.git
synced 2026-10-02 17:01:15 -07:00
wire into next auth
This commit is contained in:
+4
-1
@@ -22,7 +22,9 @@ http://your-homepage-instance/api/mcp
|
|||||||
|
|
||||||
## Authentication
|
## 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
|
```txt
|
||||||
Authorization: Bearer your-token
|
Authorization: Bearer your-token
|
||||||
@@ -39,6 +41,7 @@ Example Docker Compose environment block:
|
|||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
HOMEPAGE_MCP_ENABLED: "true"
|
HOMEPAGE_MCP_ENABLED: "true"
|
||||||
|
HOMEPAGE_AUTH_ENABLED: "true"
|
||||||
HOMEPAGE_MCP_TOKEN: "change-me"
|
HOMEPAGE_MCP_TOKEN: "change-me"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { NextResponse } from "next/server";
|
|||||||
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
||||||
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET;
|
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) {
|
export async function middleware(req) {
|
||||||
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
|
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
|
||||||
const host = req.headers.get("host");
|
const host = req.headers.get("host");
|
||||||
@@ -21,6 +28,10 @@ export async function middleware(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authEnabled) {
|
if (authEnabled) {
|
||||||
|
if (new URL(req.url).pathname === "/api/mcp" && hasMcpToken(req)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
const token = await getToken({ req, secret: authSecret });
|
const token = await getToken({ req, secret: authSecret });
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const signInUrl = new URL("/auth/signin", req.url);
|
const signInUrl = new URL("/auth/signin", req.url);
|
||||||
|
|||||||
+20
-2
@@ -18,11 +18,14 @@ async function loadMiddleware() {
|
|||||||
return mod.middleware;
|
return mod.middleware;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReq(host = "localhost:3000", url = "http://localhost:3000/") {
|
function createReq(host = "localhost:3000", url = "http://localhost:3000/", headers = {}) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
headers: {
|
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(NextResponse.next).toHaveBeenCalled();
|
||||||
expect(res).toEqual({ type: "next" });
|
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,
|
providers,
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
@@ -111,4 +111,6 @@ export default NextAuth({
|
|||||||
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
|
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
|
||||||
error: async (message) => console.error("[nextauth][event][error]", 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";
|
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) {
|
export default async function handler(req, res) {
|
||||||
if (!mcpEnabled()) {
|
if (!mcpEnabled()) {
|
||||||
return res.status(404).end("Not Found");
|
return res.status(404).end("Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mcpAuthorized(req)) {
|
if (!mcpAuthorized(req) && !(await hasHomepageSession(req, res))) {
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { getServerSession } = vi.hoisted(() => ({
|
||||||
|
getServerSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next-auth/next", () => ({ getServerSession }));
|
||||||
|
|
||||||
function mockResponse() {
|
function mockResponse() {
|
||||||
const res = {
|
const res = {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -34,6 +40,7 @@ describe("pages/api/mcp", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
getServerSession.mockReset();
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +88,59 @@ describe("pages/api/mcp", () => {
|
|||||||
expect(res.body.result.tools.length).toBeGreaterThan(0);
|
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 () => {
|
it("rejects non-POST requests", async () => {
|
||||||
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||||
const handler = await loadHandler();
|
const handler = await loadHandler();
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ function requiredToken() {
|
|||||||
return process.env.HOMEPAGE_MCP_TOKEN;
|
return process.env.HOMEPAGE_MCP_TOKEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authEnabled() {
|
||||||
|
return Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
function jsonRpcResult(id, result) {
|
function jsonRpcResult(id, result) {
|
||||||
return { jsonrpc: "2.0", id, result };
|
return { jsonrpc: "2.0", id, result };
|
||||||
}
|
}
|
||||||
@@ -435,14 +439,18 @@ export function mcpEnabled() {
|
|||||||
return enabled();
|
return enabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mcpAuthorized(req) {
|
export function mcpTokenAuthorized(req) {
|
||||||
const token = requiredToken();
|
const token = requiredToken();
|
||||||
if (!token) return true;
|
if (!token) return false;
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
return authHeader === `Bearer ${token}` || req.headers["x-homepage-mcp-token"] === token;
|
return authHeader === `Bearer ${token}` || req.headers["x-homepage-mcp-token"] === token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mcpAuthorized(req) {
|
||||||
|
return mcpTokenAuthorized(req) || (!requiredToken() && !authEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
export function handleMcpRequest(message) {
|
export function handleMcpRequest(message) {
|
||||||
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
||||||
return jsonRpcError(message?.id, -32600, "Invalid JSON-RPC request");
|
return jsonRpcError(message?.id, -32600, "Invalid JSON-RPC request");
|
||||||
|
|||||||
Reference in New Issue
Block a user