From 718683748ab990636e8d7168d75cc75f8d9244d2 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 24 Feb 2026 18:14:57 -0600 Subject: [PATCH] Signed-off-by: Max --- README.md | 4 ++++ src/lib/server/auth.ts | 47 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3fd006b..8ef19e4 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi - API routes now enforce auth on task read/write/delete (`/api/tasks` returns `401` when unauthenticated). - Added `PATCH /api/auth/account` to update profile and password for the current user. - Session cookie: `gantt_session` (HTTP-only, same-site lax, secure in production). +- Optional machine-to-machine auth for API routes: + - Set `GANTT_MACHINE_TOKEN` in server env. + - Send either `Authorization: Bearer ` or `x-gantt-machine-token: `. + - Optional identity fields: `GANTT_MACHINE_USER_ID`, `GANTT_MACHINE_USER_NAME`, `GANTT_MACHINE_USER_EMAIL`. - Added `Remember me` in auth forms: - Checked: persistent 30-day cookie/session. - Unchecked: browser-session cookie (clears on browser close), with server-side short-session expiry. diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 2d73246..25246b5 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,11 +1,12 @@ -import { randomBytes, createHash } from "crypto"; -import { cookies } from "next/headers"; +import { randomBytes, createHash, timingSafeEqual } from "crypto"; +import { cookies, headers } from "next/headers"; import { createClient } from "@supabase/supabase-js"; import { getServiceSupabase } from "@/lib/supabase/client"; const SESSION_COOKIE_NAME = "gantt_session"; const SESSION_HOURS_SHORT = 12; const SESSION_DAYS_REMEMBER = 30; +const MACHINE_AUTH_HEADER = "x-gantt-machine-token"; export interface AuthUser { id: string; @@ -66,6 +67,19 @@ function hashSessionToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } +function safeTokenEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "utf8"); + const rightBuffer = Buffer.from(right, "utf8"); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function extractBearerToken(authorization: string | null): string | null { + if (!authorization) return null; + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || null; +} + async function deleteExpiredSessions() { const supabase = getServiceSupabase(); const { error } = await supabase @@ -446,7 +460,36 @@ export async function getSessionTokenFromCookies(): Promise { return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; } +async function getMachineAuthenticatedUser(): Promise { + const configuredToken = process.env.GANTT_MACHINE_TOKEN; + if (!configuredToken) return null; + + const requestHeaders = await headers(); + const headerToken = requestHeaders.get(MACHINE_AUTH_HEADER); + const bearerToken = extractBearerToken(requestHeaders.get("authorization")); + const incomingToken = bearerToken || headerToken; + if (!incomingToken) return null; + + if (!safeTokenEquals(incomingToken, configuredToken)) { + return null; + } + + const machineUserId = process.env.GANTT_MACHINE_USER_ID || "machine-service-user"; + const machineUserName = process.env.GANTT_MACHINE_USER_NAME || "Mission Control Service"; + const machineUserEmail = process.env.GANTT_MACHINE_USER_EMAIL || "mission-control@internal.local"; + + return { + id: machineUserId, + name: machineUserName, + email: machineUserEmail, + createdAt: "1970-01-01T00:00:00.000Z", + }; +} + export async function getAuthenticatedUser(): Promise { + const machineUser = await getMachineAuthenticatedUser(); + if (machineUser) return machineUser; + const token = await getSessionTokenFromCookies(); if (!token) return null; return getUserBySessionToken(token);