@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/useMFA.d.ts +83 -0
- package/dist/react/useMFA.d.ts.map +1 -0
- package/dist/react/useMFA.js +182 -0
- package/dist/react/usePasskey.d.ts +88 -0
- package/dist/react/usePasskey.d.ts.map +1 -0
- package/dist/react/usePasskey.js +203 -0
- package/dist/react/useSession.d.ts.map +1 -1
- package/dist/react/useSession.js +16 -5
- package/dist/react/useSignIn.d.ts +9 -3
- package/dist/react/useSignIn.d.ts.map +1 -1
- package/dist/react/useSignIn.js +32 -10
- package/dist/react/useSignOut.d.ts.map +1 -1
- package/dist/react/useSignUp.d.ts.map +1 -1
- package/dist/react/useSignUp.js +25 -9
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +85 -8
- package/dist/server/brute-force.d.ts +10 -1
- package/dist/server/brute-force.d.ts.map +1 -1
- package/dist/server/brute-force.js +17 -3
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/index.d.ts +16 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +11 -5
- package/dist/server/magic-link.d.ts +52 -0
- package/dist/server/magic-link.d.ts.map +1 -0
- package/dist/server/magic-link.js +111 -0
- package/dist/server/mfa.d.ts +87 -0
- package/dist/server/mfa.d.ts.map +1 -0
- package/dist/server/mfa.js +263 -0
- package/dist/server/oauth.d.ts +52 -4
- package/dist/server/oauth.d.ts.map +1 -1
- package/dist/server/oauth.js +165 -18
- package/dist/server/passkey.d.ts +132 -0
- package/dist/server/passkey.d.ts.map +1 -0
- package/dist/server/passkey.js +257 -0
- package/dist/server/password-reset.d.ts +15 -0
- package/dist/server/password-reset.d.ts.map +1 -1
- package/dist/server/password-reset.js +44 -1
- package/dist/server/password-validation.d.ts.map +1 -1
- package/dist/server/providers/github.d.ts.map +1 -1
- package/dist/server/providers/github.js +18 -5
- package/dist/server/providers/google.d.ts.map +1 -1
- package/dist/server/providers/google.js +18 -3
- package/dist/server/providers/vercel.d.ts.map +1 -1
- package/dist/server/providers/vercel.js +18 -3
- package/dist/server/rate-limit.d.ts +10 -1
- package/dist/server/rate-limit.d.ts.map +1 -1
- package/dist/server/rate-limit.js +61 -43
- package/dist/server/session.d.ts +65 -1
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +175 -7
- package/dist/server/signed-cookie.d.ts +32 -0
- package/dist/server/signed-cookie.d.ts.map +1 -0
- package/dist/server/signed-cookie.js +67 -0
- package/dist/server/storage/database.d.ts +1 -1
- package/dist/server/storage/database.d.ts.map +1 -1
- package/dist/server/storage/database.js +15 -7
- package/dist/server/storage/in-memory.d.ts.map +1 -1
- package/dist/server/storage/in-memory.js +7 -7
- package/dist/server/storage/index.d.ts +11 -3
- package/dist/server/storage/index.d.ts.map +1 -1
- package/dist/server/storage/index.js +18 -4
- package/dist/server/storage/interface.d.ts +1 -1
- package/dist/server/storage/interface.d.ts.map +1 -1
- package/dist/server/storage/interface.js +1 -1
- package/dist/types.d.ts +20 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/utils/database.d.ts.map +1 -1
- package/dist/utils/database.js +9 -2
- package/package.json +31 -13
package/dist/server/oauth.js
CHANGED
|
@@ -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({
|
|
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 {
|
|
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: '
|
|
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
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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
|
-
|
|
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,
|
|
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"}
|