mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
/**
|
|
* API Authentication Guard — Shared utility for protecting API routes.
|
|
*
|
|
* Management APIs require a dashboard session, while client-facing APIs may still
|
|
* accept Bearer API keys. Route scope is inferred from the request pathname.
|
|
*
|
|
* @module shared/utils/apiAuth
|
|
*/
|
|
|
|
import { jwtVerify } from "jose";
|
|
import { cookies } from "next/headers";
|
|
import { getSettings } from "@/lib/localDb";
|
|
import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes";
|
|
|
|
type RequestLike = {
|
|
cookies?: {
|
|
get?: (name: string) => { value?: string } | undefined;
|
|
};
|
|
headers?: Headers;
|
|
method?: string;
|
|
nextUrl?: { hostname?: string | null; pathname?: string | null } | null;
|
|
url?: string;
|
|
};
|
|
|
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]);
|
|
|
|
function hasConfiguredPassword(settings: Record<string, unknown>): boolean {
|
|
return typeof settings.password === "string" && settings.password.length > 0;
|
|
}
|
|
|
|
function getRequestPathname(request: RequestLike | Request | null | undefined): string | null {
|
|
const nextPathname =
|
|
request &&
|
|
typeof request === "object" &&
|
|
"nextUrl" in request &&
|
|
request.nextUrl &&
|
|
typeof request.nextUrl.pathname === "string"
|
|
? request.nextUrl.pathname
|
|
: null;
|
|
|
|
if (nextPathname) return nextPathname;
|
|
|
|
const rawUrl =
|
|
request && typeof request === "object" && "url" in request && typeof request.url === "string"
|
|
? request.url
|
|
: "";
|
|
|
|
if (!rawUrl) return null;
|
|
|
|
try {
|
|
return new URL(rawUrl, "http://localhost").pathname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isOnboardingBootstrapPath(pathname: string | null): boolean {
|
|
return pathname === "/dashboard/onboarding";
|
|
}
|
|
|
|
function isRequireLoginBootstrapWritePath(pathname: string | null, method: string): boolean {
|
|
return pathname === "/api/settings/require-login" && method.toUpperCase() === "POST";
|
|
}
|
|
|
|
function getRequestMethod(request: RequestLike | Request | null | undefined): string {
|
|
if (
|
|
request &&
|
|
typeof request === "object" &&
|
|
"method" in request &&
|
|
typeof request.method === "string"
|
|
) {
|
|
return request.method.toUpperCase();
|
|
}
|
|
return "GET";
|
|
}
|
|
|
|
function getRequestHostname(request: RequestLike | Request | null | undefined): string | null {
|
|
const nextHostname =
|
|
request &&
|
|
typeof request === "object" &&
|
|
"nextUrl" in request &&
|
|
request.nextUrl &&
|
|
typeof request.nextUrl.hostname === "string"
|
|
? request.nextUrl.hostname
|
|
: null;
|
|
|
|
if (nextHostname) return nextHostname;
|
|
|
|
const rawUrl =
|
|
request && typeof request === "object" && "url" in request && typeof request.url === "string"
|
|
? request.url
|
|
: "";
|
|
|
|
if (rawUrl) {
|
|
try {
|
|
return new URL(rawUrl, "http://localhost").hostname;
|
|
} catch {
|
|
// Fall through to Host header parsing.
|
|
}
|
|
}
|
|
|
|
const requestHeaders =
|
|
request && typeof request === "object" && "headers" in request ? request.headers : undefined;
|
|
const host = requestHeaders?.get("host") || requestHeaders?.get("Host") || null;
|
|
if (!host) return null;
|
|
|
|
try {
|
|
return new URL(`http://${host}`).hostname;
|
|
} catch {
|
|
return host.split(":")[0] || null;
|
|
}
|
|
}
|
|
|
|
export function isLoopbackRequest(request: RequestLike | Request | null | undefined): boolean {
|
|
const hostname = getRequestHostname(request);
|
|
if (!hostname) return false;
|
|
|
|
const normalized = hostname
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^\[(.*)\]$/, "$1");
|
|
if (LOOPBACK_HOSTNAMES.has(normalized)) return true;
|
|
if (/^127(?:\.\d{1,3}){3}$/.test(normalized)) return true;
|
|
return false;
|
|
}
|
|
|
|
function getCookieValueFromHeader(headers: Headers | undefined, name: string): string | null {
|
|
const cookieHeader = headers?.get("cookie") || headers?.get("Cookie");
|
|
if (!cookieHeader) return null;
|
|
|
|
for (const segment of cookieHeader.split(";")) {
|
|
const [rawKey, ...rawValue] = segment.split("=");
|
|
if (!rawKey || rawValue.length === 0) continue;
|
|
if (rawKey.trim() !== name) continue;
|
|
return rawValue.join("=").trim();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getBearerToken(request: RequestLike | Request | null | undefined): string | null {
|
|
const headers =
|
|
request && typeof request === "object" && "headers" in request ? request.headers : undefined;
|
|
const authHeader = headers?.get("authorization") || headers?.get("Authorization");
|
|
if (typeof authHeader !== "string") return null;
|
|
|
|
const trimmedHeader = authHeader.trim();
|
|
if (!trimmedHeader.toLowerCase().startsWith("bearer ")) return null;
|
|
return trimmedHeader.slice(7).trim() || null;
|
|
}
|
|
|
|
async function validateBearerApiKey(apiKey: string | null): Promise<boolean> {
|
|
if (!apiKey) return false;
|
|
|
|
try {
|
|
const { validateApiKey } = await import("@/lib/db/apiKeys");
|
|
return await validateApiKey(apiKey);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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;
|
|
if (pathname.startsWith("/api/v1/")) return false;
|
|
return !isPublicApiRoute(pathname, getRequestMethod(request));
|
|
}
|
|
|
|
export async function isDashboardSessionAuthenticated(
|
|
request?: RequestLike | Request | null
|
|
): Promise<boolean> {
|
|
if (!process.env.JWT_SECRET) return false;
|
|
|
|
let token =
|
|
request &&
|
|
typeof request === "object" &&
|
|
"cookies" in request &&
|
|
request.cookies?.get?.("auth_token")?.value
|
|
? request.cookies.get("auth_token")?.value || null
|
|
: null;
|
|
|
|
const requestHeaders =
|
|
request && typeof request === "object" && "headers" in request ? request.headers : undefined;
|
|
|
|
if (!token) {
|
|
token = getCookieValueFromHeader(requestHeaders, "auth_token");
|
|
}
|
|
|
|
if (!token) {
|
|
try {
|
|
const cookieStore = await cookies();
|
|
token = cookieStore.get("auth_token")?.value || null;
|
|
} catch {
|
|
token = null;
|
|
}
|
|
}
|
|
|
|
if (!token) return false;
|
|
|
|
try {
|
|
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
|
await jwtVerify(token, secret);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ──────────────── Auth Verification ────────────────
|
|
|
|
/**
|
|
* Check if a request is authenticated.
|
|
*
|
|
* @returns null if authenticated, error message string if not
|
|
*/
|
|
export async function verifyAuth(request: any): Promise<string | null> {
|
|
if (await isDashboardSessionAuthenticated(request)) {
|
|
return null;
|
|
}
|
|
|
|
const bearerToken = getBearerToken(request);
|
|
if (isManagementApiRequest(request)) {
|
|
if (await validateBearerApiKeyForManagement(bearerToken)) {
|
|
return null;
|
|
}
|
|
return bearerToken ? "Invalid management token" : "Authentication required";
|
|
}
|
|
|
|
if (await validateBearerApiKey(bearerToken)) {
|
|
return null;
|
|
}
|
|
|
|
return "Authentication required";
|
|
}
|
|
|
|
/**
|
|
* Check if a request is authenticated — boolean convenience wrapper for route handlers.
|
|
*
|
|
* Uses `cookies()` from next/headers (App Router compatible) and Bearer API key.
|
|
* Returns true if authenticated, false otherwise.
|
|
*
|
|
* Unlike `verifyAuth`, this does NOT check `isAuthRequired()` — callers that
|
|
* need to conditionally skip auth should check that separately.
|
|
*/
|
|
export async function isAuthenticated(request: Request): Promise<boolean> {
|
|
// If settings say login/auth is disabled, treat all requests as authenticated
|
|
if (!(await isAuthRequired(request))) {
|
|
return true;
|
|
}
|
|
|
|
if (await isDashboardSessionAuthenticated(request)) {
|
|
return true;
|
|
}
|
|
|
|
const bearerToken = getBearerToken(request);
|
|
if (isManagementApiRequest(request)) {
|
|
return validateBearerApiKeyForManagement(bearerToken);
|
|
}
|
|
|
|
return validateBearerApiKey(bearerToken);
|
|
}
|
|
|
|
/**
|
|
* Check if a route is in the public (no-auth) allowlist.
|
|
*/
|
|
export function isPublicRoute(pathname: string, method = "GET"): boolean {
|
|
return isPublicApiRoute(pathname, method);
|
|
}
|
|
|
|
/**
|
|
* Check if authentication is required based on settings.
|
|
* If requireLogin is explicitly false, auth is skipped. Fresh installs without
|
|
* a password keep their unauthenticated bootstrap path only on loopback
|
|
* requests; exposed network requests must configure INITIAL_PASSWORD or log in.
|
|
*/
|
|
export async function isAuthRequired(
|
|
request?: RequestLike | Request | null | undefined
|
|
): Promise<boolean> {
|
|
try {
|
|
const settings = await getSettings();
|
|
if (settings.requireLogin === false) return false;
|
|
|
|
if (!hasConfiguredPassword(settings) && !process.env.INITIAL_PASSWORD) {
|
|
if (!request) return false;
|
|
|
|
const pathname = getRequestPathname(request);
|
|
const method = getRequestMethod(request);
|
|
if (isOnboardingBootstrapPath(pathname)) {
|
|
return false;
|
|
}
|
|
|
|
if (pathname && isPublicApiRoute(pathname, method)) {
|
|
return false;
|
|
}
|
|
|
|
if (isRequireLoginBootstrapWritePath(pathname, method)) {
|
|
return false;
|
|
}
|
|
|
|
return settings.setupComplete === true || !isLoopbackRequest(request);
|
|
}
|
|
|
|
return true;
|
|
} catch (error: any) {
|
|
// On error, require auth (secure by default)
|
|
// Log the error so failures (e.g., SQLITE_BUSY) aren't silent 401s
|
|
console.error(
|
|
"[API_AUTH_GUARD] isAuthRequired failed, defaulting to true:",
|
|
error?.message || error
|
|
);
|
|
return true;
|
|
}
|
|
}
|