@revealui/auth 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/react/index.d.ts +4 -0
  3. package/dist/react/index.d.ts.map +1 -1
  4. package/dist/react/index.js +2 -0
  5. package/dist/react/useMFA.d.ts +83 -0
  6. package/dist/react/useMFA.d.ts.map +1 -0
  7. package/dist/react/useMFA.js +182 -0
  8. package/dist/react/usePasskey.d.ts +88 -0
  9. package/dist/react/usePasskey.d.ts.map +1 -0
  10. package/dist/react/usePasskey.js +203 -0
  11. package/dist/react/useSession.d.ts.map +1 -1
  12. package/dist/react/useSession.js +16 -5
  13. package/dist/react/useSignIn.d.ts +9 -3
  14. package/dist/react/useSignIn.d.ts.map +1 -1
  15. package/dist/react/useSignIn.js +32 -10
  16. package/dist/react/useSignOut.d.ts.map +1 -1
  17. package/dist/react/useSignUp.d.ts.map +1 -1
  18. package/dist/react/useSignUp.js +25 -9
  19. package/dist/server/auth.d.ts.map +1 -1
  20. package/dist/server/auth.js +75 -4
  21. package/dist/server/brute-force.d.ts +10 -1
  22. package/dist/server/brute-force.d.ts.map +1 -1
  23. package/dist/server/brute-force.js +17 -3
  24. package/dist/server/errors.d.ts.map +1 -1
  25. package/dist/server/index.d.ts +16 -6
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +11 -5
  28. package/dist/server/magic-link.d.ts +52 -0
  29. package/dist/server/magic-link.d.ts.map +1 -0
  30. package/dist/server/magic-link.js +111 -0
  31. package/dist/server/mfa.d.ts +87 -0
  32. package/dist/server/mfa.d.ts.map +1 -0
  33. package/dist/server/mfa.js +263 -0
  34. package/dist/server/oauth.d.ts +37 -0
  35. package/dist/server/oauth.d.ts.map +1 -1
  36. package/dist/server/oauth.js +135 -3
  37. package/dist/server/passkey.d.ts +132 -0
  38. package/dist/server/passkey.d.ts.map +1 -0
  39. package/dist/server/passkey.js +257 -0
  40. package/dist/server/password-reset.d.ts +15 -0
  41. package/dist/server/password-reset.d.ts.map +1 -1
  42. package/dist/server/password-reset.js +44 -1
  43. package/dist/server/password-validation.d.ts.map +1 -1
  44. package/dist/server/providers/github.d.ts.map +1 -1
  45. package/dist/server/providers/github.js +18 -2
  46. package/dist/server/providers/google.d.ts.map +1 -1
  47. package/dist/server/providers/google.js +18 -2
  48. package/dist/server/providers/vercel.d.ts.map +1 -1
  49. package/dist/server/providers/vercel.js +18 -2
  50. package/dist/server/rate-limit.d.ts +10 -1
  51. package/dist/server/rate-limit.d.ts.map +1 -1
  52. package/dist/server/rate-limit.js +61 -43
  53. package/dist/server/session.d.ts +48 -1
  54. package/dist/server/session.d.ts.map +1 -1
  55. package/dist/server/session.js +125 -6
  56. package/dist/server/signed-cookie.d.ts +32 -0
  57. package/dist/server/signed-cookie.d.ts.map +1 -0
  58. package/dist/server/signed-cookie.js +67 -0
  59. package/dist/server/storage/database.d.ts +1 -1
  60. package/dist/server/storage/database.d.ts.map +1 -1
  61. package/dist/server/storage/database.js +15 -7
  62. package/dist/server/storage/in-memory.d.ts.map +1 -1
  63. package/dist/server/storage/in-memory.js +7 -7
  64. package/dist/server/storage/index.d.ts +11 -3
  65. package/dist/server/storage/index.d.ts.map +1 -1
  66. package/dist/server/storage/index.js +18 -4
  67. package/dist/server/storage/interface.d.ts +1 -1
  68. package/dist/server/storage/interface.d.ts.map +1 -1
  69. package/dist/server/storage/interface.js +1 -1
  70. package/dist/types.d.ts +20 -8
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js +2 -2
  73. package/dist/utils/database.d.ts.map +1 -1
  74. package/dist/utils/database.js +9 -2
  75. package/package.json +26 -8
@@ -0,0 +1,132 @@
1
+ /**
2
+ * WebAuthn Passkey Module
3
+ *
4
+ * Implements passkey registration, authentication, and management using
5
+ * @simplewebauthn/server v13. Passkeys enable passwordless authentication
6
+ * via biometrics, security keys, or platform authenticators.
7
+ *
8
+ * @see https://simplewebauthn.dev/
9
+ */
10
+ import type { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, VerifiedRegistrationResponse, WebAuthnCredential } from '@simplewebauthn/server';
11
+ export interface PasskeyConfig {
12
+ /** Maximum passkeys per user (default: 10) */
13
+ maxPasskeysPerUser: number;
14
+ /** Challenge TTL in ms (default: 5 minutes) */
15
+ challengeTtlMs: number;
16
+ /** Relying Party ID — domain name (default: 'localhost') */
17
+ rpId: string;
18
+ /** Relying Party name — user-visible (default: 'RevealUI') */
19
+ rpName: string;
20
+ /** Expected origin(s) for verification (default: 'http://localhost:4000') */
21
+ origin: string | string[];
22
+ }
23
+ export declare function configurePasskey(overrides: Partial<PasskeyConfig>): void;
24
+ export declare function resetPasskeyConfig(): void;
25
+ /**
26
+ * Generate WebAuthn registration options for a user.
27
+ *
28
+ * The returned object should be passed to the browser's navigator.credentials.create().
29
+ * The challenge is embedded in the options and should be stored server-side
30
+ * for verification.
31
+ *
32
+ * @param userId - User's database ID
33
+ * @param userEmail - User's email (used as userName in WebAuthn)
34
+ * @param existingCredentialIds - Credential IDs to exclude (prevent re-registration)
35
+ */
36
+ export declare function generateRegistrationChallenge(userId: string, userEmail: string, existingCredentialIds?: string[]): Promise<PublicKeyCredentialCreationOptionsJSON>;
37
+ /**
38
+ * Verify a WebAuthn registration response from the browser.
39
+ *
40
+ * @param response - The RegistrationResponseJSON from the browser
41
+ * @param expectedChallenge - The challenge from generateRegistrationChallenge
42
+ * @param expectedOrigin - Override origin (defaults to config.origin)
43
+ * @returns Verified registration data including credential info
44
+ * @throws If verification fails
45
+ */
46
+ export declare function verifyRegistration(response: RegistrationResponseJSON, expectedChallenge: string, expectedOrigin?: string | string[]): Promise<VerifiedRegistrationResponse>;
47
+ /**
48
+ * Store a verified passkey credential in the database.
49
+ *
50
+ * Enforces the per-user passkey limit before insertion.
51
+ *
52
+ * @param userId - User's database ID
53
+ * @param credential - Verified credential from verifyRegistration
54
+ * @param deviceName - Optional user-friendly name (e.g., "MacBook Pro Touch ID")
55
+ * @returns The stored passkey record
56
+ */
57
+ export declare function storePasskey(userId: string, credential: {
58
+ id: string;
59
+ publicKey: Uint8Array;
60
+ counter: number;
61
+ transports?: string[];
62
+ aaguid?: string;
63
+ backedUp?: boolean;
64
+ }, deviceName?: string): Promise<{
65
+ id: string;
66
+ userId: string;
67
+ credentialId: string;
68
+ deviceName: string | null;
69
+ createdAt: Date;
70
+ }>;
71
+ /**
72
+ * Generate WebAuthn authentication options.
73
+ *
74
+ * The returned object should be passed to the browser's navigator.credentials.get().
75
+ *
76
+ * @param allowCredentials - Optional list of credential IDs to allow
77
+ */
78
+ export declare function generateAuthenticationChallenge(allowCredentials?: {
79
+ id: string;
80
+ transports?: string[];
81
+ }[]): Promise<PublicKeyCredentialRequestOptionsJSON>;
82
+ /**
83
+ * Verify a WebAuthn authentication response and update the credential counter.
84
+ *
85
+ * @param response - The AuthenticationResponseJSON from the browser
86
+ * @param credential - The stored credential (id, publicKey, counter)
87
+ * @param expectedChallenge - The challenge from generateAuthenticationChallenge
88
+ * @param expectedOrigin - Override origin (defaults to config.origin)
89
+ * @returns Verification result with new counter value
90
+ */
91
+ export declare function verifyAuthentication(response: AuthenticationResponseJSON, credential: WebAuthnCredential, expectedChallenge: string, expectedOrigin?: string | string[]): Promise<{
92
+ verified: boolean;
93
+ newCounter: number;
94
+ }>;
95
+ /**
96
+ * List all passkeys for a user (safe for client — excludes publicKey and counter).
97
+ */
98
+ export declare function listPasskeys(userId: string): Promise<{
99
+ id: string;
100
+ credentialId: string;
101
+ deviceName: string | null;
102
+ backedUp: boolean;
103
+ createdAt: Date;
104
+ lastUsedAt: Date | null;
105
+ }[]>;
106
+ /**
107
+ * Delete a passkey. Blocks deletion if it's the user's last sign-in method.
108
+ *
109
+ * @param userId - User's database ID
110
+ * @param passkeyId - The passkey record ID to delete
111
+ * @throws If this is the user's only sign-in method
112
+ */
113
+ export declare function deletePasskey(userId: string, passkeyId: string): Promise<void>;
114
+ /**
115
+ * Rename a passkey's device name.
116
+ *
117
+ * @param userId - User's database ID
118
+ * @param passkeyId - The passkey record ID to rename
119
+ * @param name - New friendly name
120
+ */
121
+ export declare function renamePasskey(userId: string, passkeyId: string, name: string): Promise<void>;
122
+ /**
123
+ * Count a user's sign-in credentials (passkeys + password).
124
+ *
125
+ * @param userId - User's database ID
126
+ * @returns Passkey count and whether user has a password set
127
+ */
128
+ export declare function countUserCredentials(userId: string): Promise<{
129
+ passkeyCount: number;
130
+ hasPassword: boolean;
131
+ }>;
132
+ //# sourceMappingURL=passkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkey.d.ts","sourceRoot":"","sources":["../../src/server/passkey.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,257 @@
1
+ /**
2
+ * WebAuthn Passkey Module
3
+ *
4
+ * Implements passkey registration, authentication, and management using
5
+ * @simplewebauthn/server v13. Passkeys enable passwordless authentication
6
+ * via biometrics, security keys, or platform authenticators.
7
+ *
8
+ * @see https://simplewebauthn.dev/
9
+ */
10
+ import crypto from 'node:crypto';
11
+ import { getClient } from '@revealui/db/client';
12
+ import { passkeys, users } from '@revealui/db/schema';
13
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
14
+ import { and, count, eq } from 'drizzle-orm';
15
+ const DEFAULT_CONFIG = {
16
+ maxPasskeysPerUser: 10,
17
+ challengeTtlMs: 5 * 60 * 1000,
18
+ rpId: 'localhost',
19
+ rpName: 'RevealUI',
20
+ origin: 'http://localhost:4000',
21
+ };
22
+ let config = { ...DEFAULT_CONFIG };
23
+ export function configurePasskey(overrides) {
24
+ config = { ...DEFAULT_CONFIG, ...overrides };
25
+ }
26
+ export function resetPasskeyConfig() {
27
+ config = { ...DEFAULT_CONFIG };
28
+ }
29
+ // =============================================================================
30
+ // Registration
31
+ // =============================================================================
32
+ /**
33
+ * Generate WebAuthn registration options for a user.
34
+ *
35
+ * The returned object should be passed to the browser's navigator.credentials.create().
36
+ * The challenge is embedded in the options and should be stored server-side
37
+ * for verification.
38
+ *
39
+ * @param userId - User's database ID
40
+ * @param userEmail - User's email (used as userName in WebAuthn)
41
+ * @param existingCredentialIds - Credential IDs to exclude (prevent re-registration)
42
+ */
43
+ export async function generateRegistrationChallenge(userId, userEmail, existingCredentialIds) {
44
+ const excludeCredentials = existingCredentialIds?.map((id) => ({
45
+ id,
46
+ }));
47
+ // Encode userId as Uint8Array for WebAuthn userID field
48
+ const userIdBytes = new TextEncoder().encode(userId);
49
+ const options = await generateRegistrationOptions({
50
+ rpName: config.rpName,
51
+ rpID: config.rpId,
52
+ userName: userEmail,
53
+ userID: userIdBytes,
54
+ userDisplayName: userEmail,
55
+ excludeCredentials,
56
+ authenticatorSelection: {
57
+ residentKey: 'preferred',
58
+ userVerification: 'preferred',
59
+ },
60
+ timeout: config.challengeTtlMs,
61
+ });
62
+ return options;
63
+ }
64
+ /**
65
+ * Verify a WebAuthn registration response from the browser.
66
+ *
67
+ * @param response - The RegistrationResponseJSON from the browser
68
+ * @param expectedChallenge - The challenge from generateRegistrationChallenge
69
+ * @param expectedOrigin - Override origin (defaults to config.origin)
70
+ * @returns Verified registration data including credential info
71
+ * @throws If verification fails
72
+ */
73
+ export async function verifyRegistration(response, expectedChallenge, expectedOrigin) {
74
+ const verification = await verifyRegistrationResponse({
75
+ response,
76
+ expectedChallenge,
77
+ expectedOrigin: expectedOrigin ?? config.origin,
78
+ expectedRPID: config.rpId,
79
+ });
80
+ if (!(verification.verified && verification.registrationInfo)) {
81
+ throw new Error('Passkey registration verification failed');
82
+ }
83
+ return verification;
84
+ }
85
+ // =============================================================================
86
+ // Credential Storage
87
+ // =============================================================================
88
+ /**
89
+ * Store a verified passkey credential in the database.
90
+ *
91
+ * Enforces the per-user passkey limit before insertion.
92
+ *
93
+ * @param userId - User's database ID
94
+ * @param credential - Verified credential from verifyRegistration
95
+ * @param deviceName - Optional user-friendly name (e.g., "MacBook Pro Touch ID")
96
+ * @returns The stored passkey record
97
+ */
98
+ export async function storePasskey(userId, credential, deviceName) {
99
+ const db = getClient();
100
+ // Enforce per-user passkey limit
101
+ const [result] = await db
102
+ .select({ total: count() })
103
+ .from(passkeys)
104
+ .where(eq(passkeys.userId, userId));
105
+ const currentCount = result?.total ?? 0;
106
+ if (currentCount >= config.maxPasskeysPerUser) {
107
+ throw new Error(`Maximum of ${config.maxPasskeysPerUser} passkeys per user reached`);
108
+ }
109
+ const id = crypto.randomUUID();
110
+ const now = new Date();
111
+ await db.insert(passkeys).values({
112
+ id,
113
+ userId,
114
+ credentialId: credential.id,
115
+ publicKey: Buffer.from(credential.publicKey),
116
+ counter: credential.counter,
117
+ transports: credential.transports ?? null,
118
+ aaguid: credential.aaguid ?? null,
119
+ deviceName: deviceName ?? null,
120
+ backedUp: credential.backedUp ?? false,
121
+ createdAt: now,
122
+ });
123
+ return {
124
+ id,
125
+ userId,
126
+ credentialId: credential.id,
127
+ deviceName: deviceName ?? null,
128
+ createdAt: now,
129
+ };
130
+ }
131
+ // =============================================================================
132
+ // Authentication
133
+ // =============================================================================
134
+ /**
135
+ * Generate WebAuthn authentication options.
136
+ *
137
+ * The returned object should be passed to the browser's navigator.credentials.get().
138
+ *
139
+ * @param allowCredentials - Optional list of credential IDs to allow
140
+ */
141
+ export async function generateAuthenticationChallenge(allowCredentials) {
142
+ const options = await generateAuthenticationOptions({
143
+ rpID: config.rpId,
144
+ allowCredentials: allowCredentials?.map((cred) => ({
145
+ id: cred.id,
146
+ transports: cred.transports,
147
+ })),
148
+ timeout: config.challengeTtlMs,
149
+ userVerification: 'preferred',
150
+ });
151
+ return options;
152
+ }
153
+ /**
154
+ * Verify a WebAuthn authentication response and update the credential counter.
155
+ *
156
+ * @param response - The AuthenticationResponseJSON from the browser
157
+ * @param credential - The stored credential (id, publicKey, counter)
158
+ * @param expectedChallenge - The challenge from generateAuthenticationChallenge
159
+ * @param expectedOrigin - Override origin (defaults to config.origin)
160
+ * @returns Verification result with new counter value
161
+ */
162
+ export async function verifyAuthentication(response, credential, expectedChallenge, expectedOrigin) {
163
+ const verification = await verifyAuthenticationResponse({
164
+ response,
165
+ expectedChallenge,
166
+ expectedOrigin: expectedOrigin ?? config.origin,
167
+ expectedRPID: [config.rpId],
168
+ credential,
169
+ });
170
+ if (verification.verified) {
171
+ // Update counter and lastUsedAt in the database
172
+ const db = getClient();
173
+ await db
174
+ .update(passkeys)
175
+ .set({
176
+ counter: verification.authenticationInfo.newCounter,
177
+ lastUsedAt: new Date(),
178
+ })
179
+ .where(eq(passkeys.credentialId, credential.id));
180
+ }
181
+ return {
182
+ verified: verification.verified,
183
+ newCounter: verification.authenticationInfo.newCounter,
184
+ };
185
+ }
186
+ // =============================================================================
187
+ // Management
188
+ // =============================================================================
189
+ /**
190
+ * List all passkeys for a user (safe for client — excludes publicKey and counter).
191
+ */
192
+ export async function listPasskeys(userId) {
193
+ const db = getClient();
194
+ const rows = await db
195
+ .select({
196
+ id: passkeys.id,
197
+ credentialId: passkeys.credentialId,
198
+ deviceName: passkeys.deviceName,
199
+ backedUp: passkeys.backedUp,
200
+ createdAt: passkeys.createdAt,
201
+ lastUsedAt: passkeys.lastUsedAt,
202
+ })
203
+ .from(passkeys)
204
+ .where(eq(passkeys.userId, userId));
205
+ return rows;
206
+ }
207
+ /**
208
+ * Delete a passkey. Blocks deletion if it's the user's last sign-in method.
209
+ *
210
+ * @param userId - User's database ID
211
+ * @param passkeyId - The passkey record ID to delete
212
+ * @throws If this is the user's only sign-in method
213
+ */
214
+ export async function deletePasskey(userId, passkeyId) {
215
+ const { passkeyCount, hasPassword } = await countUserCredentials(userId);
216
+ if (passkeyCount <= 1 && !hasPassword) {
217
+ throw new Error('Cannot delete last sign-in method');
218
+ }
219
+ const db = getClient();
220
+ await db.delete(passkeys).where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)));
221
+ }
222
+ /**
223
+ * Rename a passkey's device name.
224
+ *
225
+ * @param userId - User's database ID
226
+ * @param passkeyId - The passkey record ID to rename
227
+ * @param name - New friendly name
228
+ */
229
+ export async function renamePasskey(userId, passkeyId, name) {
230
+ const db = getClient();
231
+ await db
232
+ .update(passkeys)
233
+ .set({ deviceName: name })
234
+ .where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId)));
235
+ }
236
+ /**
237
+ * Count a user's sign-in credentials (passkeys + password).
238
+ *
239
+ * @param userId - User's database ID
240
+ * @returns Passkey count and whether user has a password set
241
+ */
242
+ export async function countUserCredentials(userId) {
243
+ const db = getClient();
244
+ const [passkeyResult] = await db
245
+ .select({ total: count() })
246
+ .from(passkeys)
247
+ .where(eq(passkeys.userId, userId));
248
+ const [userResult] = await db
249
+ .select({ password: users.password })
250
+ .from(users)
251
+ .where(eq(users.id, userId))
252
+ .limit(1);
253
+ return {
254
+ passkeyCount: passkeyResult?.total ?? 0,
255
+ hasPassword: userResult?.password != null,
256
+ };
257
+ }
@@ -43,6 +43,21 @@ export declare function validatePasswordResetToken(tokenId: string, token: strin
43
43
  * @returns Success result
44
44
  */
45
45
  export declare function resetPasswordWithToken(tokenId: string, token: string, newPassword: string): Promise<PasswordResetResult>;
46
+ export interface ChangePasswordResult {
47
+ success: boolean;
48
+ error?: string;
49
+ }
50
+ /**
51
+ * Change password for an authenticated user.
52
+ *
53
+ * Verifies the current password before updating. Does not touch sessions —
54
+ * the caller is responsible for revoking other sessions if desired.
55
+ *
56
+ * @param userId - Authenticated user ID
57
+ * @param currentPassword - Plain-text current password to verify
58
+ * @param newPassword - Plain-text new password to hash and store
59
+ */
60
+ export declare function changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
46
61
  /**
47
62
  * Invalidates a password reset token
48
63
  *
@@ -1 +1 @@
1
- {"version":3,"file":"password-reset.d.ts","sourceRoot":"","sources":["../../src/server/password-reset.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqE5F;AAED;;;;;;;;;GASG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqCxB;AAED;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA4E9B;AAED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChG"}
1
+ {"version":3,"file":"password-reset.d.ts","sourceRoot":"","sources":["../../src/server/password-reset.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAuBD;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAwE5F;AAED;;;;;;;;;GASG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqCxB;AAED;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CA4E9B;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,oBAAoB,CAAC,CAqC/B;AAED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqChG"}
@@ -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)
@@ -195,6 +198,46 @@ export async function resetPasswordWithToken(tokenId, token, newPassword) {
195
198
  };
196
199
  }
197
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
+ }
198
241
  /**
199
242
  * Invalidates a password reset token
200
243
  *
@@ -1 +1 @@
1
- {"version":3,"file":"password-validation.d.ts","sourceRoot":"","sources":["../../src/server/password-validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,wBAAwB,CAgCnF;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1E"}
1
+ {"version":3,"file":"password-validation.d.ts","sourceRoot":"","sources":["../../src/server/password-validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,wBAAwB,CAgCnF;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1E"}
@@ -1 +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,CAAA;AAE/C,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,CA4BrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA2C1E"}
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"}
@@ -31,7 +31,15 @@ export async function exchangeCode(code, redirectUri) {
31
31
  }),
32
32
  });
33
33
  if (!response.ok) {
34
- throw new Error(`GitHub token exchange failed: ${response.status}`);
34
+ let detail = '';
35
+ try {
36
+ const err = (await response.json());
37
+ detail = err.error_description ?? err.error ?? '';
38
+ }
39
+ catch {
40
+ // Response body not JSON — use status only
41
+ }
42
+ throw new Error(`GitHub token exchange failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
35
43
  }
36
44
  const data = (await response.json());
37
45
  if (data.error) {
@@ -51,7 +59,15 @@ export async function fetchUser(accessToken) {
51
59
  };
52
60
  const userResponse = await fetch('https://api.github.com/user', { headers });
53
61
  if (!userResponse.ok) {
54
- throw new Error(`GitHub user fetch failed: ${userResponse.status}`);
62
+ let detail = '';
63
+ try {
64
+ const err = (await userResponse.json());
65
+ detail = err.message ?? '';
66
+ }
67
+ catch {
68
+ // Response body not JSON
69
+ }
70
+ throw new Error(`GitHub user fetch failed: ${userResponse.status}${detail ? ` — ${detail}` : ''}`);
55
71
  }
56
72
  const user = (await userResponse.json());
57
73
  let email = user.email ?? null;
@@ -1 +1 @@
1
- {"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../../src/server/providers/google.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAsBrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAuB1E"}
1
+ {"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../../src/server/providers/google.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;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,CASzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA+BrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAgC1E"}
@@ -27,7 +27,15 @@ export async function exchangeCode(code, redirectUri) {
27
27
  }),
28
28
  });
29
29
  if (!response.ok) {
30
- throw new Error(`Google token exchange failed: ${response.status}`);
30
+ let detail = '';
31
+ try {
32
+ const err = (await response.json());
33
+ detail = err.error_description ?? err.error ?? '';
34
+ }
35
+ catch {
36
+ // Response body not JSON — use status only
37
+ }
38
+ throw new Error(`Google token exchange failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
31
39
  }
32
40
  const data = (await response.json());
33
41
  if (!data.access_token || typeof data.access_token !== 'string') {
@@ -41,7 +49,15 @@ export async function fetchUser(accessToken) {
41
49
  headers: { Authorization: `Bearer ${accessToken}` },
42
50
  });
43
51
  if (!response.ok) {
44
- throw new Error(`Google userinfo fetch failed: ${response.status}`);
52
+ let detail = '';
53
+ try {
54
+ const err = (await response.json());
55
+ detail = err.error?.message ?? '';
56
+ }
57
+ catch {
58
+ // Response body not JSON
59
+ }
60
+ throw new Error(`Google userinfo fetch failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
45
61
  }
46
62
  const data = (await response.json());
47
63
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"vercel.d.ts","sourceRoot":"","sources":["../../../src/server/providers/vercel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAMzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA2B1E"}
1
+ {"version":3,"file":"vercel.d.ts","sourceRoot":"","sources":["../../../src/server/providers/vercel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;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,CAMzF;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAkC1E"}
@@ -23,7 +23,15 @@ export async function exchangeCode(code, redirectUri) {
23
23
  }),
24
24
  });
25
25
  if (!response.ok) {
26
- throw new Error(`Vercel token exchange failed: ${response.status}`);
26
+ let detail = '';
27
+ try {
28
+ const err = (await response.json());
29
+ detail = err.error_description ?? err.error ?? '';
30
+ }
31
+ catch {
32
+ // Response body not JSON — use status only
33
+ }
34
+ throw new Error(`Vercel token exchange failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
27
35
  }
28
36
  const data = (await response.json());
29
37
  return data.access_token;
@@ -34,7 +42,15 @@ export async function fetchUser(accessToken) {
34
42
  headers: { Authorization: `Bearer ${accessToken}` },
35
43
  });
36
44
  if (!response.ok) {
37
- throw new Error(`Vercel user fetch failed: ${response.status}`);
45
+ let detail = '';
46
+ try {
47
+ const err = (await response.json());
48
+ detail = err.error?.message ?? '';
49
+ }
50
+ catch {
51
+ // Response body not JSON
52
+ }
53
+ throw new Error(`Vercel user fetch failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
38
54
  }
39
55
  const data = (await response.json());
40
56
  const u = data.user;
@@ -2,7 +2,7 @@
2
2
  * Rate Limiting Utilities
3
3
  *
4
4
  * Rate limiting for authentication endpoints using storage abstraction.
5
- * Supports in-memory (dev), Redis (production), or database (fallback).
5
+ * Supports in-memory (dev) or database (production) backends.
6
6
  */
7
7
  /**
8
8
  * Rate limit configuration
@@ -12,6 +12,15 @@ export interface RateLimitConfig {
12
12
  windowMs: number;
13
13
  blockDurationMs?: number;
14
14
  }
15
+ /**
16
+ * Override default rate limit configuration globally.
17
+ * Per-call config parameters still take precedence.
18
+ */
19
+ export declare function configureRateLimit(overrides: Partial<RateLimitConfig>): void;
20
+ /**
21
+ * Reset rate limit configuration to defaults (for testing).
22
+ */
23
+ export declare function resetRateLimitConfig(): void;
15
24
  /**
16
25
  * Checks if an action should be rate limited
17
26
  *
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/server/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAqCD;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,eAAgC,GACvC,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAuDnE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI/D;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,eAAgC,GACvC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAqBhE"}
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/server/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAUD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI,CAE5E;AAED;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AA+BD;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,eAA8B,GACrC,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CA4DnE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI/D;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,eAA8B,GACrC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAqBhE"}