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 ?? {} };
}