| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { jwtVerify } from "jose";
|
| import { cookies } from "next/headers";
|
| import { getSettings } from "@/lib/localDb";
|
| import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes";
|
| import { extractApiKey } from "@/sse/services/auth";
|
|
|
| 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 {
|
|
|
| }
|
| }
|
|
|
| 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 getRequestApiKey(
|
| request: RequestLike | Request | null | undefined,
|
| opts?: { allowUrl?: boolean }
|
| ): string | null {
|
| if (!request || typeof request !== "object") return null;
|
|
|
| const headers = "headers" in request ? request.headers : undefined;
|
| const rawUrl = "url" in request && typeof request.url === "string" ? request.url : null;
|
| const pathname = getRequestPathname(request);
|
| const syntheticUrl = rawUrl || (pathname ? `http://localhost${pathname}` : null);
|
|
|
|
|
|
|
|
|
| const allowUrl = opts?.allowUrl !== false;
|
|
|
| return extractApiKey({ headers, url: allowUrl ? syntheticUrl : null }, { allowUrl });
|
| }
|
|
|
| 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;
|
| }
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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;
|
| }
|
| }
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
| export async function verifyAuth(request: any): Promise<string | null> {
|
| if (await isDashboardSessionAuthenticated(request)) {
|
| return null;
|
| }
|
|
|
| const isManagement = isManagementApiRequest(request);
|
| const apiKey = getRequestApiKey(request, { allowUrl: !isManagement });
|
| if (isManagement) {
|
| if (await validateBearerApiKeyForManagement(apiKey)) {
|
| return null;
|
| }
|
| return apiKey ? "Invalid management token" : "Authentication required";
|
| }
|
|
|
| if (await validateBearerApiKey(apiKey)) {
|
| return null;
|
| }
|
|
|
| return "Authentication required";
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export async function isAuthenticated(request: Request): Promise<boolean> {
|
|
|
| if (!(await isAuthRequired(request))) {
|
| return true;
|
| }
|
|
|
| if (await isDashboardSessionAuthenticated(request)) {
|
| return true;
|
| }
|
|
|
| const isManagement = isManagementApiRequest(request);
|
| const apiKey = getRequestApiKey(request, { allowUrl: !isManagement });
|
| if (isManagement) {
|
| return validateBearerApiKeyForManagement(apiKey);
|
| }
|
|
|
| return validateBearerApiKey(apiKey);
|
| }
|
|
|
| |
| |
|
|
| export function isPublicRoute(pathname: string, method = "GET"): boolean {
|
| return isPublicApiRoute(pathname, method);
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| 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) {
|
|
|
|
|
| console.error(
|
| "[API_AUTH_GUARD] isAuthRequired failed, defaulting to true:",
|
| error?.message || error
|
| );
|
| return true;
|
| }
|
| }
|
|
|