From ec138c6fee3c6fa9a5a94af91f5459cd4f0e1f42 Mon Sep 17 00:00:00 2001 From: Mourad Maatoug Date: Sat, 16 May 2026 18:28:57 +0200 Subject: [PATCH] fix(auth+build): Bearer manage scope on management routes + lazy-load deepseek PoW solver (#2308) Integrated into release/v3.8.0 --- open-sse/lib/deepseek-pow.ts | 13 +++++- src/lib/api/requireManagementAuth.ts | 14 +++++- src/lib/middleware/cliTokenAuth.ts | 27 +++++++++-- src/shared/constants/managementScopes.ts | 31 +++++++++++++ src/shared/utils/apiAuth.ts | 37 ++++++++++++++- tests/unit/api-auth.test.ts | 59 ++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 src/shared/constants/managementScopes.ts diff --git a/open-sse/lib/deepseek-pow.ts b/open-sse/lib/deepseek-pow.ts index 148872a300..0b1412d621 100644 --- a/open-sse/lib/deepseek-pow.ts +++ b/open-sse/lib/deepseek-pow.ts @@ -9,9 +9,17 @@ import { dirname, join } from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Load the exact solver extracted from DeepSeek's worker chunk +// Load the exact solver extracted from DeepSeek's worker chunk. +// Lazy-loaded inside the function so the standalone Next build can collect +// page data without executing a dynamic require() at module-load time. const require = createRequire(import.meta.url); -const { U } = require(join(__dirname, "deepseek-pow-solver.cjs")); +let _U: any | undefined; +function loadU(): any { + if (_U === undefined) { + _U = require(join(__dirname, "deepseek-pow-solver.cjs")).U; + } + return _U; +} export function solveDeepSeekPow( algorithm: string, @@ -23,6 +31,7 @@ export function solveDeepSeekPow( if (algorithm !== "DeepSeekHashV1") throw new Error(`Unsupported: ${algorithm}`); const prefix = `${salt}_${expireAt}_`; + const U = loadU(); const createHash = () => { const self: any = {}; self._sponge = new U({ capacity: 256, padding: 6 }); diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index c3802da503..d8b0beb0f7 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -3,11 +3,21 @@ import { createErrorResponse } from "@/lib/api/errorResponse"; import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; +import { + MANAGE_SCOPE, + hasManageScope as hasManageScopeShared, +} from "@/shared/constants/managementScopes"; -export const MANAGE_SCOPE = "manage"; +export { MANAGE_SCOPE }; +/** + * Check whether any of the supplied scopes authorizes management API access. + * + * Re-exported here for backwards compatibility with existing callers. The + * canonical definition lives in `@/shared/constants/managementScopes`. + */ export function hasManageScope(scopes: string[] = []): boolean { - return scopes.includes("manage") || scopes.includes("admin"); + return hasManageScopeShared(scopes); } export async function requireManagementAuth(request: Request): Promise { diff --git a/src/lib/middleware/cliTokenAuth.ts b/src/lib/middleware/cliTokenAuth.ts index 7d404239df..9b2a8cc542 100644 --- a/src/lib/middleware/cliTokenAuth.ts +++ b/src/lib/middleware/cliTokenAuth.ts @@ -9,6 +9,25 @@ export function isLoopback(ip: string): boolean { return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; } +/** + * Read a header value preferring the Request's own headers (works in any + * context — App Router request handlers, unit tests, raw fetch) and falling + * back to `next/headers` only when the request object isn't carrying them. + * + * Calling `headers()` outside a request scope throws (see Next.js + * `next-dynamic-api-wrong-context`), so we guard the import. + */ +async function readHeader(request: Request, name: string): Promise { + const fromRequest = request.headers?.get(name); + if (fromRequest != null) return fromRequest; + try { + const hdrs = await headers(); + return hdrs.get(name); + } catch { + return null; + } +} + /** * Validates the CLI machine-id token sent by the local omniroute CLI. * Only accepted from loopback IPs. Disabled via OMNIROUTE_DISABLE_CLI_TOKEN=true. @@ -16,13 +35,13 @@ export function isLoopback(ip: string): boolean { export async function isCliTokenAuthValid(request: Request): Promise { if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") return false; - const hdrs = await headers(); - const token = hdrs.get(HEADER_NAME); + const token = await readHeader(request, HEADER_NAME); if (!token || token.length !== 32) return false; // Only allow loopback origin — check forwarded-for, real-ip, then host header. - const ip = - (hdrs.get("x-forwarded-for") ?? "").split(",")[0].trim() || hdrs.get("x-real-ip") || ""; + const forwardedFor = (await readHeader(request, "x-forwarded-for")) ?? ""; + const realIp = (await readHeader(request, "x-real-ip")) ?? ""; + const ip = forwardedFor.split(",")[0].trim() || realIp; if (ip && !isLoopback(ip)) return false; let expected: string; diff --git a/src/shared/constants/managementScopes.ts b/src/shared/constants/managementScopes.ts new file mode 100644 index 0000000000..a97652faad --- /dev/null +++ b/src/shared/constants/managementScopes.ts @@ -0,0 +1,31 @@ +/** + * Management API key scopes — the set of API key scopes that authorize a + * Bearer key on management routes (`/api/*` excluding `/api/v1/*` and the + * public allowlist). + * + * Single source of truth shared by: + * - `src/lib/api/requireManagementAuth.ts` (`hasManageScope`) + * - `src/shared/utils/apiAuth.ts` (`validateBearerApiKeyForManagement`) + * + * Keep both helpers in sync by importing `MANAGEMENT_API_KEY_SCOPES` from + * here — never re-declare the list inline. + */ + +/** Canonical scope name granted to the default environment key. */ +export const MANAGE_SCOPE = "manage"; + +/** + * Set of scopes that grant access to management API routes. + * `admin` is treated as a superset of `manage`. + */ +export const MANAGEMENT_API_KEY_SCOPES = new Set(["manage", "admin"]); + +/** + * Check whether any of the given scopes authorizes management API access. + */ +export function hasManageScope(scopes: readonly string[] = []): boolean { + for (const scope of scopes) { + if (MANAGEMENT_API_KEY_SCOPES.has(scope)) return true; + } + return false; +} diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index e4d4f75343..9599ba734f 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -160,6 +160,35 @@ async function validateBearerApiKey(apiKey: string | null): Promise { } } +/** + * Check whether a Bearer API key is valid AND carries a scope that authorizes + * it on management API routes (`/api/*` excluding `/api/v1/*` and the public + * allowlist). Returns `false` for unscoped keys so that the existing + * default-deny posture on management routes is preserved. + * + * Scope set is sourced from `@/shared/constants/managementScopes` so this + * helper stays in lockstep with `requireManagementAuth.hasManageScope`. + */ +async function validateBearerApiKeyForManagement(apiKey: string | null): Promise { + if (!apiKey) return false; + + try { + const [{ validateApiKey, getApiKeyMetadata }, { hasManageScope }] = await Promise.all([ + import("@/lib/db/apiKeys"), + import("@/shared/constants/managementScopes"), + ]); + const valid = await validateApiKey(apiKey); + if (!valid) return false; + + const metadata = await getApiKeyMetadata(apiKey); + if (!metadata) return false; + + return hasManageScope(metadata.scopes); + } catch { + return false; + } +} + export function isManagementApiRequest(request: RequestLike | Request): boolean { const pathname = getRequestPathname(request); if (!pathname?.startsWith("/api/")) return false; @@ -221,6 +250,9 @@ export async function verifyAuth(request: any): Promise { const bearerToken = getBearerToken(request); if (isManagementApiRequest(request)) { + if (await validateBearerApiKeyForManagement(bearerToken)) { + return null; + } return bearerToken ? "Invalid management token" : "Authentication required"; } @@ -250,11 +282,12 @@ export async function isAuthenticated(request: Request): Promise { return true; } + const bearerToken = getBearerToken(request); if (isManagementApiRequest(request)) { - return false; + return validateBearerApiKeyForManagement(bearerToken); } - return validateBearerApiKey(getBearerToken(request)); + return validateBearerApiKey(bearerToken); } /** diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 5ced7ec13e..ee4dd77b94 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -155,6 +155,65 @@ test("isAuthenticated rejects bearer API keys on management routes", async () => assert.equal(result, false); }); +test("verifyAuth accepts bearer API keys with manage scope on management routes", async () => { + const key = await apiKeysDb.createApiKey("mcp-management", "machine1234567890", ["manage"]); + const result = await apiAuth.verifyAuth({ + cookies: { + get() { + return undefined; + }, + }, + headers: new Headers({ authorization: `Bearer ${key.key}` }), + url: "https://example.com/api/providers", + }); + + assert.equal(result, null); +}); + +test("verifyAuth accepts bearer API keys with admin scope on management routes", async () => { + const key = await apiKeysDb.createApiKey("mcp-admin", "machine1234567890", ["admin"]); + const result = await apiAuth.verifyAuth({ + cookies: { + get() { + return undefined; + }, + }, + headers: new Headers({ authorization: `Bearer ${key.key}` }), + url: "https://example.com/api/settings", + }); + + assert.equal(result, null); +}); + +test("verifyAuth still rejects unscoped bearer API keys on management routes", async () => { + const key = await apiKeysDb.createApiKey("integration-no-scope", "machine1234567890"); + const result = await apiAuth.verifyAuth({ + cookies: { + get() { + return undefined; + }, + }, + headers: new Headers({ authorization: `Bearer ${key.key}` }), + url: "https://example.com/api/providers", + }); + + assert.equal(result, "Invalid management token"); +}); + +test("isAuthenticated accepts bearer API keys with manage scope on management routes", async () => { + process.env.INITIAL_PASSWORD = "bootstrap-password"; + await localDb.updateSettings({ requireLogin: true, password: "" }); + + const key = await apiKeysDb.createApiKey("mcp-management", "machine1234567890", ["manage"]); + const request = new Request("https://example.com/api/providers", { + headers: { authorization: `Bearer ${key.key}` }, + }); + + const result = await apiAuth.isAuthenticated(request); + + assert.equal(result, true); +}); + test("monitoring health reset route requires dashboard authentication", async () => { process.env.INITIAL_PASSWORD = "bootstrap-password"; await localDb.updateSettings({ requireLogin: true, password: "" });