import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual, } from "node:crypto"; import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role"; import type { AppUser, PublicAppUser } from "@/types/app/user"; type JwtPayload = { sub: string; email: string | null; iat: number; exp: number; typ: "app_user"; }; export type AuthenticatedAppUser = { id: string; email: string | null; displayName: string | null; avatarUrl: string | null; authProvider: string; }; export type AppUserLoginContext = { ipAddress: string | null; userAgent: string | null; acceptLanguage: string | null; referer: string | null; origin: string | null; deviceInfo: Record; }; type AppUserLoginLogStatus = "success" | "failed" | "blocked"; type SupabaseServiceRoleClient = ReturnType< typeof createSupabaseServiceRoleClient >; const userSelect = "id,email,display_name,avatar_url,auth_provider,status,created_at,updated_at"; function getJwtSecret() { const secret = process.env.APP_JWT_SECRET ?? process.env.AUTH_SECRET; if (!secret) { throw new Error("APP_JWT_SECRET or AUTH_SECRET is not configured."); } return secret; } function base64UrlEncode(value: Buffer | string) { return Buffer.from(value) .toString("base64") .replace(/=/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); } function base64UrlDecode(value: string) { const padded = value.padEnd( value.length + ((4 - (value.length % 4)) % 4), "=", ); return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } function sign(input: string) { return base64UrlEncode( createHmac("sha256", getJwtSecret()).update(input).digest(), ); } function normalizeEmail(email: string) { return email.trim().toLowerCase(); } function firstHeaderValue(value: string | null) { return value?.split(",")[0]?.trim() || null; } function getHeader(headers: Headers, name: string) { return headers.get(name)?.trim() || null; } function getRequestIpAddress(headers: Headers) { return ( getHeader(headers, "cf-connecting-ip") ?? getHeader(headers, "x-real-ip") ?? firstHeaderValue(getHeader(headers, "x-forwarded-for")) ?? getHeader(headers, "x-client-ip") ); } function getTokenHash(token: string) { return createHash("sha256").update(token).digest("hex"); } export function getAppUserLoginContextFromRequest( request: Request, ): AppUserLoginContext { const headers = request.headers; const forwardedFor = getHeader(headers, "x-forwarded-for"); const userAgent = getHeader(headers, "user-agent"); const acceptLanguage = getHeader(headers, "accept-language"); const referer = getHeader(headers, "referer"); const origin = getHeader(headers, "origin"); return { ipAddress: getRequestIpAddress(headers), userAgent, acceptLanguage, referer, origin, deviceInfo: { method: request.method, url: request.url, host: getHeader(headers, "host"), userAgent, acceptLanguage, referer, origin, forwardedFor, realIp: getHeader(headers, "x-real-ip"), clientIp: getHeader(headers, "x-client-ip"), cfConnectingIp: getHeader(headers, "cf-connecting-ip"), cfIpcountry: getHeader(headers, "cf-ipcountry"), }, }; } function mapPublicUser(user: AppUser): PublicAppUser { return { id: user.id, email: user.email, displayName: user.display_name, avatarUrl: user.avatar_url, authProvider: user.auth_provider, }; } async function recordAppUserLoginLog({ context, email, failureReason, jwtToken, status, supabase, user, }: { context?: AppUserLoginContext; email: string; failureReason?: string | null; jwtToken?: string | null; status: AppUserLoginLogStatus; supabase: SupabaseServiceRoleClient; user?: Pick | null; }) { try { const jwtExpiresAt = jwtToken ? new Date(verifyAppUserToken(jwtToken).exp * 1000).toISOString() : null; const { error } = await supabase.from("wg_app_user_login_logs").insert({ user_id: user?.id ?? null, email, auth_provider: user?.auth_provider ?? "email", login_status: status, failure_reason: failureReason ?? null, ip_address: context?.ipAddress ?? null, user_agent: context?.userAgent ?? null, accept_language: context?.acceptLanguage ?? null, referer: context?.referer ?? null, origin: context?.origin ?? null, device_info: context?.deviceInfo ?? {}, jwt_token: jwtToken ?? null, jwt_token_hash: jwtToken ? getTokenHash(jwtToken) : null, jwt_expires_at: jwtExpiresAt, }); if (error) { console.error("Failed to record app user login log:", error); } } catch (error) { console.error("Failed to record app user login log:", error); } } export function hashPassword(password: string) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(password, salt, 64).toString("hex"); return `scrypt:v1:${salt}:${hash}`; } export function verifyPassword(password: string, storedHash: string | null) { if (!storedHash) { return false; } const [algorithm, version, salt, hash] = storedHash.split(":"); if (algorithm !== "scrypt" || version !== "v1" || !salt || !hash) { return false; } const expected = Buffer.from(hash, "hex"); const actual = scryptSync(password, salt, expected.length); return ( actual.length === expected.length && timingSafeEqual(actual, expected) ); } export function createAppUserToken(user: Pick) { const now = Math.floor(Date.now() / 1000); const payload: JwtPayload = { sub: user.id, email: user.email, iat: now, exp: now + 60 * 60 * 24 * 30, typ: "app_user", }; const header = { alg: "HS256", typ: "JWT", }; const input = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode( JSON.stringify(payload), )}`; return `${input}.${sign(input)}`; } export function verifyAppUserToken(token: string): JwtPayload { const [header, payload, signature] = token.split("."); if (!header || !payload || !signature) { throw new Error("Invalid token."); } const input = `${header}.${payload}`; const expected = sign(input); const expectedBuffer = Buffer.from(expected); const signatureBuffer = Buffer.from(signature); if ( expectedBuffer.length !== signatureBuffer.length || !timingSafeEqual(expectedBuffer, signatureBuffer) ) { throw new Error("Invalid token signature."); } const decoded = JSON.parse( base64UrlDecode(payload).toString("utf8"), ) as JwtPayload; if (decoded.typ !== "app_user" || !decoded.sub) { throw new Error("Invalid token payload."); } if (decoded.exp < Math.floor(Date.now() / 1000)) { throw new Error("Token expired."); } return decoded; } export async function registerAppUser({ displayName, email, password, }: { displayName?: string | null; email: string; password: string; }) { const normalizedEmail = normalizeEmail(email); if (!normalizedEmail.includes("@")) { throw new Error("A valid email is required."); } if (password.length < 8) { throw new Error("Password must be at least 8 characters."); } const supabase = createSupabaseServiceRoleClient(); const { data, error } = await supabase .from("wg_app_users") .insert({ email: normalizedEmail, password_hash: hashPassword(password), display_name: displayName?.trim() || null, auth_provider: "email", status: "active", }) .select(userSelect) .single(); if (error) { if (error.code === "23505") { throw new Error("Email is already registered."); } throw error; } await supabase.from("wg_user_settings").upsert( { user_id: data.id, preferences: {}, download_preferences: {}, }, { onConflict: "user_id" }, ); return { user: mapPublicUser(data), token: createAppUserToken(data), }; } export async function loginAppUser({ context, email, password, }: { context?: AppUserLoginContext; email: string; password: string; }) { const supabase = createSupabaseServiceRoleClient(); const normalizedEmail = normalizeEmail(email); const { data, error } = await supabase .from("wg_app_users") .select(`${userSelect},password_hash`) .eq("email", normalizedEmail) .eq("auth_provider", "email") .maybeSingle(); if (error) { throw error; } if (!data) { await recordAppUserLoginLog({ context, email: normalizedEmail, failureReason: "user_not_found", status: "failed", supabase, }); throw new Error("Invalid email or password."); } if (data.status !== "active") { await recordAppUserLoginLog({ context, email: normalizedEmail, failureReason: `user_status:${data.status}`, status: "blocked", supabase, user: data, }); throw new Error("Invalid email or password."); } if (!verifyPassword(password, data.password_hash)) { await recordAppUserLoginLog({ context, email: normalizedEmail, failureReason: "invalid_password", status: "failed", supabase, user: data, }); throw new Error("Invalid email or password."); } const token = createAppUserToken(data); await supabase .from("wg_app_users") .update({ last_login_at: new Date().toISOString() }) .eq("id", data.id); await recordAppUserLoginLog({ context, email: normalizedEmail, jwtToken: token, status: "success", supabase, user: data, }); return { user: mapPublicUser(data), token, }; } export async function getAppUserFromRequest(request: Request) { const authorization = request.headers.get("authorization") ?? ""; const token = authorization.match(/^Bearer\s+(.+)$/i)?.[1]; if (!token) { return null; } const payload = verifyAppUserToken(token); const supabase = createSupabaseServiceRoleClient(); const { data, error } = await supabase .from("wg_app_users") .select(userSelect) .eq("id", payload.sub) .maybeSingle(); if (error) { throw error; } if (!data || data.status !== "active") { return null; } return mapPublicUser(data) satisfies AuthenticatedAppUser; } export async function getOptionalAppUserFromRequest(request: Request) { const authorization = request.headers.get("authorization") ?? ""; if (!authorization) { return null; } return getAppUserFromRequest(request); }