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