@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.
- package/README.md +58 -34
- 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 +1 -0
- package/dist/react/useSignUp.d.ts.map +1 -1
- package/dist/react/useSignUp.js +25 -9
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +93 -5
- 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 +46 -23
- package/dist/server/errors.d.ts +4 -0
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/index.d.ts +17 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +12 -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 +86 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +355 -0
- 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 +32 -6
- package/dist/server/password-reset.d.ts.map +1 -1
- package/dist/server/password-reset.js +116 -47
- package/dist/server/password-validation.d.ts.map +1 -1
- package/dist/server/providers/github.d.ts +14 -0
- package/dist/server/providers/github.d.ts.map +1 -0
- package/dist/server/providers/github.js +89 -0
- package/dist/server/providers/google.d.ts +11 -0
- package/dist/server/providers/google.d.ts.map +1 -0
- package/dist/server/providers/google.js +69 -0
- package/dist/server/providers/vercel.d.ts +11 -0
- package/dist/server/providers/vercel.d.ts.map +1 -0
- package/dist/server/providers/vercel.js +63 -0
- 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 +48 -1
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +126 -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 +10 -1
- package/dist/server/storage/database.d.ts.map +1 -1
- package/dist/server/storage/database.js +43 -5
- package/dist/server/storage/in-memory.d.ts +4 -0
- package/dist/server/storage/in-memory.d.ts.map +1 -1
- package/dist/server/storage/in-memory.js +16 -6
- 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 +11 -1
- package/dist/server/storage/interface.d.ts.map +1 -1
- package/dist/server/storage/interface.js +1 -1
- package/dist/types.d.ts +23 -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 +12 -2
- package/dist/utils/token.d.ts +9 -1
- package/dist/utils/token.d.ts.map +1 -1
- package/dist/utils/token.js +9 -1
- package/package.json +26 -8
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Core — State Management + User Upsert
|
|
3
|
+
*
|
|
4
|
+
* CSRF state: signed cookie using HMAC-SHA256 over a base64url payload.
|
|
5
|
+
* Provider dispatch: routes to Google / GitHub / Vercel provider modules.
|
|
6
|
+
* User upsert: links OAuth identities to local users via oauth_accounts table.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { logger } from '@revealui/core/observability/logger';
|
|
10
|
+
import { getClient } from '@revealui/db/client';
|
|
11
|
+
import { oauthAccounts, users } from '@revealui/db/schema';
|
|
12
|
+
import { and, eq } from 'drizzle-orm';
|
|
13
|
+
import { OAuthAccountConflictError } from './errors.js';
|
|
14
|
+
import * as github from './providers/github.js';
|
|
15
|
+
import * as google from './providers/google.js';
|
|
16
|
+
import * as vercel from './providers/vercel.js';
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// CSRF State
|
|
19
|
+
// =============================================================================
|
|
20
|
+
/**
|
|
21
|
+
* Generate a signed OAuth state token.
|
|
22
|
+
*
|
|
23
|
+
* State encodes provider + redirectTo + nonce as base64url JSON.
|
|
24
|
+
* Cookie value is `<state>.<hmac>` — the HMAC is over the state string
|
|
25
|
+
* using REVEALUI_SECRET, providing CSRF protection without a DB table.
|
|
26
|
+
*/
|
|
27
|
+
export function generateOAuthState(provider, redirectTo) {
|
|
28
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
29
|
+
const payload = JSON.stringify({ provider, redirectTo, nonce });
|
|
30
|
+
const state = Buffer.from(payload).toString('base64url');
|
|
31
|
+
const secret = process.env.REVEALUI_SECRET;
|
|
32
|
+
if (!secret) {
|
|
33
|
+
throw new Error('REVEALUI_SECRET is required for OAuth state signing. ' +
|
|
34
|
+
'Set it in your environment variables.');
|
|
35
|
+
}
|
|
36
|
+
const hmac = crypto.createHmac('sha256', secret).update(state).digest('hex');
|
|
37
|
+
return { state, cookieValue: `${state}.${hmac}` };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify a signed OAuth state token from the callback.
|
|
41
|
+
*
|
|
42
|
+
* Returns the decoded provider + redirectTo if valid, null otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export function verifyOAuthState(state, cookieValue) {
|
|
45
|
+
if (!(state && cookieValue))
|
|
46
|
+
return null;
|
|
47
|
+
const dotIdx = cookieValue.lastIndexOf('.');
|
|
48
|
+
if (dotIdx === -1)
|
|
49
|
+
return null;
|
|
50
|
+
const storedState = cookieValue.substring(0, dotIdx);
|
|
51
|
+
const storedHmac = cookieValue.substring(dotIdx + 1);
|
|
52
|
+
// State from query param must match what's in the cookie (timing-safe)
|
|
53
|
+
if (storedState.length !== state.length ||
|
|
54
|
+
!crypto.timingSafeEqual(Buffer.from(storedState), Buffer.from(state))) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const secret = process.env.REVEALUI_SECRET;
|
|
58
|
+
if (!secret) {
|
|
59
|
+
throw new Error('REVEALUI_SECRET is required for OAuth state verification. ' +
|
|
60
|
+
'Set it in your environment variables.');
|
|
61
|
+
}
|
|
62
|
+
const expectedHmac = crypto.createHmac('sha256', secret).update(state).digest('hex');
|
|
63
|
+
// Both are hex-encoded SHA-256 HMACs — must be exactly 64 hex characters.
|
|
64
|
+
// Reject wrong-length inputs immediately; do NOT pad (padding enables forged matches
|
|
65
|
+
// where a short storedHmac is zero-padded to collide with the expected hash).
|
|
66
|
+
if (storedHmac.length !== 64 || expectedHmac.length !== 64)
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
if (!crypto.timingSafeEqual(Buffer.from(storedHmac, 'hex'), Buffer.from(expectedHmac, 'hex'))) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString());
|
|
78
|
+
return { provider: parsed.provider, redirectTo: parsed.redirectTo };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Provider Dispatch
|
|
86
|
+
// =============================================================================
|
|
87
|
+
const PROVIDERS = ['google', 'github', 'vercel'];
|
|
88
|
+
function isProvider(p) {
|
|
89
|
+
return PROVIDERS.includes(p);
|
|
90
|
+
}
|
|
91
|
+
function getClientId(provider) {
|
|
92
|
+
const map = {
|
|
93
|
+
google: process.env.GOOGLE_CLIENT_ID,
|
|
94
|
+
github: process.env.GITHUB_CLIENT_ID,
|
|
95
|
+
vercel: process.env.VERCEL_CLIENT_ID,
|
|
96
|
+
};
|
|
97
|
+
const id = map[provider];
|
|
98
|
+
if (!id)
|
|
99
|
+
throw new Error(`Missing client ID for provider: ${provider}`);
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
export function buildAuthUrl(provider, redirectUri, state) {
|
|
103
|
+
if (!isProvider(provider))
|
|
104
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
105
|
+
const clientId = getClientId(provider);
|
|
106
|
+
const builders = {
|
|
107
|
+
google: google.buildAuthUrl,
|
|
108
|
+
github: github.buildAuthUrl,
|
|
109
|
+
vercel: vercel.buildAuthUrl,
|
|
110
|
+
};
|
|
111
|
+
return builders[provider](clientId, redirectUri, state);
|
|
112
|
+
}
|
|
113
|
+
export async function exchangeCode(provider, code, redirectUri) {
|
|
114
|
+
if (!isProvider(provider))
|
|
115
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
116
|
+
const exchangers = {
|
|
117
|
+
google: google.exchangeCode,
|
|
118
|
+
github: github.exchangeCode,
|
|
119
|
+
vercel: vercel.exchangeCode,
|
|
120
|
+
};
|
|
121
|
+
return exchangers[provider](code, redirectUri);
|
|
122
|
+
}
|
|
123
|
+
export async function fetchProviderUser(provider, accessToken) {
|
|
124
|
+
if (!isProvider(provider))
|
|
125
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
126
|
+
const fetchers = {
|
|
127
|
+
google: google.fetchUser,
|
|
128
|
+
github: github.fetchUser,
|
|
129
|
+
vercel: vercel.fetchUser,
|
|
130
|
+
};
|
|
131
|
+
return fetchers[provider](accessToken);
|
|
132
|
+
}
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// User Upsert
|
|
135
|
+
// =============================================================================
|
|
136
|
+
/**
|
|
137
|
+
* Find or create a local user for the given OAuth identity.
|
|
138
|
+
*
|
|
139
|
+
* Flow:
|
|
140
|
+
* 1. Look up oauth_accounts by (provider, providerUserId) → get userId
|
|
141
|
+
* 2. If found: refresh metadata + return user
|
|
142
|
+
* 3. If not found: check users by email → link if match
|
|
143
|
+
* 4. If no match: create new user (role: 'admin', no password)
|
|
144
|
+
* 5. Insert oauth_accounts row
|
|
145
|
+
*/
|
|
146
|
+
export async function upsertOAuthUser(provider, providerUser) {
|
|
147
|
+
const db = getClient();
|
|
148
|
+
// 1. Check for existing linked account
|
|
149
|
+
const [existingAccount] = await db
|
|
150
|
+
.select()
|
|
151
|
+
.from(oauthAccounts)
|
|
152
|
+
.where(and(eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUser.id)))
|
|
153
|
+
.limit(1);
|
|
154
|
+
if (existingAccount) {
|
|
155
|
+
// Refresh provider metadata (name/email/avatar may have changed)
|
|
156
|
+
await db
|
|
157
|
+
.update(oauthAccounts)
|
|
158
|
+
.set({
|
|
159
|
+
providerEmail: providerUser.email,
|
|
160
|
+
providerName: providerUser.name,
|
|
161
|
+
providerAvatarUrl: providerUser.avatarUrl,
|
|
162
|
+
updatedAt: new Date(),
|
|
163
|
+
})
|
|
164
|
+
.where(eq(oauthAccounts.id, existingAccount.id));
|
|
165
|
+
const [user] = await db
|
|
166
|
+
.select()
|
|
167
|
+
.from(users)
|
|
168
|
+
.where(eq(users.id, existingAccount.userId))
|
|
169
|
+
.limit(1);
|
|
170
|
+
if (!user) {
|
|
171
|
+
logger.error(`oauth_accounts row ${existingAccount.id} references missing user ${existingAccount.userId}`);
|
|
172
|
+
throw new Error('OAuth account references a deleted user');
|
|
173
|
+
}
|
|
174
|
+
return user;
|
|
175
|
+
}
|
|
176
|
+
// 2. Check for existing user by email — BLOCK auto-linking
|
|
177
|
+
// If an account with this email already exists but was not linked via OAuth,
|
|
178
|
+
// reject the login. Auto-linking is an account takeover vector: an attacker
|
|
179
|
+
// who controls a provider email instantly owns the existing account.
|
|
180
|
+
// Users must link providers explicitly from an authenticated session
|
|
181
|
+
// via linkOAuthAccount().
|
|
182
|
+
let userId;
|
|
183
|
+
let isNewUser = false;
|
|
184
|
+
if (providerUser.email) {
|
|
185
|
+
const [existingUser] = await db
|
|
186
|
+
.select()
|
|
187
|
+
.from(users)
|
|
188
|
+
.where(eq(users.email, providerUser.email))
|
|
189
|
+
.limit(1);
|
|
190
|
+
if (existingUser) {
|
|
191
|
+
throw new OAuthAccountConflictError(providerUser.email);
|
|
192
|
+
}
|
|
193
|
+
isNewUser = true;
|
|
194
|
+
userId = crypto.randomUUID();
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
isNewUser = true;
|
|
198
|
+
userId = crypto.randomUUID();
|
|
199
|
+
}
|
|
200
|
+
// 3. Create user if none found
|
|
201
|
+
if (isNewUser) {
|
|
202
|
+
await db.insert(users).values({
|
|
203
|
+
id: userId,
|
|
204
|
+
name: providerUser.name,
|
|
205
|
+
email: providerUser.email,
|
|
206
|
+
avatarUrl: providerUser.avatarUrl,
|
|
207
|
+
password: null,
|
|
208
|
+
role: 'user',
|
|
209
|
+
status: 'active',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// 4. Insert oauth_accounts link
|
|
213
|
+
await db.insert(oauthAccounts).values({
|
|
214
|
+
id: crypto.randomUUID(),
|
|
215
|
+
userId,
|
|
216
|
+
provider,
|
|
217
|
+
providerUserId: providerUser.id,
|
|
218
|
+
providerEmail: providerUser.email,
|
|
219
|
+
providerName: providerUser.name,
|
|
220
|
+
providerAvatarUrl: providerUser.avatarUrl,
|
|
221
|
+
});
|
|
222
|
+
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
223
|
+
if (!user)
|
|
224
|
+
throw new Error('Failed to fetch upserted OAuth user');
|
|
225
|
+
return user;
|
|
226
|
+
}
|
|
227
|
+
// =============================================================================
|
|
228
|
+
// Explicit Account Linking
|
|
229
|
+
// =============================================================================
|
|
230
|
+
/**
|
|
231
|
+
* Link an OAuth provider to an existing authenticated user.
|
|
232
|
+
*
|
|
233
|
+
* Unlike upsertOAuthUser(), this function requires the caller to be
|
|
234
|
+
* authenticated and explicitly requests the link. This is safe because
|
|
235
|
+
* the user has already proven ownership of the local account via their
|
|
236
|
+
* session.
|
|
237
|
+
*
|
|
238
|
+
* @param userId - The authenticated user's ID (from session)
|
|
239
|
+
* @param provider - OAuth provider name
|
|
240
|
+
* @param providerUser - Profile returned by the OAuth provider
|
|
241
|
+
* @returns The linked user
|
|
242
|
+
* @throws Error if the provider account is already linked to a different user
|
|
243
|
+
*/
|
|
244
|
+
export async function linkOAuthAccount(userId, provider, providerUser) {
|
|
245
|
+
const db = getClient();
|
|
246
|
+
// 1. Check if this provider identity is already linked to ANY user
|
|
247
|
+
const [existingLink] = await db
|
|
248
|
+
.select()
|
|
249
|
+
.from(oauthAccounts)
|
|
250
|
+
.where(and(eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUser.id)))
|
|
251
|
+
.limit(1);
|
|
252
|
+
if (existingLink) {
|
|
253
|
+
if (existingLink.userId === userId) {
|
|
254
|
+
// Already linked to this user — refresh metadata and return
|
|
255
|
+
await db
|
|
256
|
+
.update(oauthAccounts)
|
|
257
|
+
.set({
|
|
258
|
+
providerEmail: providerUser.email,
|
|
259
|
+
providerName: providerUser.name,
|
|
260
|
+
providerAvatarUrl: providerUser.avatarUrl,
|
|
261
|
+
updatedAt: new Date(),
|
|
262
|
+
})
|
|
263
|
+
.where(eq(oauthAccounts.id, existingLink.id));
|
|
264
|
+
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
265
|
+
if (!user)
|
|
266
|
+
throw new Error('Authenticated user not found in database');
|
|
267
|
+
return user;
|
|
268
|
+
}
|
|
269
|
+
// Linked to a different user — cannot steal the identity
|
|
270
|
+
throw new Error('This provider account is already linked to another user. Unlink it from the other account first.');
|
|
271
|
+
}
|
|
272
|
+
// 2. Check the authenticated user exists
|
|
273
|
+
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
274
|
+
if (!user)
|
|
275
|
+
throw new Error('Authenticated user not found in database');
|
|
276
|
+
// 3. Check if this user already has a link for this provider (different provider account)
|
|
277
|
+
const [existingProviderLink] = await db
|
|
278
|
+
.select()
|
|
279
|
+
.from(oauthAccounts)
|
|
280
|
+
.where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)))
|
|
281
|
+
.limit(1);
|
|
282
|
+
if (existingProviderLink) {
|
|
283
|
+
throw new Error(`You already have a ${provider} account linked. Unlink it first to connect a different one.`);
|
|
284
|
+
}
|
|
285
|
+
// 4. Create the link
|
|
286
|
+
await db.insert(oauthAccounts).values({
|
|
287
|
+
id: crypto.randomUUID(),
|
|
288
|
+
userId,
|
|
289
|
+
provider,
|
|
290
|
+
providerUserId: providerUser.id,
|
|
291
|
+
providerEmail: providerUser.email,
|
|
292
|
+
providerName: providerUser.name,
|
|
293
|
+
providerAvatarUrl: providerUser.avatarUrl,
|
|
294
|
+
});
|
|
295
|
+
logger.info(`Linked ${provider} account to user ${userId}`);
|
|
296
|
+
return user;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Unlink an OAuth provider from a user's account.
|
|
300
|
+
*
|
|
301
|
+
* Safety: refuses to unlink the last auth method (if user has no password
|
|
302
|
+
* and this is their only OAuth link, unlinking would lock them out).
|
|
303
|
+
*
|
|
304
|
+
* @param userId - The authenticated user's ID
|
|
305
|
+
* @param provider - The provider to unlink
|
|
306
|
+
* @throws Error if unlinking would leave the user with no authentication method
|
|
307
|
+
*/
|
|
308
|
+
export async function unlinkOAuthAccount(userId, provider) {
|
|
309
|
+
const db = getClient();
|
|
310
|
+
// 1. Find the link to remove
|
|
311
|
+
const [link] = await db
|
|
312
|
+
.select()
|
|
313
|
+
.from(oauthAccounts)
|
|
314
|
+
.where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)))
|
|
315
|
+
.limit(1);
|
|
316
|
+
if (!link) {
|
|
317
|
+
throw new Error(`No ${provider} account is linked to your account`);
|
|
318
|
+
}
|
|
319
|
+
// 2. Safety check: ensure user won't be locked out
|
|
320
|
+
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
321
|
+
if (!user)
|
|
322
|
+
throw new Error('User not found');
|
|
323
|
+
const allLinks = await db
|
|
324
|
+
.select({ id: oauthAccounts.id })
|
|
325
|
+
.from(oauthAccounts)
|
|
326
|
+
.where(eq(oauthAccounts.userId, userId));
|
|
327
|
+
const hasPassword = !!user.password;
|
|
328
|
+
const otherLinksCount = allLinks.length - 1;
|
|
329
|
+
if (!hasPassword && otherLinksCount === 0) {
|
|
330
|
+
throw new Error('Cannot unlink your only sign-in method. Set a password first, or link another provider.');
|
|
331
|
+
}
|
|
332
|
+
// 3. Delete the link
|
|
333
|
+
await db
|
|
334
|
+
.delete(oauthAccounts)
|
|
335
|
+
.where(and(eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider)));
|
|
336
|
+
logger.info(`Unlinked ${provider} account from user ${userId}`);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Get all linked OAuth providers for a user.
|
|
340
|
+
*
|
|
341
|
+
* @param userId - The user's ID
|
|
342
|
+
* @returns Array of linked provider info (provider name, email, avatar)
|
|
343
|
+
*/
|
|
344
|
+
export async function getLinkedProviders(userId) {
|
|
345
|
+
const db = getClient();
|
|
346
|
+
const links = await db
|
|
347
|
+
.select({
|
|
348
|
+
provider: oauthAccounts.provider,
|
|
349
|
+
providerEmail: oauthAccounts.providerEmail,
|
|
350
|
+
providerName: oauthAccounts.providerName,
|
|
351
|
+
})
|
|
352
|
+
.from(oauthAccounts)
|
|
353
|
+
.where(eq(oauthAccounts.userId, userId));
|
|
354
|
+
return links;
|
|
355
|
+
}
|
|
@@ -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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"}
|