import { createClient, type User } from "@supabase/supabase-js"; import bcrypt from "bcryptjs"; import { randomBytes } from "node:crypto"; import type { Request, Response } from "express"; import { env, isProduction } from "./config.js"; import { timedFetch, timedAuthFetch } from "./request-timing.js"; // Bind Google's PKCE verifier to the initiating browser, not a shared client's // in-memory store (which concurrent logins can overwrite or a restart can lose). export function oauthClient(req: Request, res: Response) { const cookieName = "sp_oauth_verifier"; const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" }; return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, { global: { fetch: timedAuthFetch }, auth: { autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce", storage: { getItem: (key) => key.endsWith("-code-verifier") ? req.cookies?.[cookieName] ?? null : null, setItem: (key, value) => { if (key.endsWith("-code-verifier")) res.cookie(cookieName, value, { ...options, maxAge: 10 * 60 * 1000 }); }, removeItem: (key) => { if (key.endsWith("-code-verifier")) res.clearCookie(cookieName, options); }, }, }, }); } export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, { global: { fetch: timedAuthFetch }, // OAuth returns a one-time code for the backend callback to exchange, rather // than placing session tokens in the browser URL fragment. auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" }, }); export const supabaseAdmin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, { global: { fetch: timedFetch }, auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false }, }); export async function hashPassword(password: string) { return bcrypt.hash(password, 12); } export function userHasPassword(user: User) { return user.app_metadata?.has_password === true || Boolean(user.identities?.some((identity) => identity.provider === "email")); } async function randomPasswordHash() { return bcrypt.hash(randomBytes(32).toString("hex"), 12); } export async function syncProfile(user: User, profile?: { fname?: string; lname?: string; academy?: string | null; role?: string | null; passwordHash?: string }) { const metadata = user.user_metadata ?? {}; const { data: existing, error: readError } = await supabaseAdmin.from("users").select("user_id,email").eq("user_id", user.id).maybeSingle(); if (readError) throw new Error(`Unable to read user profile: ${readError.message}`); if (existing) { if (existing.email === user.email) return; // The profile table is the canonical source for editable profile fields. // Auth callbacks should only keep the email in sync, never reset user edits. const { error } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id); if (error) throw new Error(`Unable to update user profile: ${error.message}`); return; } const fields = { user_id: user.id, fname: profile?.fname ?? metadata.fname ?? metadata.full_name?.split(" ")[0] ?? "User", lname: profile?.lname ?? metadata.lname ?? metadata.full_name?.split(" ").slice(1).join(" ") ?? "User", email: user.email, academy: profile?.academy ?? metadata.academy ?? null, role: profile?.role ?? metadata.role ?? null, account_role: "user", }; const { error } = await supabaseAdmin.from("users").insert({ ...fields, password_hash: profile?.passwordHash ?? await randomPasswordHash() }); if (error?.code === "23505") { // Two OAuth callbacks or sign-in requests can race after both observe that // the profile is missing. Re-read the user ID before treating the conflict // as a harmless concurrent insert; an email conflict for another user must // still fail. const { data: racedProfile, error: racedReadError } = await supabaseAdmin.from("users") .select("user_id").eq("user_id", user.id).maybeSingle(); if (racedReadError) throw new Error(`Unable to verify user profile: ${racedReadError.message}`); if (racedProfile) { const { error: syncError } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id); if (syncError) throw new Error(`Unable to update user profile: ${syncError.message}`); return; } } if (error) throw new Error(`Unable to create user profile: ${error.message}`); } export function userPayload(user: User, accountRole = "user") { return { id: user.id, email: user.email, emailVerified: Boolean(user.email_confirmed_at), accountRole, metadata: user.user_metadata ?? {} }; }