@revealui/auth 0.2.1 → 0.3.2
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 +85 -8
- 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 +52 -4
- package/dist/server/oauth.d.ts.map +1 -1
- package/dist/server/oauth.js +165 -18
- 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 -5
- package/dist/server/providers/google.d.ts.map +1 -1
- package/dist/server/providers/google.js +18 -3
- package/dist/server/providers/vercel.d.ts.map +1 -1
- package/dist/server/providers/vercel.js +18 -3
- 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 +65 -1
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +175 -7
- package/dist/server/signed-cookie.d.ts +32 -0
- package/dist/server/signed-cookie.d.ts.map +1 -0
- package/dist/server/signed-cookie.js +67 -0
- package/dist/server/storage/database.d.ts +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 +31 -13
|
@@ -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,
|
|
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,
|
|
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,CAoCrF;AAED,wBAAsB,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAkD1E"}
|
|
@@ -20,7 +20,6 @@ export async function exchangeCode(code, redirectUri) {
|
|
|
20
20
|
method: 'POST',
|
|
21
21
|
headers: {
|
|
22
22
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
23
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
|
|
24
23
|
Accept: 'application/json',
|
|
25
24
|
},
|
|
26
25
|
body: new URLSearchParams({
|
|
@@ -31,7 +30,15 @@ export async function exchangeCode(code, redirectUri) {
|
|
|
31
30
|
}),
|
|
32
31
|
});
|
|
33
32
|
if (!response.ok) {
|
|
34
|
-
|
|
33
|
+
let detail = '';
|
|
34
|
+
try {
|
|
35
|
+
const err = (await response.json());
|
|
36
|
+
detail = err.error_description ?? err.error ?? '';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Response body not JSON — use status only
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`GitHub token exchange failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
|
|
35
42
|
}
|
|
36
43
|
const data = (await response.json());
|
|
37
44
|
if (data.error) {
|
|
@@ -44,14 +51,20 @@ export async function exchangeCode(code, redirectUri) {
|
|
|
44
51
|
}
|
|
45
52
|
export async function fetchUser(accessToken) {
|
|
46
53
|
const headers = {
|
|
47
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
|
|
48
54
|
Authorization: `Bearer ${accessToken}`,
|
|
49
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
|
|
50
55
|
Accept: 'application/vnd.github+json',
|
|
51
56
|
};
|
|
52
57
|
const userResponse = await fetch('https://api.github.com/user', { headers });
|
|
53
58
|
if (!userResponse.ok) {
|
|
54
|
-
|
|
59
|
+
let detail = '';
|
|
60
|
+
try {
|
|
61
|
+
const err = (await userResponse.json());
|
|
62
|
+
detail = err.message ?? '';
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Response body not JSON
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`GitHub user fetch failed: ${userResponse.status}${detail ? ` — ${detail}` : ''}`);
|
|
55
68
|
}
|
|
56
69
|
const user = (await userResponse.json());
|
|
57
70
|
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,
|
|
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,CA+B1E"}
|
|
@@ -27,7 +27,15 @@ export async function exchangeCode(code, redirectUri) {
|
|
|
27
27
|
}),
|
|
28
28
|
});
|
|
29
29
|
if (!response.ok) {
|
|
30
|
-
|
|
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') {
|
|
@@ -37,11 +45,18 @@ export async function exchangeCode(code, redirectUri) {
|
|
|
37
45
|
}
|
|
38
46
|
export async function fetchUser(accessToken) {
|
|
39
47
|
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
40
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
|
|
41
48
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
42
49
|
});
|
|
43
50
|
if (!response.ok) {
|
|
44
|
-
|
|
51
|
+
let detail = '';
|
|
52
|
+
try {
|
|
53
|
+
const err = (await response.json());
|
|
54
|
+
detail = err.error?.message ?? '';
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Response body not JSON
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Google userinfo fetch failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
|
|
45
60
|
}
|
|
46
61
|
const data = (await response.json());
|
|
47
62
|
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,
|
|
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,CAiC1E"}
|
|
@@ -23,18 +23,33 @@ export async function exchangeCode(code, redirectUri) {
|
|
|
23
23
|
}),
|
|
24
24
|
});
|
|
25
25
|
if (!response.ok) {
|
|
26
|
-
|
|
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;
|
|
30
38
|
}
|
|
31
39
|
export async function fetchUser(accessToken) {
|
|
32
40
|
const response = await fetch('https://api.vercel.com/v2/user', {
|
|
33
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header names are case-sensitive per RFC 7230
|
|
34
41
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
35
42
|
});
|
|
36
43
|
if (!response.ok) {
|
|
37
|
-
|
|
44
|
+
let detail = '';
|
|
45
|
+
try {
|
|
46
|
+
const err = (await response.json());
|
|
47
|
+
detail = err.error?.message ?? '';
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Response body not JSON
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Vercel user fetch failed: ${response.status}${detail ? ` — ${detail}` : ''}`);
|
|
38
53
|
}
|
|
39
54
|
const data = (await response.json());
|
|
40
55
|
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)
|
|
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,
|
|
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"}
|
|
@@ -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)
|
|
5
|
+
* Supports in-memory (dev) or database (production) backends.
|
|
6
6
|
*/
|
|
7
7
|
import { getStorage } from './storage/index.js';
|
|
8
8
|
const DEFAULT_CONFIG = {
|
|
@@ -10,6 +10,20 @@ const DEFAULT_CONFIG = {
|
|
|
10
10
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
11
11
|
blockDurationMs: 30 * 60 * 1000, // 30 minutes block after max attempts
|
|
12
12
|
};
|
|
13
|
+
let globalConfig = { ...DEFAULT_CONFIG };
|
|
14
|
+
/**
|
|
15
|
+
* Override default rate limit configuration globally.
|
|
16
|
+
* Per-call config parameters still take precedence.
|
|
17
|
+
*/
|
|
18
|
+
export function configureRateLimit(overrides) {
|
|
19
|
+
globalConfig = { ...DEFAULT_CONFIG, ...overrides };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Reset rate limit configuration to defaults (for testing).
|
|
23
|
+
*/
|
|
24
|
+
export function resetRateLimitConfig() {
|
|
25
|
+
globalConfig = { ...DEFAULT_CONFIG };
|
|
26
|
+
}
|
|
13
27
|
/**
|
|
14
28
|
* Serialize rate limit entry to string
|
|
15
29
|
*/
|
|
@@ -43,54 +57,58 @@ function getStorageKey(key) {
|
|
|
43
57
|
* @param config - Rate limit configuration
|
|
44
58
|
* @returns Rate limit result
|
|
45
59
|
*/
|
|
46
|
-
export async function checkRateLimit(key, config =
|
|
60
|
+
export async function checkRateLimit(key, config = globalConfig) {
|
|
47
61
|
const storage = getStorage();
|
|
48
62
|
const storageKey = getStorageKey(key);
|
|
49
63
|
const now = Date.now();
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
entry
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Check if blocked
|
|
64
|
-
if (config.blockDurationMs && currentEntry.count >= config.maxAttempts) {
|
|
65
|
-
const blockUntil = now + config.blockDurationMs;
|
|
66
|
-
// Extend the storage TTL so the entry outlives the block period.
|
|
67
|
-
// Without this, the entry expires at resetAt (end of the rate-limit window)
|
|
68
|
-
// before the block expires, letting the user back in prematurely.
|
|
69
|
-
const blockTtlSeconds = Math.ceil(config.blockDurationMs / 1000);
|
|
70
|
-
await storage.set(storageKey, serializeEntry(currentEntry), blockTtlSeconds);
|
|
71
|
-
return {
|
|
72
|
-
allowed: false,
|
|
73
|
-
remaining: 0,
|
|
74
|
-
resetAt: blockUntil,
|
|
64
|
+
// Use atomicUpdate to avoid the read-modify-write race condition.
|
|
65
|
+
// The result is captured via closure since atomicUpdate returns void.
|
|
66
|
+
let result;
|
|
67
|
+
const updater = (entryData) => {
|
|
68
|
+
let entry = deserializeEntry(entryData);
|
|
69
|
+
// Clean up expired entries
|
|
70
|
+
if (entry && entry.resetAt < now) {
|
|
71
|
+
entry = null;
|
|
72
|
+
}
|
|
73
|
+
// Get or create entry
|
|
74
|
+
const currentEntry = entry || {
|
|
75
|
+
count: 0,
|
|
76
|
+
resetAt: now + config.windowMs,
|
|
75
77
|
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
allowed: false,
|
|
81
|
-
|
|
78
|
+
// Check if blocked
|
|
79
|
+
if (config.blockDurationMs && currentEntry.count >= config.maxAttempts) {
|
|
80
|
+
const blockUntil = now + config.blockDurationMs;
|
|
81
|
+
const blockTtlSeconds = Math.ceil(config.blockDurationMs / 1000);
|
|
82
|
+
result = { allowed: false, remaining: 0, resetAt: blockUntil };
|
|
83
|
+
return { value: serializeEntry(currentEntry), ttlSeconds: blockTtlSeconds };
|
|
84
|
+
}
|
|
85
|
+
// Check if within window
|
|
86
|
+
if (currentEntry.count >= config.maxAttempts) {
|
|
87
|
+
const ttlSeconds = Math.ceil((currentEntry.resetAt - now) / 1000);
|
|
88
|
+
result = { allowed: false, remaining: 0, resetAt: currentEntry.resetAt };
|
|
89
|
+
return { value: serializeEntry(currentEntry), ttlSeconds };
|
|
90
|
+
}
|
|
91
|
+
// Increment and update
|
|
92
|
+
currentEntry.count++;
|
|
93
|
+
const ttlSeconds = Math.ceil((currentEntry.resetAt - now) / 1000);
|
|
94
|
+
result = {
|
|
95
|
+
allowed: true,
|
|
96
|
+
remaining: config.maxAttempts - currentEntry.count,
|
|
82
97
|
resetAt: currentEntry.resetAt,
|
|
83
98
|
};
|
|
84
|
-
|
|
85
|
-
// Increment and update
|
|
86
|
-
currentEntry.count++;
|
|
87
|
-
const ttlSeconds = Math.ceil((currentEntry.resetAt - now) / 1000);
|
|
88
|
-
await storage.set(storageKey, serializeEntry(currentEntry), ttlSeconds);
|
|
89
|
-
return {
|
|
90
|
-
allowed: true,
|
|
91
|
-
remaining: config.maxAttempts - currentEntry.count,
|
|
92
|
-
resetAt: currentEntry.resetAt,
|
|
99
|
+
return { value: serializeEntry(currentEntry), ttlSeconds };
|
|
93
100
|
};
|
|
101
|
+
if (storage.atomicUpdate) {
|
|
102
|
+
await storage.atomicUpdate(storageKey, updater);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Fallback for storage backends without atomicUpdate
|
|
106
|
+
const existing = await storage.get(storageKey);
|
|
107
|
+
const { value, ttlSeconds } = updater(existing);
|
|
108
|
+
await storage.set(storageKey, value, ttlSeconds);
|
|
109
|
+
}
|
|
110
|
+
// biome-ignore lint/style/noNonNullAssertion: result is always assigned by updater before this point
|
|
111
|
+
return result;
|
|
94
112
|
}
|
|
95
113
|
/**
|
|
96
114
|
* Resets rate limit for a key
|
|
@@ -109,7 +127,7 @@ export async function resetRateLimit(key) {
|
|
|
109
127
|
* @param config - Rate limit configuration
|
|
110
128
|
* @returns Rate limit status
|
|
111
129
|
*/
|
|
112
|
-
export async function getRateLimitStatus(key, config =
|
|
130
|
+
export async function getRateLimitStatus(key, config = globalConfig) {
|
|
113
131
|
const storage = getStorage();
|
|
114
132
|
const storageKey = getStorageKey(key);
|
|
115
133
|
const now = Date.now();
|
package/dist/server/session.d.ts
CHANGED
|
@@ -5,17 +5,54 @@
|
|
|
5
5
|
* Sessions are stored in PostgreSQL and validated on each request.
|
|
6
6
|
*/
|
|
7
7
|
import type { Session, User } from '../types.js';
|
|
8
|
+
export interface SessionBindingConfig {
|
|
9
|
+
/** Invalidate session when user-agent changes (default: true) */
|
|
10
|
+
enforceUserAgent: boolean;
|
|
11
|
+
/** Invalidate session when IP address changes (default: false — users roam) */
|
|
12
|
+
enforceIp: boolean;
|
|
13
|
+
/** Log a warning when IP changes but don't invalidate (default: true) */
|
|
14
|
+
warnOnIpChange: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Override session binding behaviour (useful for tests or strict deployments). */
|
|
17
|
+
export declare function configureSessionBinding(overrides: Partial<SessionBindingConfig>): void;
|
|
18
|
+
/** Reset to defaults (for tests). */
|
|
19
|
+
export declare function resetSessionBindingConfig(): void;
|
|
20
|
+
export interface RequestContext {
|
|
21
|
+
userAgent?: string;
|
|
22
|
+
ipAddress?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validate that the current request context matches the session's stored
|
|
26
|
+
* binding values (IP address, user-agent).
|
|
27
|
+
*
|
|
28
|
+
* @returns `null` when the session is valid, or a reason string when it should
|
|
29
|
+
* be invalidated.
|
|
30
|
+
*/
|
|
31
|
+
export declare function validateSessionBinding(session: Session, ctx: RequestContext): string | null;
|
|
8
32
|
export interface SessionData {
|
|
9
33
|
session: Session;
|
|
10
34
|
user: User;
|
|
11
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Check if a session is a recovery session (created via magic link recovery).
|
|
38
|
+
*
|
|
39
|
+
* Recovery sessions are restricted — they should only be used for:
|
|
40
|
+
* - Changing the password (`/api/auth/change-password`)
|
|
41
|
+
* - Signing out (`/api/auth/sign-out`)
|
|
42
|
+
* - Viewing current session (`/api/auth/me`, `/api/auth/session`)
|
|
43
|
+
*
|
|
44
|
+
* All other operations (MFA management, passkey management, OAuth linking,
|
|
45
|
+
* admin actions) should reject recovery sessions.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isRecoverySession(sessionData: SessionData | null): boolean;
|
|
12
48
|
/**
|
|
13
49
|
* Get session from request headers (cookie)
|
|
14
50
|
*
|
|
15
51
|
* @param headers - Request headers containing cookies
|
|
52
|
+
* @param requestContext - Optional IP / user-agent for session binding validation
|
|
16
53
|
* @returns Session data with user, or null if invalid/expired
|
|
17
54
|
*/
|
|
18
|
-
export declare function getSession(headers: Headers): Promise<SessionData | null>;
|
|
55
|
+
export declare function getSession(headers: Headers, requestContext?: RequestContext): Promise<SessionData | null>;
|
|
19
56
|
/**
|
|
20
57
|
* Create a new session for a user
|
|
21
58
|
*
|
|
@@ -27,6 +64,28 @@ export declare function createSession(userId: string, options?: {
|
|
|
27
64
|
persistent?: boolean;
|
|
28
65
|
userAgent?: string;
|
|
29
66
|
ipAddress?: string;
|
|
67
|
+
expiresAt?: Date;
|
|
68
|
+
metadata?: Record<string, unknown>;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
token: string;
|
|
71
|
+
session: Session;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* Rotate a user's session to prevent session fixation attacks.
|
|
75
|
+
*
|
|
76
|
+
* Deletes the old session (by token hash) or all sessions for the user,
|
|
77
|
+
* then creates a fresh session with a new token.
|
|
78
|
+
*
|
|
79
|
+
* @param userId - User ID to rotate sessions for
|
|
80
|
+
* @param options - Rotation options
|
|
81
|
+
* @returns New session token and session data
|
|
82
|
+
*/
|
|
83
|
+
export declare function rotateSession(userId: string, options?: {
|
|
84
|
+
/** Raw token of the old session to invalidate. When omitted, all user sessions are deleted. */
|
|
85
|
+
oldSessionToken?: string;
|
|
86
|
+
persistent?: boolean;
|
|
87
|
+
userAgent?: string;
|
|
88
|
+
ipAddress?: string;
|
|
30
89
|
}): Promise<{
|
|
31
90
|
token: string;
|
|
32
91
|
session: Session;
|
|
@@ -43,5 +102,10 @@ export declare function deleteSession(headers: Headers): Promise<boolean>;
|
|
|
43
102
|
*
|
|
44
103
|
* @param userId - User ID
|
|
45
104
|
*/
|
|
105
|
+
/**
|
|
106
|
+
* Delete all sessions for a user EXCEPT the specified session.
|
|
107
|
+
* Used for "sign out all other devices" functionality.
|
|
108
|
+
*/
|
|
109
|
+
export declare function deleteOtherUserSessions(userId: string, exceptSessionId: string): Promise<void>;
|
|
46
110
|
export declare function deleteAllUserSessions(userId: string): Promise<void>;
|
|
47
111
|
//# sourceMappingURL=session.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAQjD,MAAM,WAAW,oBAAoB;IACnC,iEAAiE;IACjE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,+EAA+E;IAC/E,SAAS,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,cAAc,EAAE,OAAO,CAAC;CACzB;AAUD,mFAAmF;AACnF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,OAAO,CAAC,oBAAoB,CAAC,GAAG,IAAI,CAEtF;AAED,qCAAqC;AACrC,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD;AAMD,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI,CA0B3F;AAMD,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAI1E;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,OAAO,EAChB,cAAc,CAAC,EAAE,cAAc,GAC9B,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAkH7B;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,GACA,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAmE9C;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;IACR,+FAA+F;IAC/F,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAoC9C;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAoBtE;AAED;;;;GAIG;AACH;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,MAAM,EACd,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBzE"}
|