auth-account-linking.ts140 lines · main
1/**
2 * OAuth account linking — Gap Fix #4 / Sprint S3.
3 *
4 * Automatically links OAuth accounts to existing users when the email
5 * matches (Gmail-normalized). Manual unlink for admin repair.
6 */
7
8import { NotFoundError, ValidationError } from '@briven/shared';
9
10import { runInProjectDatabase } from '../db/data-plane.js';
11import { normalizeEmail } from './auth-security.js';
12
13/**
14 * After an OAuth user is created, check whether another user already
15 * exists with the same normalized email. If so, move the OAuth account(s)
16 * to the existing user and delete the duplicate user.
17 */
18export async function maybeAutoLinkOAuthAccount(
19 projectId: string,
20 newUserId: string,
21 email: string,
22): Promise<{ linkedToUserId: string } | null> {
23 const normalizedEmail = normalizeEmail(email);
24
25 return runInProjectDatabase(projectId, async (tx) => {
26 // Candidate set: exact lower(email) match, or all gmail/googlemail rows
27 // (dots/+ aliases only matter for those domains).
28 const domain = normalizedEmail.slice(normalizedEmail.lastIndexOf('@') + 1);
29 const candidates =
30 domain === 'gmail.com'
31 ? ((await tx.unsafe(
32 `SELECT id, email FROM "_briven_auth_users"
33 WHERE lower(email) LIKE '%@gmail.com' OR lower(email) LIKE '%@googlemail.com'
34 ORDER BY created_at ASC`,
35 [] as never,
36 )) as Array<{ id: string; email: string }>)
37 : ((await tx.unsafe(
38 `SELECT id, email FROM "_briven_auth_users"
39 WHERE lower(email) = lower($1)
40 ORDER BY created_at ASC`,
41 [email] as never,
42 )) as Array<{ id: string; email: string }>);
43
44 const matches = candidates.filter((u) => normalizeEmail(u.email) === normalizedEmail);
45 if (matches.length < 2) return null;
46
47 const existingUser = matches[0]!;
48 if (existingUser.id === newUserId) return null;
49
50 const accounts = (await tx.unsafe(
51 `SELECT id, provider_id, account_id FROM "_briven_auth_accounts" WHERE user_id = $1`,
52 [newUserId] as never,
53 )) as Array<{ id: string; provider_id: string; account_id: string }>;
54
55 if (accounts.length === 0) return null;
56
57 for (const account of accounts) {
58 const dup = (await tx.unsafe(
59 `SELECT id FROM "_briven_auth_accounts" WHERE user_id = $1 AND provider_id = $2 AND account_id = $3 LIMIT 1`,
60 [existingUser.id, account.provider_id, account.account_id] as never,
61 )) as Array<{ id: string }>;
62
63 if (dup.length > 0) {
64 await tx.unsafe(
65 `DELETE FROM "_briven_auth_accounts" WHERE id = $1`,
66 [account.id] as never,
67 );
68 } else {
69 await tx.unsafe(
70 `UPDATE "_briven_auth_accounts" SET user_id = $1, updated_at = now() WHERE id = $2`,
71 [existingUser.id, account.id] as never,
72 );
73 }
74 }
75
76 await tx.unsafe(
77 `DELETE FROM "_briven_auth_users" WHERE id = $1`,
78 [newUserId] as never,
79 );
80
81 return { linkedToUserId: existingUser.id };
82 });
83}
84
85export async function listUserAccounts(
86 projectId: string,
87 userId: string,
88): Promise<Array<{ id: string; providerId: string; accountId: string; createdAt: Date }>> {
89 const rows = await runInProjectDatabase(projectId, async (tx) => {
90 return (await tx.unsafe(
91 `SELECT id, provider_id, account_id, created_at FROM "_briven_auth_accounts" WHERE user_id = $1`,
92 [userId] as never,
93 )) as Array<{ id: string; provider_id: string; account_id: string; created_at: Date }>;
94 });
95 return rows.map((r) => ({
96 id: r.id,
97 providerId: r.provider_id,
98 accountId: r.account_id,
99 createdAt: r.created_at,
100 }));
101}
102
103/**
104 * Unlink one OAuth/social account from a user.
105 * Refuses to remove the last remaining sign-in method (credential or sole account).
106 */
107export async function unlinkUserAccount(
108 projectId: string,
109 userId: string,
110 accountId: string,
111): Promise<void> {
112 await runInProjectDatabase(projectId, async (tx) => {
113 const accounts = (await tx.unsafe(
114 `SELECT id, provider_id FROM "_briven_auth_accounts" WHERE user_id = $1`,
115 [userId] as never,
116 )) as Array<{ id: string; provider_id: string }>;
117
118 const target = accounts.find((a) => a.id === accountId);
119 if (!target) {
120 throw new NotFoundError('auth_account', accountId);
121 }
122
123 if (accounts.length <= 1) {
124 throw new ValidationError(
125 'cannot unlink the only sign-in method — add another provider or set a password first',
126 );
127 }
128
129 // Keep at least one path in: either credential remains, or another OAuth.
130 const others = accounts.filter((a) => a.id !== accountId);
131 if (others.length === 0) {
132 throw new ValidationError('cannot unlink the only sign-in method');
133 }
134
135 await tx.unsafe(
136 `DELETE FROM "_briven_auth_accounts" WHERE id = $1 AND user_id = $2`,
137 [accountId, userId] as never,
138 );
139 });
140}