@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/useMFA.d.ts +83 -0
- package/dist/react/useMFA.d.ts.map +1 -0
- package/dist/react/useMFA.js +182 -0
- package/dist/react/usePasskey.d.ts +88 -0
- package/dist/react/usePasskey.d.ts.map +1 -0
- package/dist/react/usePasskey.js +203 -0
- package/dist/react/useSession.d.ts.map +1 -1
- package/dist/react/useSession.js +16 -5
- package/dist/react/useSignIn.d.ts +9 -3
- package/dist/react/useSignIn.d.ts.map +1 -1
- package/dist/react/useSignIn.js +32 -10
- package/dist/react/useSignOut.d.ts.map +1 -1
- package/dist/react/useSignUp.d.ts.map +1 -1
- package/dist/react/useSignUp.js +25 -9
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +75 -4
- package/dist/server/brute-force.d.ts +10 -1
- package/dist/server/brute-force.d.ts.map +1 -1
- package/dist/server/brute-force.js +17 -3
- package/dist/server/errors.d.ts.map +1 -1
- package/dist/server/index.d.ts +16 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +11 -5
- package/dist/server/magic-link.d.ts +52 -0
- package/dist/server/magic-link.d.ts.map +1 -0
- package/dist/server/magic-link.js +111 -0
- package/dist/server/mfa.d.ts +87 -0
- package/dist/server/mfa.d.ts.map +1 -0
- package/dist/server/mfa.js +263 -0
- package/dist/server/oauth.d.ts +37 -0
- package/dist/server/oauth.d.ts.map +1 -1
- package/dist/server/oauth.js +135 -3
- package/dist/server/passkey.d.ts +132 -0
- package/dist/server/passkey.d.ts.map +1 -0
- package/dist/server/passkey.js +257 -0
- package/dist/server/password-reset.d.ts +15 -0
- package/dist/server/password-reset.d.ts.map +1 -1
- package/dist/server/password-reset.js +44 -1
- package/dist/server/password-validation.d.ts.map +1 -1
- package/dist/server/providers/github.d.ts.map +1 -1
- package/dist/server/providers/github.js +18 -2
- package/dist/server/providers/google.d.ts.map +1 -1
- package/dist/server/providers/google.js +18 -2
- package/dist/server/providers/vercel.d.ts.map +1 -1
- package/dist/server/providers/vercel.js +18 -2
- 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 +125 -6
- package/dist/server/signed-cookie.d.ts +32 -0
- package/dist/server/signed-cookie.d.ts.map +1 -0
- package/dist/server/signed-cookie.js +67 -0
- package/dist/server/storage/database.d.ts +1 -1
- package/dist/server/storage/database.d.ts.map +1 -1
- package/dist/server/storage/database.js +15 -7
- package/dist/server/storage/in-memory.d.ts.map +1 -1
- package/dist/server/storage/in-memory.js +7 -7
- package/dist/server/storage/index.d.ts +11 -3
- package/dist/server/storage/index.d.ts.map +1 -1
- package/dist/server/storage/index.js +18 -4
- package/dist/server/storage/interface.d.ts +1 -1
- package/dist/server/storage/interface.d.ts.map +1 -1
- package/dist/server/storage/interface.js +1 -1
- package/dist/types.d.ts +20 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/utils/database.d.ts.map +1 -1
- package/dist/utils/database.js +9 -2
- package/package.json +26 -8
package/dist/react/useSignIn.js
CHANGED
|
@@ -6,18 +6,33 @@
|
|
|
6
6
|
'use client';
|
|
7
7
|
import { useCallback, useRef, useState } from 'react';
|
|
8
8
|
import { z } from 'zod/v4';
|
|
9
|
+
// Zod validates the required fields; .passthrough() preserves the rest.
|
|
10
|
+
// The API returns JSON-serialized User objects (Dates as ISO strings).
|
|
11
|
+
// z.infer output has an index signature incompatible with the concrete User
|
|
12
|
+
// interface, so we extract the validated user via the helper below.
|
|
13
|
+
const SignInUserSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
email: z.string(),
|
|
17
|
+
name: z.string().nullable().optional(),
|
|
18
|
+
})
|
|
19
|
+
.passthrough();
|
|
20
|
+
/**
|
|
21
|
+
* Narrow Zod-validated API response data to User.
|
|
22
|
+
*
|
|
23
|
+
* The cast is safe because: (1) Zod verified the required fields (id, email),
|
|
24
|
+
* (2) .passthrough() preserves all other properties from the API response,
|
|
25
|
+
* and (3) the API serializes a full User row (Dates become ISO strings in JSON).
|
|
26
|
+
*/
|
|
27
|
+
function toUser(validated) {
|
|
28
|
+
return validated;
|
|
29
|
+
}
|
|
9
30
|
// Validation schemas for sign-in response
|
|
10
31
|
const SignInErrorResponseSchema = z.object({
|
|
11
32
|
error: z.string().optional(),
|
|
12
33
|
});
|
|
13
34
|
const SignInSuccessResponseSchema = z.object({
|
|
14
|
-
user:
|
|
15
|
-
.object({
|
|
16
|
-
id: z.string(),
|
|
17
|
-
email: z.string(),
|
|
18
|
-
name: z.string().nullable().optional(),
|
|
19
|
-
})
|
|
20
|
-
.passthrough(), // Allow all other User properties
|
|
35
|
+
user: SignInUserSchema,
|
|
21
36
|
});
|
|
22
37
|
/**
|
|
23
38
|
* Hook to sign in a user
|
|
@@ -68,12 +83,19 @@ export function useSignIn() {
|
|
|
68
83
|
error: errorData.error || 'Failed to sign in',
|
|
69
84
|
};
|
|
70
85
|
}
|
|
86
|
+
// Check for MFA challenge before parsing as full success
|
|
87
|
+
const jsonObj = json;
|
|
88
|
+
if (jsonObj.requiresMfa === true && typeof jsonObj.mfaUserId === 'string') {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
requiresMfa: true,
|
|
92
|
+
mfaUserId: jsonObj.mfaUserId,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
71
95
|
const successData = SignInSuccessResponseSchema.parse(json);
|
|
72
96
|
return {
|
|
73
97
|
success: true,
|
|
74
|
-
|
|
75
|
-
// The API returns serialized data, so we cast to expected type
|
|
76
|
-
user: successData.user,
|
|
98
|
+
user: toUser(successData.user),
|
|
77
99
|
};
|
|
78
100
|
}
|
|
79
101
|
catch (err) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSignOut.d.ts","sourceRoot":"","sources":["../../src/react/useSignOut.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"useSignOut.d.ts","sourceRoot":"","sources":["../../src/react/useSignOut.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,IAAI,gBAAgB,CAkC7C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSignUp.d.ts","sourceRoot":"","sources":["../../src/react/useSignUp.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"useSignUp.d.ts","sourceRoot":"","sources":["../../src/react/useSignUp.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAmCxC,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,IAAI,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3F,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,IAAI,eAAe,CAoD3C"}
|
package/dist/react/useSignUp.js
CHANGED
|
@@ -6,18 +6,34 @@
|
|
|
6
6
|
'use client';
|
|
7
7
|
import { useState } from 'react';
|
|
8
8
|
import { z } from 'zod/v4';
|
|
9
|
+
// Zod validates the required fields; .passthrough() preserves the rest.
|
|
10
|
+
// The API returns JSON-serialized User objects (Dates as ISO strings).
|
|
11
|
+
// z.infer output has an index signature incompatible with the concrete User
|
|
12
|
+
// interface, so we extract the validated user via the helper below.
|
|
13
|
+
const SignUpUserSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
email: z.string(),
|
|
17
|
+
name: z.string().nullable().optional(),
|
|
18
|
+
})
|
|
19
|
+
.passthrough();
|
|
20
|
+
/**
|
|
21
|
+
* Narrow Zod-validated API response data to User.
|
|
22
|
+
*
|
|
23
|
+
* The cast is safe because: (1) Zod verified the required fields (id, email),
|
|
24
|
+
* (2) .passthrough() preserves all other properties from the API response,
|
|
25
|
+
* and (3) the API serializes a full User row (Dates become ISO strings in JSON).
|
|
26
|
+
*/
|
|
27
|
+
function toUser(validated) {
|
|
28
|
+
return validated;
|
|
29
|
+
}
|
|
9
30
|
// Validation schemas for sign-up response
|
|
10
31
|
const SignUpErrorResponseSchema = z.object({
|
|
32
|
+
message: z.string().optional(),
|
|
11
33
|
error: z.string().optional(),
|
|
12
34
|
});
|
|
13
35
|
const SignUpSuccessResponseSchema = z.object({
|
|
14
|
-
user:
|
|
15
|
-
.object({
|
|
16
|
-
id: z.string(),
|
|
17
|
-
email: z.string(),
|
|
18
|
-
name: z.string().nullable().optional(),
|
|
19
|
-
})
|
|
20
|
-
.passthrough(), // Allow all other User properties
|
|
36
|
+
user: SignUpUserSchema,
|
|
21
37
|
});
|
|
22
38
|
/**
|
|
23
39
|
* Hook to sign up a new user
|
|
@@ -59,7 +75,7 @@ export function useSignUp() {
|
|
|
59
75
|
const errorData = SignUpErrorResponseSchema.parse(json);
|
|
60
76
|
return {
|
|
61
77
|
success: false,
|
|
62
|
-
error: errorData.error || 'Failed to sign up',
|
|
78
|
+
error: errorData.message || errorData.error || 'Failed to sign up',
|
|
63
79
|
};
|
|
64
80
|
}
|
|
65
81
|
const successData = SignUpSuccessResponseSchema.parse(json);
|
|
@@ -67,7 +83,7 @@ export function useSignUp() {
|
|
|
67
83
|
success: true,
|
|
68
84
|
// Type assertion through unknown is safe because Zod validation ensures the shape is correct
|
|
69
85
|
// The API returns serialized data, so we cast to expected type
|
|
70
|
-
user: successData.user,
|
|
86
|
+
user: toUser(successData.user),
|
|
71
87
|
};
|
|
72
88
|
}
|
|
73
89
|
catch (err) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAQ,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAQ,MAAM,aAAa,CAAC;AASpE;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,OAAO,CAAC,YAAY,CAAC,CA2JvB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAiBtD;AAED;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAC1B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;IACR,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,IAAI,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GACA,OAAO,CAAC,YAAY,CAAC,CAsLvB"}
|
package/dist/server/auth.js
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
import { createHash, randomBytes } from 'node:crypto';
|
|
7
7
|
import { logger } from '@revealui/core/observability/logger';
|
|
8
8
|
import { getClient } from '@revealui/db/client';
|
|
9
|
-
import { users } from '@revealui/db/schema';
|
|
9
|
+
import { oauthAccounts, users } from '@revealui/db/schema';
|
|
10
10
|
import bcrypt from 'bcryptjs';
|
|
11
|
-
import { eq } from 'drizzle-orm';
|
|
11
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
12
12
|
import { clearFailedAttempts, isAccountLocked, recordFailedAttempt } from './brute-force.js';
|
|
13
13
|
import { validatePasswordStrength } from './password-validation.js';
|
|
14
14
|
import { checkRateLimit } from './rate-limit.js';
|
|
15
15
|
import { createSession } 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;
|
|
16
18
|
/**
|
|
17
19
|
* Sign in with email and password
|
|
18
20
|
*
|
|
@@ -29,6 +31,7 @@ export async function signIn(email, password, options) {
|
|
|
29
31
|
if (!rateLimit.allowed) {
|
|
30
32
|
return {
|
|
31
33
|
success: false,
|
|
34
|
+
reason: 'rate_limited',
|
|
32
35
|
error: 'Too many login attempts. Please try again later.',
|
|
33
36
|
};
|
|
34
37
|
}
|
|
@@ -40,6 +43,7 @@ export async function signIn(email, password, options) {
|
|
|
40
43
|
: 30;
|
|
41
44
|
return {
|
|
42
45
|
success: false,
|
|
46
|
+
reason: 'account_locked',
|
|
43
47
|
error: `Account locked due to too many failed attempts. Please try again in ${lockMinutes} minutes.`,
|
|
44
48
|
};
|
|
45
49
|
}
|
|
@@ -51,19 +55,25 @@ export async function signIn(email, password, options) {
|
|
|
51
55
|
logger.error('Error getting database client');
|
|
52
56
|
return {
|
|
53
57
|
success: false,
|
|
58
|
+
reason: 'database_error',
|
|
54
59
|
error: 'Database connection failed',
|
|
55
60
|
};
|
|
56
61
|
}
|
|
57
62
|
// Find user by email
|
|
58
63
|
let user;
|
|
59
64
|
try {
|
|
60
|
-
const result = await db
|
|
65
|
+
const result = await db
|
|
66
|
+
.select()
|
|
67
|
+
.from(users)
|
|
68
|
+
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
|
69
|
+
.limit(1);
|
|
61
70
|
user = result[0];
|
|
62
71
|
}
|
|
63
72
|
catch {
|
|
64
73
|
logger.error('Error querying user');
|
|
65
74
|
return {
|
|
66
75
|
success: false,
|
|
76
|
+
reason: 'database_error',
|
|
67
77
|
error: 'Database error',
|
|
68
78
|
};
|
|
69
79
|
}
|
|
@@ -73,6 +83,7 @@ export async function signIn(email, password, options) {
|
|
|
73
83
|
await recordFailedAttempt(email);
|
|
74
84
|
return {
|
|
75
85
|
success: false,
|
|
86
|
+
reason: 'invalid_credentials',
|
|
76
87
|
error: invalidCredentialsMessage,
|
|
77
88
|
};
|
|
78
89
|
}
|
|
@@ -81,6 +92,7 @@ export async function signIn(email, password, options) {
|
|
|
81
92
|
await recordFailedAttempt(email);
|
|
82
93
|
return {
|
|
83
94
|
success: false,
|
|
95
|
+
reason: 'invalid_credentials',
|
|
84
96
|
error: invalidCredentialsMessage,
|
|
85
97
|
};
|
|
86
98
|
}
|
|
@@ -94,6 +106,7 @@ export async function signIn(email, password, options) {
|
|
|
94
106
|
await recordFailedAttempt(email);
|
|
95
107
|
return {
|
|
96
108
|
success: false,
|
|
109
|
+
reason: 'invalid_credentials',
|
|
97
110
|
error: invalidCredentialsMessage,
|
|
98
111
|
};
|
|
99
112
|
}
|
|
@@ -101,11 +114,31 @@ export async function signIn(email, password, options) {
|
|
|
101
114
|
await recordFailedAttempt(email);
|
|
102
115
|
return {
|
|
103
116
|
success: false,
|
|
117
|
+
reason: 'invalid_credentials',
|
|
104
118
|
error: invalidCredentialsMessage,
|
|
105
119
|
};
|
|
106
120
|
}
|
|
107
121
|
// Successful login - clear failed attempts
|
|
108
122
|
await clearFailedAttempts(email);
|
|
123
|
+
// Check email verification (with grace period for new accounts)
|
|
124
|
+
if (!user.emailVerified) {
|
|
125
|
+
const accountAge = Date.now() - user.createdAt.getTime();
|
|
126
|
+
if (accountAge > EMAIL_VERIFICATION_GRACE_PERIOD_MS) {
|
|
127
|
+
return {
|
|
128
|
+
success: false,
|
|
129
|
+
reason: 'email_not_verified',
|
|
130
|
+
error: 'Please verify your email address before signing in.',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Check if MFA is enabled — if so, return early and require TOTP verification
|
|
135
|
+
if (user.mfaEnabled) {
|
|
136
|
+
return {
|
|
137
|
+
success: true,
|
|
138
|
+
requiresMfa: true,
|
|
139
|
+
mfaUserId: user.id,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
109
142
|
// Create session
|
|
110
143
|
let token;
|
|
111
144
|
try {
|
|
@@ -119,6 +152,7 @@ export async function signIn(email, password, options) {
|
|
|
119
152
|
logger.error('Error creating session');
|
|
120
153
|
return {
|
|
121
154
|
success: false,
|
|
155
|
+
reason: 'session_error',
|
|
122
156
|
error: 'Failed to create session',
|
|
123
157
|
};
|
|
124
158
|
}
|
|
@@ -132,6 +166,7 @@ export async function signIn(email, password, options) {
|
|
|
132
166
|
logger.error('Unexpected error in signIn');
|
|
133
167
|
return {
|
|
134
168
|
success: false,
|
|
169
|
+
reason: 'unexpected_error',
|
|
135
170
|
error: 'Unexpected error',
|
|
136
171
|
};
|
|
137
172
|
}
|
|
@@ -208,7 +243,9 @@ export async function signUp(email, password, name, options) {
|
|
|
208
243
|
error: 'Database connection failed',
|
|
209
244
|
};
|
|
210
245
|
}
|
|
211
|
-
// Check if user already exists
|
|
246
|
+
// Check if user already exists (by email in users table or OAuth accounts).
|
|
247
|
+
// Both checks prevent account collision: a password signup must not collide
|
|
248
|
+
// with an existing OAuth identity for the same email address.
|
|
212
249
|
let existing;
|
|
213
250
|
try {
|
|
214
251
|
const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
@@ -227,6 +264,29 @@ export async function signUp(email, password, name, options) {
|
|
|
227
264
|
error: 'Unable to create account',
|
|
228
265
|
};
|
|
229
266
|
}
|
|
267
|
+
// Block signup if an OAuth account already uses this email.
|
|
268
|
+
// Without this check, an attacker could create a password account
|
|
269
|
+
// for an email that was registered via OAuth, enabling account takeover.
|
|
270
|
+
try {
|
|
271
|
+
const [existingOAuth] = await db
|
|
272
|
+
.select({ id: oauthAccounts.id })
|
|
273
|
+
.from(oauthAccounts)
|
|
274
|
+
.where(eq(oauthAccounts.providerEmail, email))
|
|
275
|
+
.limit(1);
|
|
276
|
+
if (existingOAuth) {
|
|
277
|
+
return {
|
|
278
|
+
success: false,
|
|
279
|
+
error: 'Unable to create account',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
logger.error('Error checking OAuth accounts');
|
|
285
|
+
return {
|
|
286
|
+
success: false,
|
|
287
|
+
error: 'Database error',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
230
290
|
// Hash password
|
|
231
291
|
let hashedPassword;
|
|
232
292
|
try {
|
|
@@ -290,6 +350,17 @@ export async function signUp(email, password, name, options) {
|
|
|
290
350
|
}
|
|
291
351
|
catch {
|
|
292
352
|
logger.error('Error creating session');
|
|
353
|
+
// Clean up orphaned user so the email isn't permanently locked out.
|
|
354
|
+
// Without this, a retry would fail with "Unable to create account"
|
|
355
|
+
// because the user row already exists but has no valid session.
|
|
356
|
+
try {
|
|
357
|
+
await db.delete(users).where(eq(users.id, user.id));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
logger.error('Failed to clean up orphaned user after session creation failure', undefined, {
|
|
361
|
+
userId: user.id,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
293
364
|
return {
|
|
294
365
|
success: false,
|
|
295
366
|
error: 'Failed to create session',
|
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
* Brute Force Protection
|
|
3
3
|
*
|
|
4
4
|
* Tracks failed login attempts and locks accounts after threshold.
|
|
5
|
-
* Uses storage abstraction (
|
|
5
|
+
* Uses storage abstraction (database or in-memory).
|
|
6
6
|
*/
|
|
7
7
|
export interface BruteForceConfig {
|
|
8
8
|
maxAttempts: number;
|
|
9
9
|
lockDurationMs: number;
|
|
10
10
|
windowMs: number;
|
|
11
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;
|
|
12
21
|
/**
|
|
13
22
|
* Records a failed login attempt
|
|
14
23
|
*
|
|
@@ -1 +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,
|
|
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"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Brute Force Protection
|
|
3
3
|
*
|
|
4
4
|
* Tracks failed login attempts and locks accounts after threshold.
|
|
5
|
-
* Uses storage abstraction (
|
|
5
|
+
* Uses storage abstraction (database or in-memory).
|
|
6
6
|
*/
|
|
7
7
|
import { getStorage } from './storage/index.js';
|
|
8
8
|
const DEFAULT_CONFIG = {
|
|
@@ -10,6 +10,20 @@ const DEFAULT_CONFIG = {
|
|
|
10
10
|
lockDurationMs: 30 * 60 * 1000, // 30 minutes
|
|
11
11
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
12
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
|
+
}
|
|
13
27
|
/**
|
|
14
28
|
* Serialize failed attempt entry
|
|
15
29
|
*/
|
|
@@ -42,7 +56,7 @@ function getStorageKey(email) {
|
|
|
42
56
|
* @param email - User email
|
|
43
57
|
* @param config - Brute force configuration
|
|
44
58
|
*/
|
|
45
|
-
export async function recordFailedAttempt(email, config =
|
|
59
|
+
export async function recordFailedAttempt(email, config = globalConfig) {
|
|
46
60
|
const storage = getStorage();
|
|
47
61
|
const storageKey = getStorageKey(email);
|
|
48
62
|
const updater = (entryData) => {
|
|
@@ -94,7 +108,7 @@ export async function clearFailedAttempts(email) {
|
|
|
94
108
|
* @param config - Brute force configuration
|
|
95
109
|
* @returns Lock status
|
|
96
110
|
*/
|
|
97
|
-
export async function isAccountLocked(email, config =
|
|
111
|
+
export async function isAccountLocked(email, config = globalConfig) {
|
|
98
112
|
const storage = getStorage();
|
|
99
113
|
const storageKey = getStorageKey(email);
|
|
100
114
|
const now = Date.now();
|
|
@@ -1 +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,
|
|
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"}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -6,12 +6,22 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export type { SignInResult, SignUpResult } from '../types.js';
|
|
8
8
|
export { isSignupAllowed, signIn, signUp } from './auth.js';
|
|
9
|
-
export { clearFailedAttempts, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, } from './brute-force.js';
|
|
9
|
+
export { clearFailedAttempts, configureBruteForce, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, resetBruteForceConfig, } from './brute-force.js';
|
|
10
10
|
export { AuthError, AuthenticationError, DatabaseError, OAuthAccountConflictError, SessionError, TokenError, } from './errors.js';
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
export {
|
|
11
|
+
export type { MagicLinkConfig } from './magic-link.js';
|
|
12
|
+
export { configureMagicLink, createMagicLink, resetMagicLinkConfig, verifyMagicLink, } from './magic-link.js';
|
|
13
|
+
export type { MFAConfig, MFADisableProof, MFASetupResult } from './mfa.js';
|
|
14
|
+
export { configureMFA, disableMFA, initiateMFASetup, isMFAEnabled, regenerateBackupCodes, resetMFAConfig, verifyBackupCode, verifyMFACode, verifyMFASetup, } from './mfa.js';
|
|
15
|
+
export { buildAuthUrl, exchangeCode, fetchProviderUser, generateOAuthState, getLinkedProviders, linkOAuthAccount, type ProviderUser, unlinkOAuthAccount, upsertOAuthUser, verifyOAuthState, } from './oauth.js';
|
|
16
|
+
export type { PasskeyConfig } from './passkey.js';
|
|
17
|
+
export { configurePasskey, countUserCredentials, deletePasskey, generateAuthenticationChallenge, generateRegistrationChallenge, listPasskeys, renamePasskey, resetPasskeyConfig, storePasskey, verifyAuthentication, verifyRegistration, } from './passkey.js';
|
|
18
|
+
export type { ChangePasswordResult, PasswordResetResult, PasswordResetToken, } from './password-reset.js';
|
|
19
|
+
export { changePassword, generatePasswordResetToken, invalidatePasswordResetToken, resetPasswordWithToken, validatePasswordResetToken, } from './password-reset.js';
|
|
14
20
|
export { meetsMinimumPasswordRequirements, validatePasswordStrength, } from './password-validation.js';
|
|
15
|
-
export { checkRateLimit, getRateLimitStatus, resetRateLimit, } from './rate-limit.js';
|
|
16
|
-
export {
|
|
21
|
+
export { checkRateLimit, configureRateLimit, getRateLimitStatus, resetRateLimit, resetRateLimitConfig, } from './rate-limit.js';
|
|
22
|
+
export type { RequestContext, SessionBindingConfig, SessionData } from './session.js';
|
|
23
|
+
export { configureSessionBinding, createSession, deleteAllUserSessions, deleteSession, getSession, resetSessionBindingConfig, rotateSession, validateSessionBinding, } from './session.js';
|
|
24
|
+
export { signCookiePayload, verifyCookiePayload } from './signed-cookie.js';
|
|
25
|
+
export type { Storage } from './storage/index.js';
|
|
26
|
+
export { createStorage, DatabaseStorage, getStorage, InMemoryStorage, resetStorage, } from './storage/index.js';
|
|
17
27
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,qBAAqB,EACrB,eAAe,EACf,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,yBAAyB,EACzB,YAAY,EACZ,UAAU,GACX,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,oBAAoB,EACpB,eAAe,GAChB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,SAAS,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC3E,OAAO,EACL,YAAY,EACZ,UAAU,EACV,gBAAgB,EAChB,YAAY,EACZ,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,aAAa,EACb,cAAc,GACf,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,KAAK,YAAY,EACjB,kBAAkB,EAClB,eAAe,EACf,gBAAgB,GACjB,MAAM,YAAY,CAAC;AAEpB,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,+BAA+B,EAC/B,6BAA6B,EAC7B,YAAY,EACZ,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,oBAAoB,EACpB,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,cAAc,EACd,0BAA0B,EAC1B,4BAA4B,EAC5B,sBAAsB,EACtB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,gCAAgC,EAChC,wBAAwB,GACzB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AACtF,OAAO,EACL,uBAAuB,EACvB,aAAa,EACb,qBAAqB,EACrB,aAAa,EACb,UAAU,EACV,yBAAyB,EACzB,aAAa,EACb,sBAAsB,GACvB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,YAAY,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EACL,aAAa,EACb,eAAe,EACf,UAAU,EACV,eAAe,EACf,YAAY,GACb,MAAM,oBAAoB,CAAC"}
|
package/dist/server/index.js
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* Inspired by Better Auth and Neon Auth patterns.
|
|
6
6
|
*/
|
|
7
7
|
export { isSignupAllowed, signIn, signUp } from './auth.js';
|
|
8
|
-
export { clearFailedAttempts, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, } from './brute-force.js';
|
|
8
|
+
export { clearFailedAttempts, configureBruteForce, getFailedAttemptCount, isAccountLocked, recordFailedAttempt, resetBruteForceConfig, } from './brute-force.js';
|
|
9
9
|
export { AuthError, AuthenticationError, DatabaseError, OAuthAccountConflictError, SessionError, TokenError, } from './errors.js';
|
|
10
|
-
export {
|
|
11
|
-
export {
|
|
10
|
+
export { configureMagicLink, createMagicLink, resetMagicLinkConfig, verifyMagicLink, } from './magic-link.js';
|
|
11
|
+
export { configureMFA, disableMFA, initiateMFASetup, isMFAEnabled, regenerateBackupCodes, resetMFAConfig, verifyBackupCode, verifyMFACode, verifyMFASetup, } from './mfa.js';
|
|
12
|
+
export { buildAuthUrl, exchangeCode, fetchProviderUser, generateOAuthState, getLinkedProviders, linkOAuthAccount, unlinkOAuthAccount, upsertOAuthUser, verifyOAuthState, } from './oauth.js';
|
|
13
|
+
export { configurePasskey, countUserCredentials, deletePasskey, generateAuthenticationChallenge, generateRegistrationChallenge, listPasskeys, renamePasskey, resetPasskeyConfig, storePasskey, verifyAuthentication, verifyRegistration, } from './passkey.js';
|
|
14
|
+
export { changePassword, generatePasswordResetToken, invalidatePasswordResetToken, resetPasswordWithToken, validatePasswordResetToken, } from './password-reset.js';
|
|
12
15
|
export { meetsMinimumPasswordRequirements, validatePasswordStrength, } from './password-validation.js';
|
|
13
|
-
export { checkRateLimit, getRateLimitStatus, resetRateLimit, } from './rate-limit.js';
|
|
14
|
-
export { createSession, deleteAllUserSessions, deleteSession, getSession, } from './session.js';
|
|
16
|
+
export { checkRateLimit, configureRateLimit, getRateLimitStatus, resetRateLimit, resetRateLimitConfig, } from './rate-limit.js';
|
|
17
|
+
export { configureSessionBinding, createSession, deleteAllUserSessions, deleteSession, getSession, resetSessionBindingConfig, rotateSession, validateSessionBinding, } from './session.js';
|
|
18
|
+
// Signed Cookie
|
|
19
|
+
export { signCookiePayload, verifyCookiePayload } from './signed-cookie.js';
|
|
20
|
+
export { createStorage, DatabaseStorage, getStorage, InMemoryStorage, resetStorage, } from './storage/index.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic Link Token Module
|
|
3
|
+
*
|
|
4
|
+
* Generates and verifies single-use, time-limited tokens for passwordless
|
|
5
|
+
* email authentication and account recovery flows.
|
|
6
|
+
*
|
|
7
|
+
* Tokens are stored in the database as HMAC-SHA256 hashes with per-token salts,
|
|
8
|
+
* following the same security pattern as password-reset.ts.
|
|
9
|
+
*/
|
|
10
|
+
export interface MagicLinkConfig {
|
|
11
|
+
/** Token expiry in ms (default: 15 minutes) */
|
|
12
|
+
tokenExpiryMs: number;
|
|
13
|
+
/** Temp session duration in ms (default: 30 minutes) */
|
|
14
|
+
tempSessionDurationMs: number;
|
|
15
|
+
/** Max requests per hour per email (default: 3) */
|
|
16
|
+
maxRequestsPerHour: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function configureMagicLink(overrides: Partial<MagicLinkConfig>): void;
|
|
19
|
+
export declare function resetMagicLinkConfig(): void;
|
|
20
|
+
/**
|
|
21
|
+
* Creates a magic link token for a user.
|
|
22
|
+
*
|
|
23
|
+
* - Generates a 32-byte random token
|
|
24
|
+
* - Hashes it with HMAC-SHA256 + per-token salt
|
|
25
|
+
* - Cleans up expired magic links for the same user (opportunistic)
|
|
26
|
+
* - Inserts the hashed token into the database
|
|
27
|
+
*
|
|
28
|
+
* @param userId - User ID to create the magic link for
|
|
29
|
+
* @returns The plaintext token (to embed in the email link) and its expiry
|
|
30
|
+
*/
|
|
31
|
+
export declare function createMagicLink(userId: string): Promise<{
|
|
32
|
+
token: string;
|
|
33
|
+
expiresAt: Date;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Verifies a magic link token.
|
|
37
|
+
*
|
|
38
|
+
* Selects all unexpired, unused magic links and checks each one against the
|
|
39
|
+
* provided token using HMAC-SHA256 + timingSafeEqual. This is a table scan
|
|
40
|
+
* by design (same approach as password-reset.ts validation). The table stays
|
|
41
|
+
* small due to opportunistic cleanup in createMagicLink.
|
|
42
|
+
*
|
|
43
|
+
* On match: marks the token as used and returns the userId.
|
|
44
|
+
* On no match: returns null.
|
|
45
|
+
*
|
|
46
|
+
* @param token - Plaintext token from the magic link URL
|
|
47
|
+
* @returns Object with userId if valid, null otherwise
|
|
48
|
+
*/
|
|
49
|
+
export declare function verifyMagicLink(token: string): Promise<{
|
|
50
|
+
userId: string;
|
|
51
|
+
} | null>;
|
|
52
|
+
//# sourceMappingURL=magic-link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"magic-link.d.ts","sourceRoot":"","sources":["../../src/server/magic-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAWH,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,qBAAqB,EAAE,MAAM,CAAC;IAC9B,mDAAmD;IACnD,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAUD,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI,CAE5E;AAED,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C;AA0BD;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAyBjG;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAuBvF"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic Link Token Module
|
|
3
|
+
*
|
|
4
|
+
* Generates and verifies single-use, time-limited tokens for passwordless
|
|
5
|
+
* email authentication and account recovery flows.
|
|
6
|
+
*
|
|
7
|
+
* Tokens are stored in the database as HMAC-SHA256 hashes with per-token salts,
|
|
8
|
+
* following the same security pattern as password-reset.ts.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { getClient } from '@revealui/db/client';
|
|
12
|
+
import { magicLinks } from '@revealui/db/schema';
|
|
13
|
+
import { and, eq, gt, isNull, lt } from 'drizzle-orm';
|
|
14
|
+
const DEFAULT_CONFIG = {
|
|
15
|
+
tokenExpiryMs: 15 * 60 * 1000,
|
|
16
|
+
tempSessionDurationMs: 30 * 60 * 1000,
|
|
17
|
+
maxRequestsPerHour: 3,
|
|
18
|
+
};
|
|
19
|
+
let config = { ...DEFAULT_CONFIG };
|
|
20
|
+
export function configureMagicLink(overrides) {
|
|
21
|
+
config = { ...DEFAULT_CONFIG, ...overrides };
|
|
22
|
+
}
|
|
23
|
+
export function resetMagicLinkConfig() {
|
|
24
|
+
config = { ...DEFAULT_CONFIG };
|
|
25
|
+
}
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Crypto helpers (same pattern as password-reset.ts)
|
|
28
|
+
// =============================================================================
|
|
29
|
+
/**
|
|
30
|
+
* Hash a token using HMAC-SHA256 with a per-token salt.
|
|
31
|
+
* The salt is stored in the DB alongside the hash; this defeats rainbow
|
|
32
|
+
* table attacks even if the database is fully compromised.
|
|
33
|
+
*/
|
|
34
|
+
function hashToken(token, salt) {
|
|
35
|
+
return crypto.createHmac('sha256', salt).update(token).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Generate a 16-byte random salt (hex string).
|
|
39
|
+
*/
|
|
40
|
+
function generateSalt() {
|
|
41
|
+
return crypto.randomBytes(16).toString('hex');
|
|
42
|
+
}
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Public API
|
|
45
|
+
// =============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Creates a magic link token for a user.
|
|
48
|
+
*
|
|
49
|
+
* - Generates a 32-byte random token
|
|
50
|
+
* - Hashes it with HMAC-SHA256 + per-token salt
|
|
51
|
+
* - Cleans up expired magic links for the same user (opportunistic)
|
|
52
|
+
* - Inserts the hashed token into the database
|
|
53
|
+
*
|
|
54
|
+
* @param userId - User ID to create the magic link for
|
|
55
|
+
* @returns The plaintext token (to embed in the email link) and its expiry
|
|
56
|
+
*/
|
|
57
|
+
export async function createMagicLink(userId) {
|
|
58
|
+
const db = getClient();
|
|
59
|
+
// Generate secure token with per-token salt
|
|
60
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
61
|
+
const tokenSalt = generateSalt();
|
|
62
|
+
const tokenHash = hashToken(token, tokenSalt);
|
|
63
|
+
const expiresAt = new Date(Date.now() + config.tokenExpiryMs);
|
|
64
|
+
const id = crypto.randomUUID();
|
|
65
|
+
// Opportunistic cleanup: delete expired magic links for this user
|
|
66
|
+
await db
|
|
67
|
+
.delete(magicLinks)
|
|
68
|
+
.where(and(eq(magicLinks.userId, userId), lt(magicLinks.expiresAt, new Date())));
|
|
69
|
+
// Store hashed token + salt in database
|
|
70
|
+
await db.insert(magicLinks).values({
|
|
71
|
+
id,
|
|
72
|
+
userId,
|
|
73
|
+
tokenHash,
|
|
74
|
+
tokenSalt,
|
|
75
|
+
expiresAt,
|
|
76
|
+
});
|
|
77
|
+
return { token, expiresAt };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Verifies a magic link token.
|
|
81
|
+
*
|
|
82
|
+
* Selects all unexpired, unused magic links and checks each one against the
|
|
83
|
+
* provided token using HMAC-SHA256 + timingSafeEqual. This is a table scan
|
|
84
|
+
* by design (same approach as password-reset.ts validation). The table stays
|
|
85
|
+
* small due to opportunistic cleanup in createMagicLink.
|
|
86
|
+
*
|
|
87
|
+
* On match: marks the token as used and returns the userId.
|
|
88
|
+
* On no match: returns null.
|
|
89
|
+
*
|
|
90
|
+
* @param token - Plaintext token from the magic link URL
|
|
91
|
+
* @returns Object with userId if valid, null otherwise
|
|
92
|
+
*/
|
|
93
|
+
export async function verifyMagicLink(token) {
|
|
94
|
+
const db = getClient();
|
|
95
|
+
// Select unexpired, unused magic links only — expired tokens can never match
|
|
96
|
+
const rows = await db
|
|
97
|
+
.select()
|
|
98
|
+
.from(magicLinks)
|
|
99
|
+
.where(and(isNull(magicLinks.usedAt), gt(magicLinks.expiresAt, new Date())));
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const expectedHash = hashToken(token, row.tokenSalt);
|
|
102
|
+
const expectedBuf = Buffer.from(expectedHash);
|
|
103
|
+
const actualBuf = Buffer.from(row.tokenHash);
|
|
104
|
+
if (expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
105
|
+
// Mark token as used (single-use enforcement)
|
|
106
|
+
await db.update(magicLinks).set({ usedAt: new Date() }).where(eq(magicLinks.id, row.id));
|
|
107
|
+
return { userId: row.userId };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|