@revealui/auth 0.0.0-canary-20260409021642
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/LICENSE +22 -0
- package/README.md +115 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +12 -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 +32 -0
- package/dist/react/useSession.d.ts.map +1 -0
- package/dist/react/useSession.js +109 -0
- package/dist/react/useSignIn.d.ts +47 -0
- package/dist/react/useSignIn.d.ts.map +1 -0
- package/dist/react/useSignIn.js +121 -0
- package/dist/react/useSignOut.d.ts +30 -0
- package/dist/react/useSignOut.d.ts.map +1 -0
- package/dist/react/useSignOut.js +57 -0
- package/dist/react/useSignUp.d.ts +43 -0
- package/dist/react/useSignUp.d.ts.map +1 -0
- package/dist/react/useSignUp.js +106 -0
- package/dist/server/audit-bridge.d.ts +69 -0
- package/dist/server/audit-bridge.d.ts.map +1 -0
- package/dist/server/audit-bridge.js +169 -0
- package/dist/server/auth.d.ts +46 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +393 -0
- package/dist/server/brute-force.d.ts +53 -0
- package/dist/server/brute-force.d.ts.map +1 -0
- package/dist/server/brute-force.js +163 -0
- package/dist/server/errors.d.ts +28 -0
- package/dist/server/errors.d.ts.map +1 -0
- package/dist/server/errors.js +51 -0
- package/dist/server/index.d.ts +30 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +23 -0
- 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-enforcement.d.ts +76 -0
- package/dist/server/mfa-enforcement.d.ts.map +1 -0
- package/dist/server/mfa-enforcement.js +76 -0
- package/dist/server/mfa.d.ts +90 -0
- package/dist/server/mfa.d.ts.map +1 -0
- package/dist/server/mfa.js +304 -0
- package/dist/server/oauth.d.ts +97 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +370 -0
- package/dist/server/passkey.d.ts +132 -0
- package/dist/server/passkey.d.ts.map +1 -0
- package/dist/server/passkey.js +269 -0
- package/dist/server/password-reset.d.ts +72 -0
- package/dist/server/password-reset.d.ts.map +1 -0
- package/dist/server/password-reset.js +289 -0
- package/dist/server/password-validation.d.ts +25 -0
- package/dist/server/password-validation.d.ts.map +1 -0
- package/dist/server/password-validation.js +52 -0
- 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 +86 -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 +68 -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 +68 -0
- package/dist/server/rate-limit.d.ts +54 -0
- package/dist/server/rate-limit.d.ts.map +1 -0
- package/dist/server/rate-limit.js +148 -0
- package/dist/server/session.d.ts +111 -0
- package/dist/server/session.d.ts.map +1 -0
- package/dist/server/session.js +416 -0
- 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 +26 -0
- package/dist/server/storage/database.d.ts.map +1 -0
- package/dist/server/storage/database.js +110 -0
- package/dist/server/storage/in-memory.d.ts +28 -0
- package/dist/server/storage/in-memory.d.ts.map +1 -0
- package/dist/server/storage/in-memory.js +80 -0
- package/dist/server/storage/index.d.ts +34 -0
- package/dist/server/storage/index.d.ts.map +1 -0
- package/dist/server/storage/index.js +94 -0
- package/dist/server/storage/interface.d.ts +46 -0
- package/dist/server/storage/interface.d.ts.map +1 -0
- package/dist/server/storage/interface.js +6 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/utils/database.d.ts +53 -0
- package/dist/utils/database.d.ts.map +1 -0
- package/dist/utils/database.js +151 -0
- package/dist/utils/token.d.ts +30 -0
- package/dist/utils/token.d.ts.map +1 -0
- package/dist/utils/token.js +39 -0
- package/package.json +85 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Functions (Server-side)
|
|
3
|
+
*
|
|
4
|
+
* Sign in and sign up functionality with password hashing.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import { logger } from '@revealui/core/observability/logger';
|
|
8
|
+
import { getClient } from '@revealui/db/client';
|
|
9
|
+
import { oauthAccounts, users } from '@revealui/db/schema';
|
|
10
|
+
import bcrypt from 'bcryptjs';
|
|
11
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
12
|
+
import { clearFailedAttempts, isAccountLocked, recordFailedAttempt } from './brute-force.js';
|
|
13
|
+
import { validatePasswordStrength } from './password-validation.js';
|
|
14
|
+
import { checkRateLimit } from './rate-limit.js';
|
|
15
|
+
import { createSession, rotateSession } from './session.js';
|
|
16
|
+
/** Grace period after signup during which unverified users can still sign in (24 hours) */
|
|
17
|
+
const EMAIL_VERIFICATION_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* Sign in with email and password
|
|
20
|
+
*
|
|
21
|
+
* @param email - User email
|
|
22
|
+
* @param password - User password
|
|
23
|
+
* @param options - Additional options (userAgent, ipAddress)
|
|
24
|
+
* @returns Sign in result with user and session token
|
|
25
|
+
*/
|
|
26
|
+
export async function signIn(email, password, options) {
|
|
27
|
+
try {
|
|
28
|
+
// Rate limiting by IP address
|
|
29
|
+
const ipKey = options?.ipAddress || 'unknown';
|
|
30
|
+
const rateLimit = await checkRateLimit(`signin:${ipKey}`);
|
|
31
|
+
if (!rateLimit.allowed) {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
reason: 'rate_limited',
|
|
35
|
+
error: 'Too many login attempts. Please try again later.',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Brute force protection by email
|
|
39
|
+
const bruteForceCheck = await isAccountLocked(email);
|
|
40
|
+
if (bruteForceCheck.locked) {
|
|
41
|
+
const lockMinutes = bruteForceCheck.lockUntil
|
|
42
|
+
? Math.ceil((bruteForceCheck.lockUntil - Date.now()) / (60 * 1000))
|
|
43
|
+
: 30;
|
|
44
|
+
return {
|
|
45
|
+
success: false,
|
|
46
|
+
reason: 'account_locked',
|
|
47
|
+
error: `Account locked due to too many failed attempts. Please try again in ${lockMinutes} minutes.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
let db;
|
|
51
|
+
try {
|
|
52
|
+
db = getClient();
|
|
53
|
+
}
|
|
54
|
+
catch (clientError) {
|
|
55
|
+
logger.error('Error getting database client', clientError instanceof Error ? clientError : undefined, {
|
|
56
|
+
message: clientError instanceof Error ? clientError.message : String(clientError),
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
reason: 'database_error',
|
|
61
|
+
error: 'Database connection failed',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Find user by email
|
|
65
|
+
let user;
|
|
66
|
+
try {
|
|
67
|
+
const result = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(users)
|
|
70
|
+
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
|
71
|
+
.limit(1);
|
|
72
|
+
user = result[0];
|
|
73
|
+
}
|
|
74
|
+
catch (dbError) {
|
|
75
|
+
logger.error('Error querying user', dbError instanceof Error ? dbError : undefined, {
|
|
76
|
+
message: dbError instanceof Error ? dbError.message : String(dbError),
|
|
77
|
+
name: dbError instanceof Error ? dbError.name : 'unknown',
|
|
78
|
+
stack: dbError instanceof Error ? dbError.stack : undefined,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
reason: 'database_error',
|
|
83
|
+
error: 'Database error',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Always return same error message to prevent user enumeration
|
|
87
|
+
const invalidCredentialsMessage = 'Invalid email or password';
|
|
88
|
+
if (!user) {
|
|
89
|
+
await recordFailedAttempt(email);
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
reason: 'invalid_credentials',
|
|
93
|
+
error: invalidCredentialsMessage,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Check if user has a password (not OAuth-only user)
|
|
97
|
+
if (!user.password) {
|
|
98
|
+
await recordFailedAttempt(email);
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
reason: 'invalid_credentials',
|
|
102
|
+
error: invalidCredentialsMessage,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Verify password hash
|
|
106
|
+
let isValid;
|
|
107
|
+
try {
|
|
108
|
+
isValid = await bcrypt.compare(password, user.password);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
logger.error('Error comparing password');
|
|
112
|
+
await recordFailedAttempt(email);
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
reason: 'invalid_credentials',
|
|
116
|
+
error: invalidCredentialsMessage,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!isValid) {
|
|
120
|
+
await recordFailedAttempt(email);
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
reason: 'invalid_credentials',
|
|
124
|
+
error: invalidCredentialsMessage,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Successful login - clear failed attempts
|
|
128
|
+
await clearFailedAttempts(email);
|
|
129
|
+
// Check email verification (with grace period for new accounts)
|
|
130
|
+
if (!user.emailVerified) {
|
|
131
|
+
const accountAge = Date.now() - user.createdAt.getTime();
|
|
132
|
+
if (accountAge > EMAIL_VERIFICATION_GRACE_PERIOD_MS) {
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
reason: 'email_not_verified',
|
|
136
|
+
error: 'Please verify your email address before signing in.',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Check if MFA is enabled — if so, return early and require TOTP verification
|
|
141
|
+
if (user.mfaEnabled) {
|
|
142
|
+
return {
|
|
143
|
+
success: true,
|
|
144
|
+
requiresMfa: true,
|
|
145
|
+
mfaUserId: user.id,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Rotate session: delete all existing sessions for this user, then create
|
|
149
|
+
// a fresh one. This prevents session fixation attacks where an attacker
|
|
150
|
+
// plants a session token that the victim later authenticates.
|
|
151
|
+
let token;
|
|
152
|
+
try {
|
|
153
|
+
const sessionResult = await rotateSession(user.id, {
|
|
154
|
+
userAgent: options?.userAgent || 'Unknown',
|
|
155
|
+
ipAddress: options?.ipAddress || 'Unknown',
|
|
156
|
+
});
|
|
157
|
+
token = sessionResult.token;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
logger.error('Error creating session');
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
reason: 'session_error',
|
|
164
|
+
error: 'Failed to create session',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
success: true,
|
|
169
|
+
user,
|
|
170
|
+
sessionToken: token,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
logger.error('Unexpected error in signIn');
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
reason: 'unexpected_error',
|
|
178
|
+
error: 'Unexpected error',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Check if a given email is allowed to sign up.
|
|
184
|
+
*
|
|
185
|
+
* Behavior:
|
|
186
|
+
* - If REVEALUI_SIGNUP_OPEN is 'true', all emails are allowed.
|
|
187
|
+
* - If REVEALUI_SIGNUP_WHITELIST is set (comma-separated emails), only listed emails pass.
|
|
188
|
+
* - If neither env var is set, signups are open (backwards compatible).
|
|
189
|
+
*
|
|
190
|
+
* @param email - Lowercase, trimmed email to check
|
|
191
|
+
* @returns true if signup is allowed
|
|
192
|
+
*/
|
|
193
|
+
export function isSignupAllowed(email) {
|
|
194
|
+
const signupOpen = process.env.REVEALUI_SIGNUP_OPEN;
|
|
195
|
+
if (signupOpen === 'true') {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const whitelist = process.env.REVEALUI_SIGNUP_WHITELIST;
|
|
199
|
+
if (!whitelist) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
const allowedEmails = whitelist
|
|
203
|
+
.split(',')
|
|
204
|
+
.map((e) => e.trim().toLowerCase())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
return allowedEmails.includes(email.toLowerCase());
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Sign up a new user
|
|
210
|
+
*
|
|
211
|
+
* @param email - User email
|
|
212
|
+
* @param password - User password
|
|
213
|
+
* @param name - User name
|
|
214
|
+
* @param options - Additional options
|
|
215
|
+
* @returns Sign up result with user and session token
|
|
216
|
+
*/
|
|
217
|
+
export async function signUp(email, password, name, options) {
|
|
218
|
+
try {
|
|
219
|
+
// Signup gating: check email whitelist before anything else
|
|
220
|
+
if (!isSignupAllowed(email)) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
error: 'Signups are currently restricted. Contact the administrator for access.',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// Rate limiting by IP address
|
|
227
|
+
const ipKey = options?.ipAddress || 'unknown';
|
|
228
|
+
const rateLimit = await checkRateLimit(`signup:${ipKey}`);
|
|
229
|
+
if (!rateLimit.allowed) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: 'Too many registration attempts. Please try again later.',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// Validate password strength
|
|
236
|
+
const passwordValidation = validatePasswordStrength(password);
|
|
237
|
+
if (!passwordValidation.valid) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: passwordValidation.errors.join('. '),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
let db;
|
|
244
|
+
try {
|
|
245
|
+
db = getClient();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
logger.error('Error getting database client');
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
error: 'Database connection failed',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// Check if user already exists (by email in users table or OAuth accounts).
|
|
255
|
+
// Both checks prevent account collision: a password signup must not collide
|
|
256
|
+
// with an existing OAuth identity for the same email address.
|
|
257
|
+
let existing;
|
|
258
|
+
try {
|
|
259
|
+
const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
260
|
+
existing = result[0];
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
logger.error('Error checking existing user');
|
|
264
|
+
return {
|
|
265
|
+
success: false,
|
|
266
|
+
error: 'Database error',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (existing) {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
error: 'Unable to create account',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// Block signup if an OAuth account already uses this email.
|
|
276
|
+
// Without this check, an attacker could create a password account
|
|
277
|
+
// for an email that was registered via OAuth, enabling account takeover.
|
|
278
|
+
try {
|
|
279
|
+
const [existingOAuth] = await db
|
|
280
|
+
.select({ id: oauthAccounts.id })
|
|
281
|
+
.from(oauthAccounts)
|
|
282
|
+
.where(eq(oauthAccounts.providerEmail, email))
|
|
283
|
+
.limit(1);
|
|
284
|
+
if (existingOAuth) {
|
|
285
|
+
return {
|
|
286
|
+
success: false,
|
|
287
|
+
error: 'Unable to create account',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
logger.error('Error checking OAuth accounts');
|
|
293
|
+
return {
|
|
294
|
+
success: false,
|
|
295
|
+
error: 'Database error',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// Hash password
|
|
299
|
+
let hashedPassword;
|
|
300
|
+
try {
|
|
301
|
+
hashedPassword = await bcrypt.hash(password, 12);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
logger.error('Error hashing password');
|
|
305
|
+
return {
|
|
306
|
+
success: false,
|
|
307
|
+
error: 'Failed to process password',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// Generate email verification token.
|
|
311
|
+
// Store the SHA-256 hash in the DB; send the raw token in the email link.
|
|
312
|
+
// A DB breach cannot be used to verify arbitrary emails without the raw token.
|
|
313
|
+
const rawEmailVerificationToken = randomBytes(32).toString('hex');
|
|
314
|
+
const emailVerificationToken = createHash('sha256')
|
|
315
|
+
.update(rawEmailVerificationToken)
|
|
316
|
+
.digest('hex');
|
|
317
|
+
const emailVerificationTokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
318
|
+
// Create user
|
|
319
|
+
let user;
|
|
320
|
+
try {
|
|
321
|
+
const result = await db
|
|
322
|
+
.insert(users)
|
|
323
|
+
.values({
|
|
324
|
+
id: crypto.randomUUID(),
|
|
325
|
+
email,
|
|
326
|
+
name,
|
|
327
|
+
password: hashedPassword,
|
|
328
|
+
emailVerified: false,
|
|
329
|
+
emailVerificationToken,
|
|
330
|
+
emailVerificationTokenExpiresAt,
|
|
331
|
+
tosAcceptedAt: options?.tosAcceptedAt ?? null,
|
|
332
|
+
tosVersion: options?.tosVersion ?? null,
|
|
333
|
+
})
|
|
334
|
+
.returning();
|
|
335
|
+
user = result[0];
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
logger.error('Error creating user');
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
error: 'Failed to create user',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (!user) {
|
|
345
|
+
return {
|
|
346
|
+
success: false,
|
|
347
|
+
error: 'User creation returned no result',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// Create session
|
|
351
|
+
let token;
|
|
352
|
+
try {
|
|
353
|
+
const sessionResult = await createSession(user.id, {
|
|
354
|
+
userAgent: options?.userAgent || 'Unknown',
|
|
355
|
+
ipAddress: options?.ipAddress || 'Unknown',
|
|
356
|
+
});
|
|
357
|
+
token = sessionResult.token;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
logger.error('Error creating session');
|
|
361
|
+
// Clean up orphaned user so the email isn't permanently locked out.
|
|
362
|
+
// Without this, a retry would fail with "Unable to create account"
|
|
363
|
+
// because the user row already exists but has no valid session.
|
|
364
|
+
try {
|
|
365
|
+
await db.delete(users).where(eq(users.id, user.id));
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
logger.error('Failed to clean up orphaned user after session creation failure', undefined, {
|
|
369
|
+
userId: user.id,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
success: false,
|
|
374
|
+
error: 'Failed to create session',
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
// Return the raw (unhashed) token so the caller can include it in the
|
|
378
|
+
// verification email link. The DB holds only the hash.
|
|
379
|
+
const userWithRawToken = { ...user, emailVerificationToken: rawEmailVerificationToken };
|
|
380
|
+
return {
|
|
381
|
+
success: true,
|
|
382
|
+
user: userWithRawToken,
|
|
383
|
+
sessionToken: token,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
logger.error('Unexpected error in signUp');
|
|
388
|
+
return {
|
|
389
|
+
success: false,
|
|
390
|
+
error: 'Unexpected error',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brute Force Protection
|
|
3
|
+
*
|
|
4
|
+
* Tracks failed login attempts and locks accounts after threshold.
|
|
5
|
+
* Uses storage abstraction (database or in-memory).
|
|
6
|
+
*/
|
|
7
|
+
export interface BruteForceConfig {
|
|
8
|
+
maxAttempts: number;
|
|
9
|
+
lockDurationMs: number;
|
|
10
|
+
windowMs: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Override default brute force configuration globally.
|
|
14
|
+
* Per-call config parameters still take precedence.
|
|
15
|
+
*/
|
|
16
|
+
export declare function configureBruteForce(overrides: Partial<BruteForceConfig>): void;
|
|
17
|
+
/**
|
|
18
|
+
* Reset brute force configuration to defaults (for testing).
|
|
19
|
+
*/
|
|
20
|
+
export declare function resetBruteForceConfig(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Records a failed login attempt
|
|
23
|
+
*
|
|
24
|
+
* @param email - User email
|
|
25
|
+
* @param config - Brute force configuration
|
|
26
|
+
*/
|
|
27
|
+
export declare function recordFailedAttempt(email: string, config?: BruteForceConfig): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Clears failed attempts for an email (on successful login)
|
|
30
|
+
*
|
|
31
|
+
* @param email - User email
|
|
32
|
+
*/
|
|
33
|
+
export declare function clearFailedAttempts(email: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Checks if account is locked due to brute force attempts
|
|
36
|
+
*
|
|
37
|
+
* @param email - User email
|
|
38
|
+
* @param config - Brute force configuration
|
|
39
|
+
* @returns Lock status
|
|
40
|
+
*/
|
|
41
|
+
export declare function isAccountLocked(email: string, config?: BruteForceConfig): Promise<{
|
|
42
|
+
locked: boolean;
|
|
43
|
+
lockUntil?: number;
|
|
44
|
+
attemptsRemaining: number;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Gets failed attempt count for an email
|
|
48
|
+
*
|
|
49
|
+
* @param email - User email
|
|
50
|
+
* @returns Failed attempt count
|
|
51
|
+
*/
|
|
52
|
+
export declare function getFailedAttemptCount(email: string): Promise<number>;
|
|
53
|
+
//# sourceMappingURL=brute-force.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"brute-force.d.ts","sourceRoot":"","sources":["../../src/server/brute-force.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAUH,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAUD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,IAAI,CAE9E;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AA+BD;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,gBAA+B,GACtC,OAAO,CAAC,IAAI,CAAC,CA0Cf;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAItE;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,GAAE,gBAA+B,GACtC,OAAO,CAAC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC,CA6C7E;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ1E"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brute Force Protection
|
|
3
|
+
*
|
|
4
|
+
* Tracks failed login attempts and locks accounts after threshold.
|
|
5
|
+
* Uses storage abstraction (database or in-memory).
|
|
6
|
+
*/
|
|
7
|
+
import { getStorage } from './storage/index.js';
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
maxAttempts: 5,
|
|
10
|
+
lockDurationMs: 30 * 60 * 1000, // 30 minutes
|
|
11
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
12
|
+
};
|
|
13
|
+
let globalConfig = { ...DEFAULT_CONFIG };
|
|
14
|
+
/**
|
|
15
|
+
* Override default brute force configuration globally.
|
|
16
|
+
* Per-call config parameters still take precedence.
|
|
17
|
+
*/
|
|
18
|
+
export function configureBruteForce(overrides) {
|
|
19
|
+
globalConfig = { ...DEFAULT_CONFIG, ...overrides };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Reset brute force configuration to defaults (for testing).
|
|
23
|
+
*/
|
|
24
|
+
export function resetBruteForceConfig() {
|
|
25
|
+
globalConfig = { ...DEFAULT_CONFIG };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Serialize failed attempt entry
|
|
29
|
+
*/
|
|
30
|
+
function serializeEntry(entry) {
|
|
31
|
+
return JSON.stringify(entry);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deserialize failed attempt entry
|
|
35
|
+
*/
|
|
36
|
+
function deserializeEntry(data) {
|
|
37
|
+
if (!data) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(data);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get storage key for failed attempts
|
|
49
|
+
*/
|
|
50
|
+
function getStorageKey(email) {
|
|
51
|
+
return `brute_force:${email}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Records a failed login attempt
|
|
55
|
+
*
|
|
56
|
+
* @param email - User email
|
|
57
|
+
* @param config - Brute force configuration
|
|
58
|
+
*/
|
|
59
|
+
export async function recordFailedAttempt(email, config = globalConfig) {
|
|
60
|
+
const storage = getStorage();
|
|
61
|
+
const storageKey = getStorageKey(email);
|
|
62
|
+
const updater = (entryData) => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const entry = deserializeEntry(entryData) || { count: 0, windowStart: now };
|
|
65
|
+
// Reset if lock expired
|
|
66
|
+
if (entry.lockUntil && entry.lockUntil < now) {
|
|
67
|
+
entry.count = 0;
|
|
68
|
+
entry.lockUntil = undefined;
|
|
69
|
+
entry.windowStart = now;
|
|
70
|
+
}
|
|
71
|
+
// Reset if window expired
|
|
72
|
+
if (now - entry.windowStart > config.windowMs) {
|
|
73
|
+
entry.count = 0;
|
|
74
|
+
entry.windowStart = now;
|
|
75
|
+
}
|
|
76
|
+
entry.count++;
|
|
77
|
+
// Lock account if threshold reached
|
|
78
|
+
if (entry.count >= config.maxAttempts) {
|
|
79
|
+
entry.lockUntil = now + config.lockDurationMs;
|
|
80
|
+
}
|
|
81
|
+
const ttlSeconds = Math.ceil(Math.max(config.windowMs, entry.lockUntil ? entry.lockUntil - now : config.windowMs) / 1000);
|
|
82
|
+
return { value: serializeEntry(entry), ttlSeconds };
|
|
83
|
+
};
|
|
84
|
+
if (storage.atomicUpdate) {
|
|
85
|
+
await storage.atomicUpdate(storageKey, updater);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// Fallback for storage backends that don't support atomic updates
|
|
89
|
+
const existing = await storage.get(storageKey);
|
|
90
|
+
const { value, ttlSeconds } = updater(existing);
|
|
91
|
+
await storage.set(storageKey, value, ttlSeconds);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Clears failed attempts for an email (on successful login)
|
|
96
|
+
*
|
|
97
|
+
* @param email - User email
|
|
98
|
+
*/
|
|
99
|
+
export async function clearFailedAttempts(email) {
|
|
100
|
+
const storage = getStorage();
|
|
101
|
+
const storageKey = getStorageKey(email);
|
|
102
|
+
await storage.del(storageKey);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Checks if account is locked due to brute force attempts
|
|
106
|
+
*
|
|
107
|
+
* @param email - User email
|
|
108
|
+
* @param config - Brute force configuration
|
|
109
|
+
* @returns Lock status
|
|
110
|
+
*/
|
|
111
|
+
export async function isAccountLocked(email, config = globalConfig) {
|
|
112
|
+
const storage = getStorage();
|
|
113
|
+
const storageKey = getStorageKey(email);
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const entryData = await storage.get(storageKey);
|
|
116
|
+
const entry = deserializeEntry(entryData);
|
|
117
|
+
if (!entry) {
|
|
118
|
+
return {
|
|
119
|
+
locked: false,
|
|
120
|
+
attemptsRemaining: config.maxAttempts,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Check if lock expired
|
|
124
|
+
if (entry.lockUntil && entry.lockUntil < now) {
|
|
125
|
+
await storage.del(storageKey);
|
|
126
|
+
return {
|
|
127
|
+
locked: false,
|
|
128
|
+
attemptsRemaining: config.maxAttempts,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Check if window expired
|
|
132
|
+
if (now - entry.windowStart > config.windowMs) {
|
|
133
|
+
await storage.del(storageKey);
|
|
134
|
+
return {
|
|
135
|
+
locked: false,
|
|
136
|
+
attemptsRemaining: config.maxAttempts,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (entry.lockUntil) {
|
|
140
|
+
return {
|
|
141
|
+
locked: true,
|
|
142
|
+
lockUntil: entry.lockUntil,
|
|
143
|
+
attemptsRemaining: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
locked: false,
|
|
148
|
+
attemptsRemaining: Math.max(0, config.maxAttempts - entry.count),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Gets failed attempt count for an email
|
|
153
|
+
*
|
|
154
|
+
* @param email - User email
|
|
155
|
+
* @returns Failed attempt count
|
|
156
|
+
*/
|
|
157
|
+
export async function getFailedAttemptCount(email) {
|
|
158
|
+
const storage = getStorage();
|
|
159
|
+
const storageKey = getStorageKey(email);
|
|
160
|
+
const entryData = await storage.get(storageKey);
|
|
161
|
+
const entry = deserializeEntry(entryData);
|
|
162
|
+
return entry?.count || 0;
|
|
163
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Error Types
|
|
3
|
+
*
|
|
4
|
+
* Custom error classes for authentication operations.
|
|
5
|
+
*/
|
|
6
|
+
export declare class AuthError extends Error {
|
|
7
|
+
code: string;
|
|
8
|
+
statusCode: number;
|
|
9
|
+
constructor(message: string, code: string, statusCode?: number);
|
|
10
|
+
}
|
|
11
|
+
export declare class SessionError extends AuthError {
|
|
12
|
+
constructor(message?: string, statusCode?: number);
|
|
13
|
+
}
|
|
14
|
+
export declare class AuthenticationError extends AuthError {
|
|
15
|
+
constructor(message?: string, statusCode?: number);
|
|
16
|
+
}
|
|
17
|
+
export declare class DatabaseError extends AuthError {
|
|
18
|
+
originalError?: Error;
|
|
19
|
+
constructor(message?: string, originalError?: unknown);
|
|
20
|
+
}
|
|
21
|
+
export declare class TokenError extends AuthError {
|
|
22
|
+
constructor(message?: string, statusCode?: number);
|
|
23
|
+
}
|
|
24
|
+
export declare class OAuthAccountConflictError extends AuthError {
|
|
25
|
+
email: string;
|
|
26
|
+
constructor(email: string);
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/server/errors.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,qBAAa,SAAU,SAAQ,KAAK;IAGzB,IAAI,EAAE,MAAM;IACZ,UAAU,EAAE,MAAM;gBAFzB,OAAO,EAAE,MAAM,EACR,IAAI,EAAE,MAAM,EACZ,UAAU,GAAE,MAAY;CAKlC;AAED,qBAAa,YAAa,SAAQ,SAAS;gBAC7B,OAAO,GAAE,MAAwB,EAAE,UAAU,GAAE,MAAY;CAIxE;AAED,qBAAa,mBAAoB,SAAQ,SAAS;gBACpC,OAAO,GAAE,MAAgC,EAAE,UAAU,GAAE,MAAY;CAIhF;AAED,qBAAa,aAAc,SAAQ,SAAS;IACnC,aAAa,CAAC,EAAE,KAAK,CAAC;gBAEjB,OAAO,GAAE,MAAyB,EAAE,aAAa,CAAC,EAAE,OAAO;CAOxE;AAED,qBAAa,UAAW,SAAQ,SAAS;gBAC3B,OAAO,GAAE,MAAsB,EAAE,UAAU,GAAE,MAAY;CAItE;AAED,qBAAa,yBAA0B,SAAQ,SAAS;IAC/C,KAAK,EAAE,MAAM,CAAC;gBAET,KAAK,EAAE,MAAM;CAS1B"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication Error Types
|
|
3
|
+
*
|
|
4
|
+
* Custom error classes for authentication operations.
|
|
5
|
+
*/
|
|
6
|
+
export class AuthError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
statusCode;
|
|
9
|
+
constructor(message, code, statusCode = 500) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.statusCode = statusCode;
|
|
13
|
+
this.name = 'AuthError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class SessionError extends AuthError {
|
|
17
|
+
constructor(message = 'Session error', statusCode = 401) {
|
|
18
|
+
super(message, 'SESSION_ERROR', statusCode);
|
|
19
|
+
this.name = 'SessionError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class AuthenticationError extends AuthError {
|
|
23
|
+
constructor(message = 'Authentication failed', statusCode = 401) {
|
|
24
|
+
super(message, 'AUTHENTICATION_ERROR', statusCode);
|
|
25
|
+
this.name = 'AuthenticationError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class DatabaseError extends AuthError {
|
|
29
|
+
originalError;
|
|
30
|
+
constructor(message = 'Database error', originalError) {
|
|
31
|
+
super(message, 'DATABASE_ERROR', 500);
|
|
32
|
+
this.name = 'DatabaseError';
|
|
33
|
+
if (originalError instanceof Error) {
|
|
34
|
+
this.originalError = originalError;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class TokenError extends AuthError {
|
|
39
|
+
constructor(message = 'Token error', statusCode = 401) {
|
|
40
|
+
super(message, 'TOKEN_ERROR', statusCode);
|
|
41
|
+
this.name = 'TokenError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class OAuthAccountConflictError extends AuthError {
|
|
45
|
+
email;
|
|
46
|
+
constructor(email) {
|
|
47
|
+
super('An account with this email already exists. Sign in with your password or original provider.', 'OAUTH_ACCOUNT_CONFLICT', 409);
|
|
48
|
+
this.name = 'OAuthAccountConflictError';
|
|
49
|
+
this.email = email;
|
|
50
|
+
}
|
|
51
|
+
}
|