@revealui/auth 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/react/index.d.ts +4 -0
  3. package/dist/react/index.d.ts.map +1 -1
  4. package/dist/react/index.js +2 -0
  5. package/dist/react/useMFA.d.ts +83 -0
  6. package/dist/react/useMFA.d.ts.map +1 -0
  7. package/dist/react/useMFA.js +182 -0
  8. package/dist/react/usePasskey.d.ts +88 -0
  9. package/dist/react/usePasskey.d.ts.map +1 -0
  10. package/dist/react/usePasskey.js +203 -0
  11. package/dist/react/useSession.d.ts.map +1 -1
  12. package/dist/react/useSession.js +16 -5
  13. package/dist/react/useSignIn.d.ts +9 -3
  14. package/dist/react/useSignIn.d.ts.map +1 -1
  15. package/dist/react/useSignIn.js +32 -10
  16. package/dist/react/useSignOut.d.ts.map +1 -1
  17. package/dist/react/useSignUp.d.ts.map +1 -1
  18. package/dist/react/useSignUp.js +25 -9
  19. package/dist/server/auth.d.ts.map +1 -1
  20. package/dist/server/auth.js +75 -4
  21. package/dist/server/brute-force.d.ts +10 -1
  22. package/dist/server/brute-force.d.ts.map +1 -1
  23. package/dist/server/brute-force.js +17 -3
  24. package/dist/server/errors.d.ts.map +1 -1
  25. package/dist/server/index.d.ts +16 -6
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +11 -5
  28. package/dist/server/magic-link.d.ts +52 -0
  29. package/dist/server/magic-link.d.ts.map +1 -0
  30. package/dist/server/magic-link.js +111 -0
  31. package/dist/server/mfa.d.ts +87 -0
  32. package/dist/server/mfa.d.ts.map +1 -0
  33. package/dist/server/mfa.js +263 -0
  34. package/dist/server/oauth.d.ts +37 -0
  35. package/dist/server/oauth.d.ts.map +1 -1
  36. package/dist/server/oauth.js +135 -3
  37. package/dist/server/passkey.d.ts +132 -0
  38. package/dist/server/passkey.d.ts.map +1 -0
  39. package/dist/server/passkey.js +257 -0
  40. package/dist/server/password-reset.d.ts +15 -0
  41. package/dist/server/password-reset.d.ts.map +1 -1
  42. package/dist/server/password-reset.js +44 -1
  43. package/dist/server/password-validation.d.ts.map +1 -1
  44. package/dist/server/providers/github.d.ts.map +1 -1
  45. package/dist/server/providers/github.js +18 -2
  46. package/dist/server/providers/google.d.ts.map +1 -1
  47. package/dist/server/providers/google.js +18 -2
  48. package/dist/server/providers/vercel.d.ts.map +1 -1
  49. package/dist/server/providers/vercel.js +18 -2
  50. package/dist/server/rate-limit.d.ts +10 -1
  51. package/dist/server/rate-limit.d.ts.map +1 -1
  52. package/dist/server/rate-limit.js +61 -43
  53. package/dist/server/session.d.ts +48 -1
  54. package/dist/server/session.d.ts.map +1 -1
  55. package/dist/server/session.js +125 -6
  56. package/dist/server/signed-cookie.d.ts +32 -0
  57. package/dist/server/signed-cookie.d.ts.map +1 -0
  58. package/dist/server/signed-cookie.js +67 -0
  59. package/dist/server/storage/database.d.ts +1 -1
  60. package/dist/server/storage/database.d.ts.map +1 -1
  61. package/dist/server/storage/database.js +15 -7
  62. package/dist/server/storage/in-memory.d.ts.map +1 -1
  63. package/dist/server/storage/in-memory.js +7 -7
  64. package/dist/server/storage/index.d.ts +11 -3
  65. package/dist/server/storage/index.d.ts.map +1 -1
  66. package/dist/server/storage/index.js +18 -4
  67. package/dist/server/storage/interface.d.ts +1 -1
  68. package/dist/server/storage/interface.d.ts.map +1 -1
  69. package/dist/server/storage/interface.js +1 -1
  70. package/dist/types.d.ts +20 -8
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js +2 -2
  73. package/dist/utils/database.d.ts.map +1 -1
  74. package/dist/utils/database.js +9 -2
  75. package/package.json +26 -8
@@ -0,0 +1,87 @@
1
+ /**
2
+ * MFA/2FA — TOTP-based Multi-Factor Authentication
3
+ *
4
+ * Uses the timing-safe TOTP implementation from @revealui/core/security/auth.
5
+ * Backup codes are bcrypt-hashed for storage (one-time use, consumed on verify).
6
+ */
7
+ export interface MFAConfig {
8
+ /** Number of backup codes to generate (default: 8) */
9
+ backupCodeCount: number;
10
+ /** Length of each backup code in bytes (default: 5, produces 10 hex chars) */
11
+ backupCodeLength: number;
12
+ /** Issuer name shown in authenticator apps */
13
+ issuer: string;
14
+ }
15
+ export declare function configureMFA(overrides: Partial<MFAConfig>): void;
16
+ export declare function resetMFAConfig(): void;
17
+ export interface MFASetupResult {
18
+ success: boolean;
19
+ /** Base32-encoded TOTP secret (show once) */
20
+ secret?: string;
21
+ /** otpauth:// URI for QR code */
22
+ uri?: string;
23
+ /** Plaintext backup codes (show once) */
24
+ backupCodes?: string[];
25
+ error?: string;
26
+ }
27
+ /**
28
+ * Initiate MFA setup for a user.
29
+ * Generates a TOTP secret and backup codes. The user must verify with a TOTP
30
+ * code before MFA is activated (see `verifyMFASetup`).
31
+ */
32
+ export declare function initiateMFASetup(userId: string, email: string): Promise<MFASetupResult>;
33
+ /**
34
+ * Verify MFA setup by confirming the user's authenticator app works.
35
+ * This activates MFA on the account.
36
+ */
37
+ export declare function verifyMFASetup(userId: string, code: string): Promise<{
38
+ success: boolean;
39
+ error?: string;
40
+ }>;
41
+ /**
42
+ * Verify a TOTP code during login (step 2 of MFA login flow).
43
+ */
44
+ export declare function verifyMFACode(userId: string, code: string): Promise<{
45
+ success: boolean;
46
+ error?: string;
47
+ }>;
48
+ /**
49
+ * Verify a backup code (one-time use). Consumes the code on success.
50
+ */
51
+ export declare function verifyBackupCode(userId: string, code: string): Promise<{
52
+ success: boolean;
53
+ remainingCodes?: number;
54
+ error?: string;
55
+ }>;
56
+ /**
57
+ * Regenerate backup codes (requires active MFA).
58
+ */
59
+ export declare function regenerateBackupCodes(userId: string): Promise<{
60
+ success: boolean;
61
+ backupCodes?: string[];
62
+ error?: string;
63
+ }>;
64
+ /**
65
+ * Discriminated union for MFA disable re-authentication proof.
66
+ * - `password`: traditional password confirmation
67
+ * - `passkey`: WebAuthn assertion already verified by the API route
68
+ */
69
+ export type MFADisableProof = {
70
+ method: 'password';
71
+ password: string;
72
+ } | {
73
+ method: 'passkey';
74
+ verified: true;
75
+ };
76
+ /**
77
+ * Disable MFA on a user account. Requires re-authentication proof.
78
+ */
79
+ export declare function disableMFA(userId: string, proof: MFADisableProof): Promise<{
80
+ success: boolean;
81
+ error?: string;
82
+ }>;
83
+ /**
84
+ * Check if a user has MFA enabled.
85
+ */
86
+ export declare function isMFAEnabled(userId: string): Promise<boolean>;
87
+ //# sourceMappingURL=mfa.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mfa.d.ts","sourceRoot":"","sources":["../../src/server/mfa.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,MAAM,WAAW,SAAS;IACxB,sDAAsD;IACtD,eAAe,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,gBAAgB,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD,wBAAgB,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC,GAAG,IAAI,CAEhE;AAED,wBAAgB,cAAc,IAAI,IAAI,CAErC;AA2CD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAuC7F;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAsC/C;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAmB/C;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCxE;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAwBvE;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACxC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,IAAI,CAAA;CAAE,CAAC;AAE1C;;GAEG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA+C/C;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUnE"}
@@ -0,0 +1,263 @@
1
+ /**
2
+ * MFA/2FA — TOTP-based Multi-Factor Authentication
3
+ *
4
+ * Uses the timing-safe TOTP implementation from @revealui/core/security/auth.
5
+ * Backup codes are bcrypt-hashed for storage (one-time use, consumed on verify).
6
+ */
7
+ import { randomBytes } from 'node:crypto';
8
+ import { TwoFactorAuth } from '@revealui/core/security';
9
+ import { getClient } from '@revealui/db/client';
10
+ import { users } from '@revealui/db/schema';
11
+ import bcrypt from 'bcryptjs';
12
+ import { eq } from 'drizzle-orm';
13
+ const DEFAULT_MFA_CONFIG = {
14
+ backupCodeCount: 8,
15
+ backupCodeLength: 5,
16
+ issuer: 'RevealUI',
17
+ };
18
+ let config = { ...DEFAULT_MFA_CONFIG };
19
+ export function configureMFA(overrides) {
20
+ config = { ...DEFAULT_MFA_CONFIG, ...overrides };
21
+ }
22
+ export function resetMFAConfig() {
23
+ config = { ...DEFAULT_MFA_CONFIG };
24
+ }
25
+ // =============================================================================
26
+ // Backup Code Generation
27
+ // =============================================================================
28
+ /**
29
+ * Generate a set of plaintext backup codes.
30
+ * Returns both the plaintext codes (to show the user once) and bcrypt hashes (to store).
31
+ */
32
+ async function generateBackupCodes() {
33
+ const plaintext = [];
34
+ const hashed = [];
35
+ for (let i = 0; i < config.backupCodeCount; i++) {
36
+ const code = randomBytes(config.backupCodeLength).toString('hex');
37
+ plaintext.push(code);
38
+ hashed.push(await bcrypt.hash(code, 10));
39
+ }
40
+ return { plaintext, hashed };
41
+ }
42
+ // =============================================================================
43
+ // TOTP Provisioning URI
44
+ // =============================================================================
45
+ /**
46
+ * Build an otpauth:// URI for QR code generation in authenticator apps.
47
+ */
48
+ function buildProvisioningUri(secret, email) {
49
+ const issuer = encodeURIComponent(config.issuer);
50
+ const account = encodeURIComponent(email);
51
+ return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
52
+ }
53
+ /**
54
+ * Initiate MFA setup for a user.
55
+ * Generates a TOTP secret and backup codes. The user must verify with a TOTP
56
+ * code before MFA is activated (see `verifyMFASetup`).
57
+ */
58
+ export async function initiateMFASetup(userId, email) {
59
+ const db = getClient();
60
+ // Check if MFA is already enabled
61
+ const [user] = await db
62
+ .select({ mfaEnabled: users.mfaEnabled })
63
+ .from(users)
64
+ .where(eq(users.id, userId))
65
+ .limit(1);
66
+ if (!user) {
67
+ return { success: false, error: 'User not found' };
68
+ }
69
+ if (user.mfaEnabled) {
70
+ return { success: false, error: 'MFA is already enabled' };
71
+ }
72
+ // Generate TOTP secret and backup codes
73
+ const secret = TwoFactorAuth.generateSecret();
74
+ const { plaintext, hashed } = await generateBackupCodes();
75
+ const uri = buildProvisioningUri(secret, email);
76
+ // Store secret and backup codes (MFA stays disabled until verified)
77
+ await db
78
+ .update(users)
79
+ .set({
80
+ mfaSecret: secret,
81
+ mfaBackupCodes: hashed,
82
+ updatedAt: new Date(),
83
+ })
84
+ .where(eq(users.id, userId));
85
+ return {
86
+ success: true,
87
+ secret,
88
+ uri,
89
+ backupCodes: plaintext,
90
+ };
91
+ }
92
+ /**
93
+ * Verify MFA setup by confirming the user's authenticator app works.
94
+ * This activates MFA on the account.
95
+ */
96
+ export async function verifyMFASetup(userId, code) {
97
+ const db = getClient();
98
+ const [user] = await db
99
+ .select({ mfaSecret: users.mfaSecret, mfaEnabled: users.mfaEnabled })
100
+ .from(users)
101
+ .where(eq(users.id, userId))
102
+ .limit(1);
103
+ if (!user) {
104
+ return { success: false, error: 'User not found' };
105
+ }
106
+ if (user.mfaEnabled) {
107
+ return { success: false, error: 'MFA is already enabled' };
108
+ }
109
+ if (!user.mfaSecret) {
110
+ return { success: false, error: 'MFA setup not initiated' };
111
+ }
112
+ // Verify the TOTP code against the stored secret
113
+ const valid = TwoFactorAuth.verifyCode(user.mfaSecret, code);
114
+ if (!valid) {
115
+ return { success: false, error: 'Invalid verification code' };
116
+ }
117
+ // Activate MFA
118
+ await db
119
+ .update(users)
120
+ .set({
121
+ mfaEnabled: true,
122
+ mfaVerifiedAt: new Date(),
123
+ updatedAt: new Date(),
124
+ })
125
+ .where(eq(users.id, userId));
126
+ return { success: true };
127
+ }
128
+ /**
129
+ * Verify a TOTP code during login (step 2 of MFA login flow).
130
+ */
131
+ export async function verifyMFACode(userId, code) {
132
+ const db = getClient();
133
+ const [user] = await db
134
+ .select({ mfaSecret: users.mfaSecret, mfaEnabled: users.mfaEnabled })
135
+ .from(users)
136
+ .where(eq(users.id, userId))
137
+ .limit(1);
138
+ if (!(user?.mfaEnabled && user.mfaSecret)) {
139
+ return { success: false, error: 'MFA not enabled' };
140
+ }
141
+ const valid = TwoFactorAuth.verifyCode(user.mfaSecret, code);
142
+ if (!valid) {
143
+ return { success: false, error: 'Invalid code' };
144
+ }
145
+ return { success: true };
146
+ }
147
+ /**
148
+ * Verify a backup code (one-time use). Consumes the code on success.
149
+ */
150
+ export async function verifyBackupCode(userId, code) {
151
+ const db = getClient();
152
+ const [user] = await db
153
+ .select({ mfaBackupCodes: users.mfaBackupCodes, mfaEnabled: users.mfaEnabled })
154
+ .from(users)
155
+ .where(eq(users.id, userId))
156
+ .limit(1);
157
+ if (!user?.mfaEnabled) {
158
+ return { success: false, error: 'MFA not enabled' };
159
+ }
160
+ const storedCodes = (user.mfaBackupCodes ?? []);
161
+ if (storedCodes.length === 0) {
162
+ return { success: false, error: 'No backup codes available' };
163
+ }
164
+ // Find and consume the matching backup code
165
+ for (let i = 0; i < storedCodes.length; i++) {
166
+ const storedCode = storedCodes[i];
167
+ if (!storedCode)
168
+ continue;
169
+ const matches = await bcrypt.compare(code, storedCode);
170
+ if (matches) {
171
+ // Remove the consumed code
172
+ const remaining = [...storedCodes.slice(0, i), ...storedCodes.slice(i + 1)];
173
+ await db
174
+ .update(users)
175
+ .set({
176
+ mfaBackupCodes: remaining,
177
+ updatedAt: new Date(),
178
+ })
179
+ .where(eq(users.id, userId));
180
+ return { success: true, remainingCodes: remaining.length };
181
+ }
182
+ }
183
+ return { success: false, error: 'Invalid backup code' };
184
+ }
185
+ /**
186
+ * Regenerate backup codes (requires active MFA).
187
+ */
188
+ export async function regenerateBackupCodes(userId) {
189
+ const db = getClient();
190
+ const [user] = await db
191
+ .select({ mfaEnabled: users.mfaEnabled })
192
+ .from(users)
193
+ .where(eq(users.id, userId))
194
+ .limit(1);
195
+ if (!user?.mfaEnabled) {
196
+ return { success: false, error: 'MFA not enabled' };
197
+ }
198
+ const { plaintext, hashed } = await generateBackupCodes();
199
+ await db
200
+ .update(users)
201
+ .set({
202
+ mfaBackupCodes: hashed,
203
+ updatedAt: new Date(),
204
+ })
205
+ .where(eq(users.id, userId));
206
+ return { success: true, backupCodes: plaintext };
207
+ }
208
+ /**
209
+ * Disable MFA on a user account. Requires re-authentication proof.
210
+ */
211
+ export async function disableMFA(userId, proof) {
212
+ const db = getClient();
213
+ const [user] = await db
214
+ .select({
215
+ mfaEnabled: users.mfaEnabled,
216
+ password: users.password,
217
+ })
218
+ .from(users)
219
+ .where(eq(users.id, userId))
220
+ .limit(1);
221
+ if (!user) {
222
+ return { success: false, error: 'User not found' };
223
+ }
224
+ if (!user.mfaEnabled) {
225
+ return { success: false, error: 'MFA is not enabled' };
226
+ }
227
+ // Verify re-authentication proof
228
+ if (proof.method === 'password') {
229
+ if (!user.password) {
230
+ return { success: false, error: 'Password verification required' };
231
+ }
232
+ const passwordValid = await bcrypt.compare(proof.password, user.password);
233
+ if (!passwordValid) {
234
+ return { success: false, error: 'Invalid password' };
235
+ }
236
+ }
237
+ // For passkey proof, the API route has already performed the WebAuthn assertion —
238
+ // the `verified: true` flag is trusted as a server-side signal.
239
+ // Clear all MFA data
240
+ await db
241
+ .update(users)
242
+ .set({
243
+ mfaEnabled: false,
244
+ mfaSecret: null,
245
+ mfaBackupCodes: null,
246
+ mfaVerifiedAt: null,
247
+ updatedAt: new Date(),
248
+ })
249
+ .where(eq(users.id, userId));
250
+ return { success: true };
251
+ }
252
+ /**
253
+ * Check if a user has MFA enabled.
254
+ */
255
+ export async function isMFAEnabled(userId) {
256
+ const db = getClient();
257
+ const [user] = await db
258
+ .select({ mfaEnabled: users.mfaEnabled })
259
+ .from(users)
260
+ .where(eq(users.id, userId))
261
+ .limit(1);
262
+ return user?.mfaEnabled ?? false;
263
+ }
@@ -46,4 +46,41 @@ export declare function fetchProviderUser(provider: string, accessToken: string)
46
46
  * 5. Insert oauth_accounts row
47
47
  */
48
48
  export declare function upsertOAuthUser(provider: string, providerUser: ProviderUser): Promise<User>;
49
+ /**
50
+ * Link an OAuth provider to an existing authenticated user.
51
+ *
52
+ * Unlike upsertOAuthUser(), this function requires the caller to be
53
+ * authenticated and explicitly requests the link. This is safe because
54
+ * the user has already proven ownership of the local account via their
55
+ * session.
56
+ *
57
+ * @param userId - The authenticated user's ID (from session)
58
+ * @param provider - OAuth provider name
59
+ * @param providerUser - Profile returned by the OAuth provider
60
+ * @returns The linked user
61
+ * @throws Error if the provider account is already linked to a different user
62
+ */
63
+ export declare function linkOAuthAccount(userId: string, provider: string, providerUser: ProviderUser): Promise<User>;
64
+ /**
65
+ * Unlink an OAuth provider from a user's account.
66
+ *
67
+ * Safety: refuses to unlink the last auth method (if user has no password
68
+ * and this is their only OAuth link, unlinking would lock them out).
69
+ *
70
+ * @param userId - The authenticated user's ID
71
+ * @param provider - The provider to unlink
72
+ * @throws Error if unlinking would leave the user with no authentication method
73
+ */
74
+ export declare function unlinkOAuthAccount(userId: string, provider: string): Promise<void>;
75
+ /**
76
+ * Get all linked OAuth providers for a user.
77
+ *
78
+ * @param userId - The user's ID
79
+ * @returns Array of linked provider info (provider name, email, avatar)
80
+ */
81
+ export declare function getLinkedProviders(userId: string): Promise<Array<{
82
+ provider: string;
83
+ providerEmail: string | null;
84
+ providerName: string | null;
85
+ }>>;
49
86
  //# sourceMappingURL=oauth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/server/oauth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAMvC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAMD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAaxC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA2CjD;AAwBD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASzF;AAED,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CAQvB;AAMD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA4FjG"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/server/oauth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAOH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAMxC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAMD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAaxC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAgDjD;AAwBD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASzF;AAED,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CAQvB;AAMD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA6FjG;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC,IAAI,CAAC,CAiEf;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsCxF;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,KAAK,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAAC,CAajG"}
@@ -49,9 +49,11 @@ export function verifyOAuthState(state, cookieValue) {
49
49
  return null;
50
50
  const storedState = cookieValue.substring(0, dotIdx);
51
51
  const storedHmac = cookieValue.substring(dotIdx + 1);
52
- // State from query param must match what's in the cookie
53
- if (storedState !== state)
52
+ // State from query param must match what's in the cookie (timing-safe)
53
+ if (storedState.length !== state.length ||
54
+ !crypto.timingSafeEqual(Buffer.from(storedState), Buffer.from(state))) {
54
55
  return null;
56
+ }
55
57
  const secret = process.env.REVEALUI_SECRET;
56
58
  if (!secret) {
57
59
  throw new Error('REVEALUI_SECRET is required for OAuth state verification. ' +
@@ -175,7 +177,8 @@ export async function upsertOAuthUser(provider, providerUser) {
175
177
  // If an account with this email already exists but was not linked via OAuth,
176
178
  // reject the login. Auto-linking is an account takeover vector: an attacker
177
179
  // who controls a provider email instantly owns the existing account.
178
- // Explicit linking (from an authenticated session) is a future feature.
180
+ // Users must link providers explicitly from an authenticated session
181
+ // via linkOAuthAccount().
179
182
  let userId;
180
183
  let isNewUser = false;
181
184
  if (providerUser.email) {
@@ -221,3 +224,132 @@ export async function upsertOAuthUser(provider, providerUser) {
221
224
  throw new Error('Failed to fetch upserted OAuth user');
222
225
  return user;
223
226
  }
227
+ // =============================================================================
228
+ // Explicit Account Linking
229
+ // =============================================================================
230
+ /**
231
+ * Link an OAuth provider to an existing authenticated user.
232
+ *
233
+ * Unlike upsertOAuthUser(), this function requires the caller to be
234
+ * authenticated and explicitly requests the link. This is safe because
235
+ * the user has already proven ownership of the local account via their
236
+ * session.
237
+ *
238
+ * @param userId - The authenticated user's ID (from session)
239
+ * @param provider - OAuth provider name
240
+ * @param providerUser - Profile returned by the OAuth provider
241
+ * @returns The linked user
242
+ * @throws Error if the provider account is already linked to a different user
243
+ */
244
+ export async function linkOAuthAccount(userId, provider, providerUser) {
245
+ const db = getClient();
246
+ // 1. Check if this provider identity is already linked to ANY user
247
+ const [existingLink] = await db
248
+ .select()
249
+ .from(oauthAccounts)
250
+ .where(and(eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUser.id)))
251
+ .limit(1);
252
+ if (existingLink) {
253
+ if (existingLink.userId === userId) {
254
+ // Already linked to this user — refresh metadata and return
255
+ await db
256
+ .update(oauthAccounts)
257
+ .set({
258
+ providerEmail: providerUser.email,
259
+ providerName: providerUser.name,
260
+ providerAvatarUrl: providerUser.avatarUrl,
261
+ updatedAt: new Date(),
262
+ })
263
+ .where(eq(oauthAccounts.id, existingLink.id));
264
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
265
+ if (!user)
266
+ throw new Error('Authenticated user not found in database');
267
+ return user;
268
+ }
269
+ // Linked to a different user — cannot steal the identity
270
+ throw new Error('This provider account is already linked to another user. Unlink it from the other account first.');
271
+ }
272
+ // 2. Check the authenticated user exists
273
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
274
+ if (!user)
275
+ throw new Error('Authenticated user not found in database');
276
+ // 3. Check if this user already has a link for this provider (different provider account)
277
+ const [existingProviderLink] = await db
278
+ .select()
279
+ .from(oauthAccounts)
280
+ .where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)))
281
+ .limit(1);
282
+ if (existingProviderLink) {
283
+ throw new Error(`You already have a ${provider} account linked. Unlink it first to connect a different one.`);
284
+ }
285
+ // 4. Create the link
286
+ await db.insert(oauthAccounts).values({
287
+ id: crypto.randomUUID(),
288
+ userId,
289
+ provider,
290
+ providerUserId: providerUser.id,
291
+ providerEmail: providerUser.email,
292
+ providerName: providerUser.name,
293
+ providerAvatarUrl: providerUser.avatarUrl,
294
+ });
295
+ logger.info(`Linked ${provider} account to user ${userId}`);
296
+ return user;
297
+ }
298
+ /**
299
+ * Unlink an OAuth provider from a user's account.
300
+ *
301
+ * Safety: refuses to unlink the last auth method (if user has no password
302
+ * and this is their only OAuth link, unlinking would lock them out).
303
+ *
304
+ * @param userId - The authenticated user's ID
305
+ * @param provider - The provider to unlink
306
+ * @throws Error if unlinking would leave the user with no authentication method
307
+ */
308
+ export async function unlinkOAuthAccount(userId, provider) {
309
+ const db = getClient();
310
+ // 1. Find the link to remove
311
+ const [link] = await db
312
+ .select()
313
+ .from(oauthAccounts)
314
+ .where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)))
315
+ .limit(1);
316
+ if (!link) {
317
+ throw new Error(`No ${provider} account is linked to your account`);
318
+ }
319
+ // 2. Safety check: ensure user won't be locked out
320
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
321
+ if (!user)
322
+ throw new Error('User not found');
323
+ const allLinks = await db
324
+ .select({ id: oauthAccounts.id })
325
+ .from(oauthAccounts)
326
+ .where(eq(oauthAccounts.userId, userId));
327
+ const hasPassword = !!user.password;
328
+ const otherLinksCount = allLinks.length - 1;
329
+ if (!hasPassword && otherLinksCount === 0) {
330
+ throw new Error('Cannot unlink your only sign-in method. Set a password first, or link another provider.');
331
+ }
332
+ // 3. Delete the link
333
+ await db
334
+ .delete(oauthAccounts)
335
+ .where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)));
336
+ logger.info(`Unlinked ${provider} account from user ${userId}`);
337
+ }
338
+ /**
339
+ * Get all linked OAuth providers for a user.
340
+ *
341
+ * @param userId - The user's ID
342
+ * @returns Array of linked provider info (provider name, email, avatar)
343
+ */
344
+ export async function getLinkedProviders(userId) {
345
+ const db = getClient();
346
+ const links = await db
347
+ .select({
348
+ provider: oauthAccounts.provider,
349
+ providerEmail: oauthAccounts.providerEmail,
350
+ providerName: oauthAccounts.providerName,
351
+ })
352
+ .from(oauthAccounts)
353
+ .where(eq(oauthAccounts.userId, userId));
354
+ return links;
355
+ }