@revealui/auth 0.2.1 → 0.3.2

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 +85 -8
  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 +52 -4
  35. package/dist/server/oauth.d.ts.map +1 -1
  36. package/dist/server/oauth.js +165 -18
  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 -5
  46. package/dist/server/providers/google.d.ts.map +1 -1
  47. package/dist/server/providers/google.js +18 -3
  48. package/dist/server/providers/vercel.d.ts.map +1 -1
  49. package/dist/server/providers/vercel.js +18 -3
  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 +65 -1
  54. package/dist/server/session.d.ts.map +1 -1
  55. package/dist/server/session.js +175 -7
  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 +31 -13
@@ -24,9 +24,14 @@ import * as vercel from './providers/vercel.js';
24
24
  * Cookie value is `<state>.<hmac>` — the HMAC is over the state string
25
25
  * using REVEALUI_SECRET, providing CSRF protection without a DB table.
26
26
  */
27
- export function generateOAuthState(provider, redirectTo) {
27
+ export function generateOAuthState(provider, redirectTo, options) {
28
28
  const nonce = crypto.randomBytes(16).toString('hex');
29
- const payload = JSON.stringify({ provider, redirectTo, nonce });
29
+ const payload = JSON.stringify({
30
+ provider,
31
+ redirectTo,
32
+ nonce,
33
+ ...(options?.linkConsent ? { linkConsent: true } : {}),
34
+ });
30
35
  const state = Buffer.from(payload).toString('base64url');
31
36
  const secret = process.env.REVEALUI_SECRET;
32
37
  if (!secret) {
@@ -49,9 +54,11 @@ export function verifyOAuthState(state, cookieValue) {
49
54
  return null;
50
55
  const storedState = cookieValue.substring(0, dotIdx);
51
56
  const storedHmac = cookieValue.substring(dotIdx + 1);
52
- // State from query param must match what's in the cookie
53
- if (storedState !== state)
57
+ // State from query param must match what's in the cookie (timing-safe)
58
+ if (storedState.length !== state.length ||
59
+ !crypto.timingSafeEqual(Buffer.from(storedState), Buffer.from(state))) {
54
60
  return null;
61
+ }
55
62
  const secret = process.env.REVEALUI_SECRET;
56
63
  if (!secret) {
57
64
  throw new Error('REVEALUI_SECRET is required for OAuth state verification. ' +
@@ -73,7 +80,11 @@ export function verifyOAuthState(state, cookieValue) {
73
80
  }
74
81
  try {
75
82
  const parsed = JSON.parse(Buffer.from(state, 'base64url').toString());
76
- return { provider: parsed.provider, redirectTo: parsed.redirectTo };
83
+ return {
84
+ provider: parsed.provider,
85
+ redirectTo: parsed.redirectTo,
86
+ ...(parsed.linkConsent ? { linkConsent: true } : {}),
87
+ };
77
88
  }
78
89
  catch {
79
90
  return null;
@@ -128,20 +139,17 @@ export async function fetchProviderUser(provider, accessToken) {
128
139
  };
129
140
  return fetchers[provider](accessToken);
130
141
  }
131
- // =============================================================================
132
- // User Upsert
133
- // =============================================================================
134
142
  /**
135
143
  * Find or create a local user for the given OAuth identity.
136
144
  *
137
145
  * Flow:
138
146
  * 1. Look up oauth_accounts by (provider, providerUserId) → get userId
139
147
  * 2. If found: refresh metadata + return user
140
- * 3. If not found: check users by email → link if match
141
- * 4. If no match: create new user (role: 'admin', no password)
148
+ * 3. If not found: check users by email → link if match (with consent) or throw
149
+ * 4. If no match: create new user (role: 'user', no password)
142
150
  * 5. Insert oauth_accounts row
143
151
  */
144
- export async function upsertOAuthUser(provider, providerUser) {
152
+ export async function upsertOAuthUser(provider, providerUser, options) {
145
153
  const db = getClient();
146
154
  // 1. Check for existing linked account
147
155
  const [existingAccount] = await db
@@ -171,11 +179,11 @@ export async function upsertOAuthUser(provider, providerUser) {
171
179
  }
172
180
  return user;
173
181
  }
174
- // 2. Check for existing user by email — BLOCK auto-linking
182
+ // 2. Check for existing user by email
175
183
  // If an account with this email already exists but was not linked via OAuth,
176
- // reject the login. Auto-linking is an account takeover vector: an attacker
177
- // who controls a provider email instantly owns the existing account.
178
- // Explicit linking (from an authenticated session) is a future feature.
184
+ // either link it (when the user has given explicit consent) or reject the
185
+ // login. Auto-linking without consent is an account takeover vector: an
186
+ // attacker who controls a provider email instantly owns the existing account.
179
187
  let userId;
180
188
  let isNewUser = false;
181
189
  if (providerUser.email) {
@@ -185,10 +193,20 @@ export async function upsertOAuthUser(provider, providerUser) {
185
193
  .where(eq(users.email, providerUser.email))
186
194
  .limit(1);
187
195
  if (existingUser) {
188
- throw new OAuthAccountConflictError(providerUser.email);
196
+ if (options?.linkConsent) {
197
+ // User explicitly consented to link — use the existing account
198
+ userId = existingUser.id;
199
+ isNewUser = false;
200
+ logger.info(`Linking ${provider} account to existing user ${userId} (consent-based)`);
201
+ }
202
+ else {
203
+ throw new OAuthAccountConflictError(providerUser.email);
204
+ }
205
+ }
206
+ else {
207
+ isNewUser = true;
208
+ userId = crypto.randomUUID();
189
209
  }
190
- isNewUser = true;
191
- userId = crypto.randomUUID();
192
210
  }
193
211
  else {
194
212
  isNewUser = true;
@@ -221,3 +239,132 @@ export async function upsertOAuthUser(provider, providerUser) {
221
239
  throw new Error('Failed to fetch upserted OAuth user');
222
240
  return user;
223
241
  }
242
+ // =============================================================================
243
+ // Explicit Account Linking
244
+ // =============================================================================
245
+ /**
246
+ * Link an OAuth provider to an existing authenticated user.
247
+ *
248
+ * Unlike upsertOAuthUser(), this function requires the caller to be
249
+ * authenticated and explicitly requests the link. This is safe because
250
+ * the user has already proven ownership of the local account via their
251
+ * session.
252
+ *
253
+ * @param userId - The authenticated user's ID (from session)
254
+ * @param provider - OAuth provider name
255
+ * @param providerUser - Profile returned by the OAuth provider
256
+ * @returns The linked user
257
+ * @throws Error if the provider account is already linked to a different user
258
+ */
259
+ export async function linkOAuthAccount(userId, provider, providerUser) {
260
+ const db = getClient();
261
+ // 1. Check if this provider identity is already linked to ANY user
262
+ const [existingLink] = await db
263
+ .select()
264
+ .from(oauthAccounts)
265
+ .where(and(eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUser.id)))
266
+ .limit(1);
267
+ if (existingLink) {
268
+ if (existingLink.userId === userId) {
269
+ // Already linked to this user — refresh metadata and return
270
+ await db
271
+ .update(oauthAccounts)
272
+ .set({
273
+ providerEmail: providerUser.email,
274
+ providerName: providerUser.name,
275
+ providerAvatarUrl: providerUser.avatarUrl,
276
+ updatedAt: new Date(),
277
+ })
278
+ .where(eq(oauthAccounts.id, existingLink.id));
279
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
280
+ if (!user)
281
+ throw new Error('Authenticated user not found in database');
282
+ return user;
283
+ }
284
+ // Linked to a different user — cannot steal the identity
285
+ throw new Error('This provider account is already linked to another user. Unlink it from the other account first.');
286
+ }
287
+ // 2. Check the authenticated user exists
288
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
289
+ if (!user)
290
+ throw new Error('Authenticated user not found in database');
291
+ // 3. Check if this user already has a link for this provider (different provider account)
292
+ const [existingProviderLink] = await db
293
+ .select()
294
+ .from(oauthAccounts)
295
+ .where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)))
296
+ .limit(1);
297
+ if (existingProviderLink) {
298
+ throw new Error(`You already have a ${provider} account linked. Unlink it first to connect a different one.`);
299
+ }
300
+ // 4. Create the link
301
+ await db.insert(oauthAccounts).values({
302
+ id: crypto.randomUUID(),
303
+ userId,
304
+ provider,
305
+ providerUserId: providerUser.id,
306
+ providerEmail: providerUser.email,
307
+ providerName: providerUser.name,
308
+ providerAvatarUrl: providerUser.avatarUrl,
309
+ });
310
+ logger.info(`Linked ${provider} account to user ${userId}`);
311
+ return user;
312
+ }
313
+ /**
314
+ * Unlink an OAuth provider from a user's account.
315
+ *
316
+ * Safety: refuses to unlink the last auth method (if user has no password
317
+ * and this is their only OAuth link, unlinking would lock them out).
318
+ *
319
+ * @param userId - The authenticated user's ID
320
+ * @param provider - The provider to unlink
321
+ * @throws Error if unlinking would leave the user with no authentication method
322
+ */
323
+ export async function unlinkOAuthAccount(userId, provider) {
324
+ const db = getClient();
325
+ // 1. Find the link to remove
326
+ const [link] = await db
327
+ .select()
328
+ .from(oauthAccounts)
329
+ .where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)))
330
+ .limit(1);
331
+ if (!link) {
332
+ throw new Error(`No ${provider} account is linked to your account`);
333
+ }
334
+ // 2. Safety check: ensure user won't be locked out
335
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
336
+ if (!user)
337
+ throw new Error('User not found');
338
+ const allLinks = await db
339
+ .select({ id: oauthAccounts.id })
340
+ .from(oauthAccounts)
341
+ .where(eq(oauthAccounts.userId, userId));
342
+ const hasPassword = !!user.password;
343
+ const otherLinksCount = allLinks.length - 1;
344
+ if (!hasPassword && otherLinksCount === 0) {
345
+ throw new Error('Cannot unlink your only sign-in method. Set a password first, or link another provider.');
346
+ }
347
+ // 3. Delete the link
348
+ await db
349
+ .delete(oauthAccounts)
350
+ .where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)));
351
+ logger.info(`Unlinked ${provider} account from user ${userId}`);
352
+ }
353
+ /**
354
+ * Get all linked OAuth providers for a user.
355
+ *
356
+ * @param userId - The user's ID
357
+ * @returns Array of linked provider info (provider name, email, avatar)
358
+ */
359
+ export async function getLinkedProviders(userId) {
360
+ const db = getClient();
361
+ const links = await db
362
+ .select({
363
+ provider: oauthAccounts.provider,
364
+ providerEmail: oauthAccounts.providerEmail,
365
+ providerName: oauthAccounts.providerName,
366
+ })
367
+ .from(oauthAccounts)
368
+ .where(eq(oauthAccounts.userId, userId));
369
+ return links;
370
+ }
@@ -0,0 +1,132 @@
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 type { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, VerifiedRegistrationResponse, WebAuthnCredential } from '@simplewebauthn/server';
11
+ export interface PasskeyConfig {
12
+ /** Maximum passkeys per user (default: 10) */
13
+ maxPasskeysPerUser: number;
14
+ /** Challenge TTL in ms (default: 5 minutes) */
15
+ challengeTtlMs: number;
16
+ /** Relying Party ID — domain name (default: 'localhost') */
17
+ rpId: string;
18
+ /** Relying Party name — user-visible (default: 'RevealUI') */
19
+ rpName: string;
20
+ /** Expected origin(s) for verification (default: 'http://localhost:4000') */
21
+ origin: string | string[];
22
+ }
23
+ export declare function configurePasskey(overrides: Partial<PasskeyConfig>): void;
24
+ export declare function resetPasskeyConfig(): void;
25
+ /**
26
+ * Generate WebAuthn registration options for a user.
27
+ *
28
+ * The returned object should be passed to the browser's navigator.credentials.create().
29
+ * The challenge is embedded in the options and should be stored server-side
30
+ * for verification.
31
+ *
32
+ * @param userId - User's database ID
33
+ * @param userEmail - User's email (used as userName in WebAuthn)
34
+ * @param existingCredentialIds - Credential IDs to exclude (prevent re-registration)
35
+ */
36
+ export declare function generateRegistrationChallenge(userId: string, userEmail: string, existingCredentialIds?: string[]): Promise<PublicKeyCredentialCreationOptionsJSON>;
37
+ /**
38
+ * Verify a WebAuthn registration response from the browser.
39
+ *
40
+ * @param response - The RegistrationResponseJSON from the browser
41
+ * @param expectedChallenge - The challenge from generateRegistrationChallenge
42
+ * @param expectedOrigin - Override origin (defaults to config.origin)
43
+ * @returns Verified registration data including credential info
44
+ * @throws If verification fails
45
+ */
46
+ export declare function verifyRegistration(response: RegistrationResponseJSON, expectedChallenge: string, expectedOrigin?: string | string[]): Promise<VerifiedRegistrationResponse>;
47
+ /**
48
+ * Store a verified passkey credential in the database.
49
+ *
50
+ * Enforces the per-user passkey limit before insertion.
51
+ *
52
+ * @param userId - User's database ID
53
+ * @param credential - Verified credential from verifyRegistration
54
+ * @param deviceName - Optional user-friendly name (e.g., "MacBook Pro Touch ID")
55
+ * @returns The stored passkey record
56
+ */
57
+ export declare function storePasskey(userId: string, credential: {
58
+ id: string;
59
+ publicKey: Uint8Array;
60
+ counter: number;
61
+ transports?: string[];
62
+ aaguid?: string;
63
+ backedUp?: boolean;
64
+ }, deviceName?: string): Promise<{
65
+ id: string;
66
+ userId: string;
67
+ credentialId: string;
68
+ deviceName: string | null;
69
+ createdAt: Date;
70
+ }>;
71
+ /**
72
+ * Generate WebAuthn authentication options.
73
+ *
74
+ * The returned object should be passed to the browser's navigator.credentials.get().
75
+ *
76
+ * @param allowCredentials - Optional list of credential IDs to allow
77
+ */
78
+ export declare function generateAuthenticationChallenge(allowCredentials?: {
79
+ id: string;
80
+ transports?: string[];
81
+ }[]): Promise<PublicKeyCredentialRequestOptionsJSON>;
82
+ /**
83
+ * Verify a WebAuthn authentication response and update the credential counter.
84
+ *
85
+ * @param response - The AuthenticationResponseJSON from the browser
86
+ * @param credential - The stored credential (id, publicKey, counter)
87
+ * @param expectedChallenge - The challenge from generateAuthenticationChallenge
88
+ * @param expectedOrigin - Override origin (defaults to config.origin)
89
+ * @returns Verification result with new counter value
90
+ */
91
+ export declare function verifyAuthentication(response: AuthenticationResponseJSON, credential: WebAuthnCredential, expectedChallenge: string, expectedOrigin?: string | string[]): Promise<{
92
+ verified: boolean;
93
+ newCounter: number;
94
+ }>;
95
+ /**
96
+ * List all passkeys for a user (safe for client — excludes publicKey and counter).
97
+ */
98
+ export declare function listPasskeys(userId: string): Promise<{
99
+ id: string;
100
+ credentialId: string;
101
+ deviceName: string | null;
102
+ backedUp: boolean;
103
+ createdAt: Date;
104
+ lastUsedAt: Date | null;
105
+ }[]>;
106
+ /**
107
+ * Delete a passkey. Blocks deletion if it's the user's last sign-in method.
108
+ *
109
+ * @param userId - User's database ID
110
+ * @param passkeyId - The passkey record ID to delete
111
+ * @throws If this is the user's only sign-in method
112
+ */
113
+ export declare function deletePasskey(userId: string, passkeyId: string): Promise<void>;
114
+ /**
115
+ * Rename a passkey's device name.
116
+ *
117
+ * @param userId - User's database ID
118
+ * @param passkeyId - The passkey record ID to rename
119
+ * @param name - New friendly name
120
+ */
121
+ export declare function renamePasskey(userId: string, passkeyId: string, name: string): Promise<void>;
122
+ /**
123
+ * Count a user's sign-in credentials (passkeys + password).
124
+ *
125
+ * @param userId - User's database ID
126
+ * @returns Passkey count and whether user has a password set
127
+ */
128
+ export declare function countUserCredentials(userId: string): Promise<{
129
+ passkeyCount: number;
130
+ hasPassword: boolean;
131
+ }>;
132
+ //# sourceMappingURL=passkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkey.d.ts","sourceRoot":"","sources":["../../src/server/passkey.ts"],"names":[],"mappings":"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"}
@@ -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
+ }
@@ -43,6 +43,21 @@ export declare function validatePasswordResetToken(tokenId: string, token: strin
43
43
  * @returns Success result
44
44
  */
45
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>;
46
61
  /**
47
62
  * Invalidates a password reset token
48
63
  *
@@ -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;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqE5F;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;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChG"}
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"}