@revealui/auth 0.2.0 → 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 (87) hide show
  1. package/README.md +58 -34
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/react/index.d.ts +4 -0
  4. package/dist/react/index.d.ts.map +1 -1
  5. package/dist/react/index.js +2 -0
  6. package/dist/react/useMFA.d.ts +83 -0
  7. package/dist/react/useMFA.d.ts.map +1 -0
  8. package/dist/react/useMFA.js +182 -0
  9. package/dist/react/usePasskey.d.ts +88 -0
  10. package/dist/react/usePasskey.d.ts.map +1 -0
  11. package/dist/react/usePasskey.js +203 -0
  12. package/dist/react/useSession.d.ts.map +1 -1
  13. package/dist/react/useSession.js +16 -5
  14. package/dist/react/useSignIn.d.ts +9 -3
  15. package/dist/react/useSignIn.d.ts.map +1 -1
  16. package/dist/react/useSignIn.js +32 -10
  17. package/dist/react/useSignOut.d.ts.map +1 -1
  18. package/dist/react/useSignUp.d.ts +1 -0
  19. package/dist/react/useSignUp.d.ts.map +1 -1
  20. package/dist/react/useSignUp.js +25 -9
  21. package/dist/server/auth.d.ts +2 -0
  22. package/dist/server/auth.d.ts.map +1 -1
  23. package/dist/server/auth.js +93 -5
  24. package/dist/server/brute-force.d.ts +10 -1
  25. package/dist/server/brute-force.d.ts.map +1 -1
  26. package/dist/server/brute-force.js +46 -23
  27. package/dist/server/errors.d.ts +4 -0
  28. package/dist/server/errors.d.ts.map +1 -1
  29. package/dist/server/errors.js +8 -0
  30. package/dist/server/index.d.ts +17 -6
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +12 -5
  33. package/dist/server/magic-link.d.ts +52 -0
  34. package/dist/server/magic-link.d.ts.map +1 -0
  35. package/dist/server/magic-link.js +111 -0
  36. package/dist/server/mfa.d.ts +87 -0
  37. package/dist/server/mfa.d.ts.map +1 -0
  38. package/dist/server/mfa.js +263 -0
  39. package/dist/server/oauth.d.ts +86 -0
  40. package/dist/server/oauth.d.ts.map +1 -0
  41. package/dist/server/oauth.js +355 -0
  42. package/dist/server/passkey.d.ts +132 -0
  43. package/dist/server/passkey.d.ts.map +1 -0
  44. package/dist/server/passkey.js +257 -0
  45. package/dist/server/password-reset.d.ts +32 -6
  46. package/dist/server/password-reset.d.ts.map +1 -1
  47. package/dist/server/password-reset.js +116 -47
  48. package/dist/server/password-validation.d.ts.map +1 -1
  49. package/dist/server/providers/github.d.ts +14 -0
  50. package/dist/server/providers/github.d.ts.map +1 -0
  51. package/dist/server/providers/github.js +89 -0
  52. package/dist/server/providers/google.d.ts +11 -0
  53. package/dist/server/providers/google.d.ts.map +1 -0
  54. package/dist/server/providers/google.js +69 -0
  55. package/dist/server/providers/vercel.d.ts +11 -0
  56. package/dist/server/providers/vercel.d.ts.map +1 -0
  57. package/dist/server/providers/vercel.js +63 -0
  58. package/dist/server/rate-limit.d.ts +10 -1
  59. package/dist/server/rate-limit.d.ts.map +1 -1
  60. package/dist/server/rate-limit.js +61 -43
  61. package/dist/server/session.d.ts +48 -1
  62. package/dist/server/session.d.ts.map +1 -1
  63. package/dist/server/session.js +126 -7
  64. package/dist/server/signed-cookie.d.ts +32 -0
  65. package/dist/server/signed-cookie.d.ts.map +1 -0
  66. package/dist/server/signed-cookie.js +67 -0
  67. package/dist/server/storage/database.d.ts +10 -1
  68. package/dist/server/storage/database.d.ts.map +1 -1
  69. package/dist/server/storage/database.js +43 -5
  70. package/dist/server/storage/in-memory.d.ts +4 -0
  71. package/dist/server/storage/in-memory.d.ts.map +1 -1
  72. package/dist/server/storage/in-memory.js +16 -6
  73. package/dist/server/storage/index.d.ts +11 -3
  74. package/dist/server/storage/index.d.ts.map +1 -1
  75. package/dist/server/storage/index.js +18 -4
  76. package/dist/server/storage/interface.d.ts +11 -1
  77. package/dist/server/storage/interface.d.ts.map +1 -1
  78. package/dist/server/storage/interface.js +1 -1
  79. package/dist/types.d.ts +23 -8
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/types.js +2 -2
  82. package/dist/utils/database.d.ts.map +1 -1
  83. package/dist/utils/database.js +12 -2
  84. package/dist/utils/token.d.ts +9 -1
  85. package/dist/utils/token.d.ts.map +1 -1
  86. package/dist/utils/token.js +9 -1
  87. package/package.json +26 -8
@@ -0,0 +1,257 @@
1
+ /**
2
+ * WebAuthn Passkey Module
3
+ *
4
+ * Implements passkey registration, authentication, and management using
5
+ * @simplewebauthn/server v13. Passkeys enable passwordless authentication
6
+ * via biometrics, security keys, or platform authenticators.
7
+ *
8
+ * @see https://simplewebauthn.dev/
9
+ */
10
+ import crypto from 'node:crypto';
11
+ import { getClient } from '@revealui/db/client';
12
+ import { passkeys, users } from '@revealui/db/schema';
13
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
14
+ import { and, count, eq } from 'drizzle-orm';
15
+ const DEFAULT_CONFIG = {
16
+ maxPasskeysPerUser: 10,
17
+ challengeTtlMs: 5 * 60 * 1000,
18
+ rpId: 'localhost',
19
+ rpName: 'RevealUI',
20
+ origin: 'http://localhost:4000',
21
+ };
22
+ let config = { ...DEFAULT_CONFIG };
23
+ export function configurePasskey(overrides) {
24
+ config = { ...DEFAULT_CONFIG, ...overrides };
25
+ }
26
+ export function resetPasskeyConfig() {
27
+ config = { ...DEFAULT_CONFIG };
28
+ }
29
+ // =============================================================================
30
+ // Registration
31
+ // =============================================================================
32
+ /**
33
+ * Generate WebAuthn registration options for a user.
34
+ *
35
+ * The returned object should be passed to the browser's navigator.credentials.create().
36
+ * The challenge is embedded in the options and should be stored server-side
37
+ * for verification.
38
+ *
39
+ * @param userId - User's database ID
40
+ * @param userEmail - User's email (used as userName in WebAuthn)
41
+ * @param existingCredentialIds - Credential IDs to exclude (prevent re-registration)
42
+ */
43
+ export async function generateRegistrationChallenge(userId, userEmail, existingCredentialIds) {
44
+ const excludeCredentials = existingCredentialIds?.map((id) => ({
45
+ id,
46
+ }));
47
+ // Encode userId as Uint8Array for WebAuthn userID field
48
+ const userIdBytes = new TextEncoder().encode(userId);
49
+ const options = await generateRegistrationOptions({
50
+ rpName: config.rpName,
51
+ rpID: config.rpId,
52
+ userName: userEmail,
53
+ userID: userIdBytes,
54
+ userDisplayName: userEmail,
55
+ excludeCredentials,
56
+ authenticatorSelection: {
57
+ residentKey: 'preferred',
58
+ userVerification: 'preferred',
59
+ },
60
+ timeout: config.challengeTtlMs,
61
+ });
62
+ return options;
63
+ }
64
+ /**
65
+ * Verify a WebAuthn registration response from the browser.
66
+ *
67
+ * @param response - The RegistrationResponseJSON from the browser
68
+ * @param expectedChallenge - The challenge from generateRegistrationChallenge
69
+ * @param expectedOrigin - Override origin (defaults to config.origin)
70
+ * @returns Verified registration data including credential info
71
+ * @throws If verification fails
72
+ */
73
+ export async function verifyRegistration(response, expectedChallenge, expectedOrigin) {
74
+ const verification = await verifyRegistrationResponse({
75
+ response,
76
+ expectedChallenge,
77
+ expectedOrigin: expectedOrigin ?? config.origin,
78
+ expectedRPID: config.rpId,
79
+ });
80
+ if (!(verification.verified && verification.registrationInfo)) {
81
+ throw new Error('Passkey registration verification failed');
82
+ }
83
+ return verification;
84
+ }
85
+ // =============================================================================
86
+ // Credential Storage
87
+ // =============================================================================
88
+ /**
89
+ * Store a verified passkey credential in the database.
90
+ *
91
+ * Enforces the per-user passkey limit before insertion.
92
+ *
93
+ * @param userId - User's database ID
94
+ * @param credential - Verified credential from verifyRegistration
95
+ * @param deviceName - Optional user-friendly name (e.g., "MacBook Pro Touch ID")
96
+ * @returns The stored passkey record
97
+ */
98
+ export async function storePasskey(userId, credential, deviceName) {
99
+ const db = getClient();
100
+ // Enforce per-user passkey limit
101
+ const [result] = await db
102
+ .select({ total: count() })
103
+ .from(passkeys)
104
+ .where(eq(passkeys.userId, userId));
105
+ const currentCount = result?.total ?? 0;
106
+ if (currentCount >= config.maxPasskeysPerUser) {
107
+ throw new Error(`Maximum of ${config.maxPasskeysPerUser} passkeys per user reached`);
108
+ }
109
+ const id = crypto.randomUUID();
110
+ const now = new Date();
111
+ await db.insert(passkeys).values({
112
+ id,
113
+ userId,
114
+ credentialId: credential.id,
115
+ publicKey: Buffer.from(credential.publicKey),
116
+ counter: credential.counter,
117
+ transports: credential.transports ?? null,
118
+ aaguid: credential.aaguid ?? null,
119
+ deviceName: deviceName ?? null,
120
+ backedUp: credential.backedUp ?? false,
121
+ createdAt: now,
122
+ });
123
+ return {
124
+ id,
125
+ userId,
126
+ credentialId: credential.id,
127
+ deviceName: deviceName ?? null,
128
+ createdAt: now,
129
+ };
130
+ }
131
+ // =============================================================================
132
+ // Authentication
133
+ // =============================================================================
134
+ /**
135
+ * Generate WebAuthn authentication options.
136
+ *
137
+ * The returned object should be passed to the browser's navigator.credentials.get().
138
+ *
139
+ * @param allowCredentials - Optional list of credential IDs to allow
140
+ */
141
+ export async function generateAuthenticationChallenge(allowCredentials) {
142
+ const options = await generateAuthenticationOptions({
143
+ rpID: config.rpId,
144
+ allowCredentials: allowCredentials?.map((cred) => ({
145
+ id: cred.id,
146
+ transports: cred.transports,
147
+ })),
148
+ timeout: config.challengeTtlMs,
149
+ userVerification: 'preferred',
150
+ });
151
+ return options;
152
+ }
153
+ /**
154
+ * Verify a WebAuthn authentication response and update the credential counter.
155
+ *
156
+ * @param response - The AuthenticationResponseJSON from the browser
157
+ * @param credential - The stored credential (id, publicKey, counter)
158
+ * @param expectedChallenge - The challenge from generateAuthenticationChallenge
159
+ * @param expectedOrigin - Override origin (defaults to config.origin)
160
+ * @returns Verification result with new counter value
161
+ */
162
+ export async function verifyAuthentication(response, credential, expectedChallenge, expectedOrigin) {
163
+ const verification = await verifyAuthenticationResponse({
164
+ response,
165
+ expectedChallenge,
166
+ expectedOrigin: expectedOrigin ?? config.origin,
167
+ expectedRPID: [config.rpId],
168
+ credential,
169
+ });
170
+ if (verification.verified) {
171
+ // Update counter and lastUsedAt in the database
172
+ const db = getClient();
173
+ await db
174
+ .update(passkeys)
175
+ .set({
176
+ counter: verification.authenticationInfo.newCounter,
177
+ lastUsedAt: new Date(),
178
+ })
179
+ .where(eq(passkeys.credentialId, credential.id));
180
+ }
181
+ return {
182
+ verified: verification.verified,
183
+ newCounter: verification.authenticationInfo.newCounter,
184
+ };
185
+ }
186
+ // =============================================================================
187
+ // Management
188
+ // =============================================================================
189
+ /**
190
+ * List all passkeys for a user (safe for client — excludes publicKey and counter).
191
+ */
192
+ export async function listPasskeys(userId) {
193
+ const db = getClient();
194
+ const rows = await db
195
+ .select({
196
+ id: passkeys.id,
197
+ credentialId: passkeys.credentialId,
198
+ deviceName: passkeys.deviceName,
199
+ backedUp: passkeys.backedUp,
200
+ createdAt: passkeys.createdAt,
201
+ lastUsedAt: passkeys.lastUsedAt,
202
+ })
203
+ .from(passkeys)
204
+ .where(eq(passkeys.userId, userId));
205
+ return rows;
206
+ }
207
+ /**
208
+ * Delete a passkey. Blocks deletion if it's the user's last sign-in method.
209
+ *
210
+ * @param userId - User's database ID
211
+ * @param passkeyId - The passkey record ID to delete
212
+ * @throws If this is the user's only sign-in method
213
+ */
214
+ export async function deletePasskey(userId, passkeyId) {
215
+ const { passkeyCount, hasPassword } = await countUserCredentials(userId);
216
+ if (passkeyCount <= 1 && !hasPassword) {
217
+ throw new Error('Cannot delete last sign-in method');
218
+ }
219
+ const db = getClient();
220
+ await db.delete(passkeys).where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)));
221
+ }
222
+ /**
223
+ * Rename a passkey's device name.
224
+ *
225
+ * @param userId - User's database ID
226
+ * @param passkeyId - The passkey record ID to rename
227
+ * @param name - New friendly name
228
+ */
229
+ export async function renamePasskey(userId, passkeyId, name) {
230
+ const db = getClient();
231
+ await db
232
+ .update(passkeys)
233
+ .set({ deviceName: name })
234
+ .where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)));
235
+ }
236
+ /**
237
+ * Count a user's sign-in credentials (passkeys + password).
238
+ *
239
+ * @param userId - User's database ID
240
+ * @returns Passkey count and whether user has a password set
241
+ */
242
+ export async function countUserCredentials(userId) {
243
+ const db = getClient();
244
+ const [passkeyResult] = await db
245
+ .select({ total: count() })
246
+ .from(passkeys)
247
+ .where(eq(passkeys.userId, userId));
248
+ const [userResult] = await db
249
+ .select({ password: users.password })
250
+ .from(users)
251
+ .where(eq(users.id, userId))
252
+ .limit(1);
253
+ return {
254
+ passkeyCount: passkeyResult?.total ?? 0,
255
+ hasPassword: userResult?.password != null,
256
+ };
257
+ }
@@ -12,6 +12,7 @@ export interface PasswordResetResult {
12
12
  success: boolean;
13
13
  error?: string;
14
14
  token?: string;
15
+ tokenId?: string;
15
16
  }
16
17
  /**
17
18
  * Generates a password reset token for a user
@@ -23,22 +24,47 @@ export declare function generatePasswordResetToken(email: string): Promise<Passw
23
24
  /**
24
25
  * Validates a password reset token
25
26
  *
26
- * @param token - Reset token (plain text)
27
+ * Uses O(1) lookup by token ID, then verifies the token hash with timingSafeEqual
28
+ * against the single matching row.
29
+ *
30
+ * @param tokenId - Token row ID (from the reset URL)
31
+ * @param token - Reset token (plain text, from the reset URL)
27
32
  * @returns User ID if valid, null otherwise
28
33
  */
29
- export declare function validatePasswordResetToken(token: string): Promise<string | null>;
34
+ export declare function validatePasswordResetToken(tokenId: string, token: string): Promise<string | null>;
30
35
  /**
31
36
  * Resets password using a token
32
37
  *
33
- * @param token - Reset token (plain text)
38
+ * Uses O(1) lookup by token ID, then verifies the token hash.
39
+ *
40
+ * @param tokenId - Token row ID (from the reset URL)
41
+ * @param token - Reset token (plain text, from the reset URL)
34
42
  * @param newPassword - New password
35
43
  * @returns Success result
36
44
  */
37
- export declare function resetPasswordWithToken(token: string, newPassword: string): Promise<PasswordResetResult>;
45
+ export declare function resetPasswordWithToken(tokenId: string, token: string, newPassword: string): Promise<PasswordResetResult>;
46
+ export interface ChangePasswordResult {
47
+ success: boolean;
48
+ error?: string;
49
+ }
50
+ /**
51
+ * Change password for an authenticated user.
52
+ *
53
+ * Verifies the current password before updating. Does not touch sessions —
54
+ * the caller is responsible for revoking other sessions if desired.
55
+ *
56
+ * @param userId - Authenticated user ID
57
+ * @param currentPassword - Plain-text current password to verify
58
+ * @param newPassword - Plain-text new password to hash and store
59
+ */
60
+ export declare function changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
38
61
  /**
39
62
  * Invalidates a password reset token
40
63
  *
41
- * @param token - Reset token (plain text)
64
+ * Uses O(1) lookup by token ID, then verifies the token hash before marking as used.
65
+ *
66
+ * @param tokenId - Token row ID (from the reset URL)
67
+ * @param token - Reset token (plain text, from the reset URL)
42
68
  */
43
- export declare function invalidatePasswordResetToken(token: string): Promise<void>;
69
+ export declare function invalidatePasswordResetToken(tokenId: string, token: string): Promise<void>;
44
70
  //# sourceMappingURL=password-reset.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"password-reset.d.ts","sourceRoot":"","sources":["../../src/server/password-reset.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA4D5F;AAED;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgCtF;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA6D9B;AAED;;;;GAIG;AACH,wBAAsB,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B/E"}
1
+ {"version":3,"file":"password-reset.d.ts","sourceRoot":"","sources":["../../src/server/password-reset.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAwE5F;AAED;;;;;;;;;GASG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqCxB;AAED;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA4E9B;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,oBAAoB,CAAC,CAqC/B;AAED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChG"}
@@ -7,10 +7,10 @@
7
7
  import crypto from 'node:crypto';
8
8
  import { logger } from '@revealui/core/observability/logger';
9
9
  import { getClient } from '@revealui/db/client';
10
- import { passwordResetTokens, users } from '@revealui/db/schema';
10
+ import { passwordResetTokens, sessions, users } from '@revealui/db/schema';
11
11
  import bcrypt from 'bcryptjs';
12
12
  import { and, eq, gt, isNull } from 'drizzle-orm';
13
- const TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
13
+ const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
14
14
  /**
15
15
  * Hash a token using HMAC-SHA256 with a per-token salt.
16
16
  * The salt is stored in the DB alongside the hash; this defeats rainbow
@@ -37,7 +37,10 @@ function generateSalt() {
37
37
  export async function generatePasswordResetToken(email) {
38
38
  try {
39
39
  const db = getClient();
40
- // Find user by email
40
+ // Find user by email — intentionally does NOT check user.password.
41
+ // OAuth-only users (password: null) can use this flow to set a password,
42
+ // giving them a fallback login method independent of their OAuth provider.
43
+ // This is safe because the reset link is sent to their verified email.
41
44
  const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
42
45
  if (!user) {
43
46
  // Don't reveal if user exists (security best practice)
@@ -46,6 +49,13 @@ export async function generatePasswordResetToken(email) {
46
49
  token: crypto.randomBytes(32).toString('hex'),
47
50
  };
48
51
  }
52
+ // Invalidate any existing unused reset tokens for this user before creating a new one.
53
+ // This limits active tokens to one per user, preventing table accumulation that would
54
+ // slow the time-bounded full-table scan in validatePasswordResetToken.
55
+ await db
56
+ .update(passwordResetTokens)
57
+ .set({ usedAt: new Date() })
58
+ .where(and(eq(passwordResetTokens.userId, user.id), isNull(passwordResetTokens.usedAt)));
49
59
  // Generate secure token with per-token salt
50
60
  const token = crypto.randomBytes(32).toString('hex');
51
61
  const tokenSalt = generateSalt();
@@ -63,6 +73,7 @@ export async function generatePasswordResetToken(email) {
63
73
  return {
64
74
  success: true,
65
75
  token,
76
+ tokenId: id,
66
77
  };
67
78
  }
68
79
  catch (error) {
@@ -86,29 +97,31 @@ export async function generatePasswordResetToken(email) {
86
97
  /**
87
98
  * Validates a password reset token
88
99
  *
89
- * @param token - Reset token (plain text)
100
+ * Uses O(1) lookup by token ID, then verifies the token hash with timingSafeEqual
101
+ * against the single matching row.
102
+ *
103
+ * @param tokenId - Token row ID (from the reset URL)
104
+ * @param token - Reset token (plain text, from the reset URL)
90
105
  * @returns User ID if valid, null otherwise
91
106
  */
92
- export async function validatePasswordResetToken(token) {
107
+ export async function validatePasswordResetToken(tokenId, token) {
93
108
  try {
94
109
  const db = getClient();
95
- // We cannot look up by hash directly without the salt.
96
- // Strategy: find unexpired unused tokens for any user, then verify via HMAC.
97
- // To avoid full-table scans we include a time filter. In practice, there are
98
- // very few active reset tokens at any given moment.
99
- //
100
- // A common alternative is to encode the token ID in the reset URL (e.g.,
101
- // /reset?id=<uuid>&token=<token>), but this requires a URL format change.
102
- // For now we do a time-bounded scan — acceptable at low token volume.
103
- const candidates = await db
110
+ // O(1) lookup by primary key, filtered to unexpired and unused tokens
111
+ const [entry] = await db
104
112
  .select()
105
113
  .from(passwordResetTokens)
106
- .where(and(gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)));
107
- for (const entry of candidates) {
108
- const expectedHash = hashToken(token, entry.tokenSalt);
109
- if (expectedHash === entry.tokenHash) {
110
- return entry.userId;
111
- }
114
+ .where(and(eq(passwordResetTokens.id, tokenId), gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)))
115
+ .limit(1);
116
+ if (!entry) {
117
+ return null;
118
+ }
119
+ // Verify the token hash using timing-safe comparison
120
+ const expectedHash = hashToken(token, entry.tokenSalt);
121
+ const expectedBuf = Buffer.from(expectedHash);
122
+ const actualBuf = Buffer.from(entry.tokenHash);
123
+ if (expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)) {
124
+ return entry.userId;
112
125
  }
113
126
  return null;
114
127
  }
@@ -120,32 +133,38 @@ export async function validatePasswordResetToken(token) {
120
133
  /**
121
134
  * Resets password using a token
122
135
  *
123
- * @param token - Reset token (plain text)
136
+ * Uses O(1) lookup by token ID, then verifies the token hash.
137
+ *
138
+ * @param tokenId - Token row ID (from the reset URL)
139
+ * @param token - Reset token (plain text, from the reset URL)
124
140
  * @param newPassword - New password
125
141
  * @returns Success result
126
142
  */
127
- export async function resetPasswordWithToken(token, newPassword) {
143
+ export async function resetPasswordWithToken(tokenId, token, newPassword) {
128
144
  try {
129
145
  const db = getClient();
130
- // Find valid token by HMAC verification (same time-bounded scan as validatePasswordResetToken)
131
- const candidates = await db
146
+ // O(1) lookup by primary key, filtered to unexpired and unused tokens
147
+ const [entry] = await db
132
148
  .select()
133
149
  .from(passwordResetTokens)
134
- .where(and(gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)));
135
- let entry;
136
- for (const candidate of candidates) {
137
- const expectedHash = hashToken(token, candidate.tokenSalt);
138
- if (expectedHash === candidate.tokenHash) {
139
- entry = candidate;
140
- break;
141
- }
142
- }
150
+ .where(and(eq(passwordResetTokens.id, tokenId), gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)))
151
+ .limit(1);
143
152
  if (!entry) {
144
153
  return {
145
154
  success: false,
146
155
  error: 'Invalid or expired reset token',
147
156
  };
148
157
  }
158
+ // Verify the token hash using timing-safe comparison
159
+ const expectedHash = hashToken(token, entry.tokenSalt);
160
+ const expectedBuf = Buffer.from(expectedHash);
161
+ const actualBuf = Buffer.from(entry.tokenHash);
162
+ if (!(expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf))) {
163
+ return {
164
+ success: false,
165
+ error: 'Invalid or expired reset token',
166
+ };
167
+ }
149
168
  // Validate password strength
150
169
  const { validatePasswordStrength } = await import('./password-validation.js');
151
170
  const passwordValidation = validatePasswordStrength(newPassword);
@@ -159,6 +178,9 @@ export async function resetPasswordWithToken(token, newPassword) {
159
178
  const password = await bcrypt.hash(newPassword, 12);
160
179
  // Update user password
161
180
  await db.update(users).set({ password }).where(eq(users.id, entry.userId));
181
+ // Invalidate all existing sessions for this user so any attacker who had
182
+ // a compromised session can no longer use it after the password change.
183
+ await db.delete(sessions).where(eq(sessions.userId, entry.userId));
162
184
  // Mark token as used (single-use enforcement)
163
185
  await db
164
186
  .update(passwordResetTokens)
@@ -176,28 +198,75 @@ export async function resetPasswordWithToken(token, newPassword) {
176
198
  };
177
199
  }
178
200
  }
201
+ /**
202
+ * Change password for an authenticated user.
203
+ *
204
+ * Verifies the current password before updating. Does not touch sessions —
205
+ * the caller is responsible for revoking other sessions if desired.
206
+ *
207
+ * @param userId - Authenticated user ID
208
+ * @param currentPassword - Plain-text current password to verify
209
+ * @param newPassword - Plain-text new password to hash and store
210
+ */
211
+ export async function changePassword(userId, currentPassword, newPassword) {
212
+ try {
213
+ const db = getClient();
214
+ const [user] = await db
215
+ .select({ id: users.id, password: users.password })
216
+ .from(users)
217
+ .where(eq(users.id, userId))
218
+ .limit(1);
219
+ if (!user) {
220
+ return { success: false, error: 'User not found.' };
221
+ }
222
+ if (!user.password) {
223
+ return {
224
+ success: false,
225
+ error: 'No password is set on this account. Use the password reset link to set one.',
226
+ };
227
+ }
228
+ const currentValid = await bcrypt.compare(currentPassword, user.password);
229
+ if (!currentValid) {
230
+ return { success: false, error: 'Current password is incorrect.' };
231
+ }
232
+ const newHash = await bcrypt.hash(newPassword, 12);
233
+ await db.update(users).set({ password: newHash }).where(eq(users.id, userId));
234
+ return { success: true };
235
+ }
236
+ catch (error) {
237
+ logger.error('Error changing password', error instanceof Error ? error : new Error(String(error)));
238
+ return { success: false, error: 'An unexpected error occurred.' };
239
+ }
240
+ }
179
241
  /**
180
242
  * Invalidates a password reset token
181
243
  *
182
- * @param token - Reset token (plain text)
244
+ * Uses O(1) lookup by token ID, then verifies the token hash before marking as used.
245
+ *
246
+ * @param tokenId - Token row ID (from the reset URL)
247
+ * @param token - Reset token (plain text, from the reset URL)
183
248
  */
184
- export async function invalidatePasswordResetToken(token) {
249
+ export async function invalidatePasswordResetToken(tokenId, token) {
185
250
  try {
186
251
  const db = getClient();
187
- // Find the token entry by HMAC verification (time-bounded scan)
188
- const candidates = await db
252
+ // O(1) lookup by primary key
253
+ const [entry] = await db
189
254
  .select()
190
255
  .from(passwordResetTokens)
191
- .where(and(gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)));
192
- for (const candidate of candidates) {
193
- const expectedHash = hashToken(token, candidate.tokenSalt);
194
- if (expectedHash === candidate.tokenHash) {
195
- await db
196
- .update(passwordResetTokens)
197
- .set({ usedAt: new Date() })
198
- .where(eq(passwordResetTokens.id, candidate.id));
199
- break;
200
- }
256
+ .where(and(eq(passwordResetTokens.id, tokenId), gt(passwordResetTokens.expiresAt, new Date()), isNull(passwordResetTokens.usedAt)))
257
+ .limit(1);
258
+ if (!entry) {
259
+ return;
260
+ }
261
+ // Verify the token hash before invalidating
262
+ const expectedHash = hashToken(token, entry.tokenSalt);
263
+ const expectedBuf = Buffer.from(expectedHash);
264
+ const actualBuf = Buffer.from(entry.tokenHash);
265
+ if (expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)) {
266
+ await db
267
+ .update(passwordResetTokens)
268
+ .set({ usedAt: new Date() })
269
+ .where(eq(passwordResetTokens.id, entry.id));
201
270
  }
202
271
  }
203
272
  catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"password-validation.d.ts","sourceRoot":"","sources":["../../src/server/password-validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,wBAAwB,CAgCnF;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1E"}
1
+ {"version":3,"file":"password-validation.d.ts","sourceRoot":"","sources":["../../src/server/password-validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,wBAAwB,CAgCnF;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1E"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GitHub OAuth Provider
3
+ *
4
+ * Uses native fetch — no additional npm dependencies.
5
+ * Scopes: read:user user:email
6
+ *
7
+ * Note: GitHub may return null email if user has set it private.
8
+ * In that case we fetch from /user/emails and pick the primary verified one.
9
+ */
10
+ import type { ProviderUser } from '../oauth.js';
11
+ export declare function buildAuthUrl(clientId: string, redirectUri: string, state: string): string;
12
+ export declare function exchangeCode(code: string, redirectUri: string): Promise<string>;
13
+ export declare function fetchUser(accessToken: string): Promise<ProviderUser>;
14
+ //# sourceMappingURL=github.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../../../src/server/providers/github.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAOzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqCrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAoD1E"}