Spaces:
Running
Running
File size: 4,810 Bytes
ce4b4fb f496327 35243f1 f496327 35243f1 f496327 ce4b4fb 35243f1 ce4b4fb 82b44e7 ce4b4fb 57b63b8 ce4b4fb 57b63b8 ce4b4fb 8aa1d7f ce4b4fb ab01304 ce4b4fb d1ceac4 ce4b4fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | 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 ?? {} };
}
|