@devmoods/express-extras 0.48.1 → 0.49.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/auth/emailAndPassword.d.ts +10 -5
- package/dist/auth/emailAndPassword.js +50 -15
- package/dist/auth/passwordless.js +7 -12
- package/dist/auth/token.d.ts +12 -2
- package/dist/auth/token.js +22 -8
- package/dist/auth/types.d.ts +28 -14
- package/dist/auth/types.js +3 -1
- package/dist/auth.d.ts +12 -8
- package/dist/auth.js +71 -30
- package/dist/cache.d.ts +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +9 -0
- package/dist/email.d.ts +3 -3
- package/dist/email.js +6 -2
- package/dist/listen.js +10 -2
- package/dist/middleware.d.ts +2 -6
- package/dist/middleware.js +7 -5
- package/dist/utils.d.ts +6 -1
- package/dist/utils.js +6 -1
- package/dist/vite/defineViteConfig.d.ts +2 -1
- package/dist/vite/defineViteConfig.js +10 -1
- package/dist/vite/ssr.d.ts +1 -2
- package/dist/vite/ssr.js +6 -3
- package/package.json +20 -20
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { type AnyUser, type AuthProvider } from './types.js';
|
|
2
2
|
export interface EmailAndPasswordOptions<UserModel = AnyUser> {
|
|
3
|
-
|
|
4
|
-
login: (email: string, verifyPassword: (realPassword: string) => Promise<boolean>) => Promise<UserModel>;
|
|
3
|
+
login: (email: string, verifyPassword: (realPassword: string) => Promise<string>) => Promise<UserModel>;
|
|
5
4
|
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
6
5
|
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
7
6
|
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
@@ -9,10 +8,16 @@ export interface EmailAndPasswordOptions<UserModel = AnyUser> {
|
|
|
9
8
|
}
|
|
10
9
|
export declare function EmailAndPasswordAuthProvider<UserModel extends AnyUser>(options: EmailAndPasswordOptions<UserModel>): AuthProvider<UserModel>;
|
|
11
10
|
/**
|
|
12
|
-
* Compare
|
|
11
|
+
* Compare password against a stored hash.
|
|
12
|
+
*
|
|
13
|
+
* Supports both bcrypt and argon2id. A new argon2id hash will be returned
|
|
14
|
+
* when using bcrypt to allow gradually migrating. If the returned hash
|
|
15
|
+
* does not equal the currently stored hash in your db, you should update it.
|
|
16
|
+
*
|
|
17
|
+
* Returns a password hash.
|
|
13
18
|
*/
|
|
14
|
-
export declare function checkIfPasswordsMatch(
|
|
19
|
+
export declare function checkIfPasswordsMatch(hashedUserPassword: string, attemptedPassword: string): Promise<string>;
|
|
15
20
|
/**
|
|
16
21
|
* Returns a hash of a password suitable for storage.
|
|
17
22
|
*/
|
|
18
|
-
export declare function hashPassword(password: string): Promise<string>;
|
|
23
|
+
export declare function hashPassword(password: string, useArgon2Id?: boolean): Promise<string>;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import argon2 from 'argon2';
|
|
1
2
|
import bcrypt from 'bcrypt';
|
|
2
|
-
import { tokenSchema } from './token.js';
|
|
3
|
+
import { createTokenResponse, tokenSchema } from './token.js';
|
|
3
4
|
import {} from './types.js';
|
|
4
5
|
import { Unauthorized } from '../errors.js';
|
|
5
6
|
import { getLogger } from '../logger.js';
|
|
6
7
|
import { route } from '../middleware.js';
|
|
8
|
+
import { startTimer } from '../utils.js';
|
|
7
9
|
import { validateBody } from '../validate.js';
|
|
8
10
|
const passwordSchema = { type: 'string', minLength: 4 };
|
|
9
11
|
const emailSchema = { type: 'string', minLength: 4 };
|
|
10
12
|
const logger = getLogger('auth');
|
|
11
13
|
export function EmailAndPasswordAuthProvider(options) {
|
|
12
|
-
const skipPasswordCheck = async () =>
|
|
14
|
+
const skipPasswordCheck = async () => '';
|
|
13
15
|
return {
|
|
14
16
|
setupRoutes(router, context) {
|
|
15
17
|
router.post('/login', validateBody({
|
|
@@ -19,15 +21,19 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
19
21
|
password: passwordSchema,
|
|
20
22
|
},
|
|
21
23
|
required: ['email', 'password'],
|
|
22
|
-
}), route(async (
|
|
24
|
+
}), route(async (req, res) => {
|
|
25
|
+
const { email, password } = req.body;
|
|
26
|
+
const timer = startTimer();
|
|
23
27
|
try {
|
|
24
28
|
const user = await options.login(email, (realPassword) => checkIfPasswordsMatch(realPassword, password));
|
|
25
|
-
const
|
|
29
|
+
const accessToken = await context.login(req, res, user);
|
|
30
|
+
await context.jitter(timer.getDuration());
|
|
26
31
|
logger.info('access token created');
|
|
27
|
-
return
|
|
32
|
+
return createTokenResponse(accessToken);
|
|
28
33
|
}
|
|
29
34
|
catch (error) {
|
|
30
35
|
logger.error(error);
|
|
36
|
+
await context.jitter(timer.getDuration());
|
|
31
37
|
throw Unauthorized(error.message);
|
|
32
38
|
}
|
|
33
39
|
}));
|
|
@@ -38,7 +44,7 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
38
44
|
}), route(async (req) => {
|
|
39
45
|
const { password, token } = req.body;
|
|
40
46
|
const decoded = context.decodeTemporaryToken(token);
|
|
41
|
-
const user = await
|
|
47
|
+
const user = await context.getUserById(decoded.payload.uid);
|
|
42
48
|
try {
|
|
43
49
|
await context.verifyTemporaryToken(token, user);
|
|
44
50
|
}
|
|
@@ -56,9 +62,11 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
56
62
|
required: ['email'],
|
|
57
63
|
}), route(async (req) => {
|
|
58
64
|
const { email } = req.body;
|
|
65
|
+
const timer = startTimer();
|
|
59
66
|
const user = await options.login(email, skipPasswordCheck);
|
|
60
67
|
const temporaryToken = await context.createTemporaryToken(user);
|
|
61
68
|
await options.onEmailVerificationRequest?.(user, temporaryToken);
|
|
69
|
+
await context.jitter(timer.getDuration());
|
|
62
70
|
return 202;
|
|
63
71
|
}));
|
|
64
72
|
router.post('/verify-email', validateBody({
|
|
@@ -71,7 +79,12 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
71
79
|
try {
|
|
72
80
|
await context.verifyTemporaryToken(token, user);
|
|
73
81
|
}
|
|
74
|
-
catch {
|
|
82
|
+
catch (error) {
|
|
83
|
+
logger.warn('failed to verify token', {
|
|
84
|
+
token,
|
|
85
|
+
user: context.filterUser(user),
|
|
86
|
+
error: error.message,
|
|
87
|
+
});
|
|
75
88
|
throw Unauthorized('Invalid token');
|
|
76
89
|
}
|
|
77
90
|
context.setCurrentUser(user);
|
|
@@ -85,9 +98,11 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
85
98
|
required: ['email'],
|
|
86
99
|
}), route(async (req) => {
|
|
87
100
|
const { email } = req.body;
|
|
101
|
+
const timer = startTimer();
|
|
88
102
|
try {
|
|
89
103
|
const user = await options.login(email, skipPasswordCheck);
|
|
90
104
|
const temporaryToken = await context.createTemporaryToken(user);
|
|
105
|
+
await context.jitter(timer.getDuration());
|
|
91
106
|
await options.onPasswordResetRequest?.(user, temporaryToken);
|
|
92
107
|
logger.info('password reset request', { email });
|
|
93
108
|
}
|
|
@@ -100,21 +115,41 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
100
115
|
};
|
|
101
116
|
}
|
|
102
117
|
/**
|
|
103
|
-
* Compare
|
|
118
|
+
* Compare password against a stored hash.
|
|
119
|
+
*
|
|
120
|
+
* Supports both bcrypt and argon2id. A new argon2id hash will be returned
|
|
121
|
+
* when using bcrypt to allow gradually migrating. If the returned hash
|
|
122
|
+
* does not equal the currently stored hash in your db, you should update it.
|
|
123
|
+
*
|
|
124
|
+
* Returns a password hash.
|
|
104
125
|
*/
|
|
105
|
-
export async function checkIfPasswordsMatch(
|
|
106
|
-
if (!
|
|
126
|
+
export async function checkIfPasswordsMatch(hashedUserPassword, attemptedPassword) {
|
|
127
|
+
if (!hashedUserPassword) {
|
|
107
128
|
throw new Error('User has no defined password');
|
|
108
129
|
}
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
130
|
+
// Migrate from bcrypt to argon2id
|
|
131
|
+
if (hashedUserPassword.startsWith('$2')) {
|
|
132
|
+
const validPassword = await bcrypt.compare(attemptedPassword, hashedUserPassword);
|
|
133
|
+
if (!validPassword) {
|
|
134
|
+
throw new Error(`Passwords do not match`);
|
|
135
|
+
}
|
|
136
|
+
// Return the new argon2id hash
|
|
137
|
+
return hashPassword(attemptedPassword);
|
|
112
138
|
}
|
|
113
|
-
|
|
139
|
+
else {
|
|
140
|
+
const validPassword = await argon2.verify(hashedUserPassword, attemptedPassword);
|
|
141
|
+
if (!validPassword) {
|
|
142
|
+
throw new Error(`Passwords do not match`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return hashedUserPassword;
|
|
114
146
|
}
|
|
115
147
|
/**
|
|
116
148
|
* Returns a hash of a password suitable for storage.
|
|
117
149
|
*/
|
|
118
|
-
export function hashPassword(password) {
|
|
150
|
+
export function hashPassword(password, useArgon2Id = true) {
|
|
151
|
+
if (useArgon2Id) {
|
|
152
|
+
return argon2.hash(password);
|
|
153
|
+
}
|
|
119
154
|
return bcrypt.hash(password, 10);
|
|
120
155
|
}
|
|
@@ -1,34 +1,29 @@
|
|
|
1
|
+
import { createTokenResponse } from './token.js';
|
|
1
2
|
import {} from './types.js';
|
|
2
3
|
import { Unauthorized } from '../errors.js';
|
|
3
4
|
import { getLogger } from '../logger.js';
|
|
4
5
|
import { route } from '../middleware.js';
|
|
5
|
-
import { validateBody
|
|
6
|
+
import { validateBody } from '../validate.js';
|
|
6
7
|
const logger = getLogger('auth');
|
|
7
8
|
export function PasswordlessAuthProvider(options) {
|
|
8
9
|
return {
|
|
9
10
|
setupRoutes(router, context) {
|
|
10
|
-
router.
|
|
11
|
-
type: 'object',
|
|
12
|
-
properties: {
|
|
13
|
-
email: { type: 'string', minLength: 3 },
|
|
14
|
-
},
|
|
15
|
-
required: ['email'],
|
|
16
|
-
}), route(async (req, res) => {
|
|
11
|
+
router.post('/passwordless/login/:token', route(async (req, res) => {
|
|
17
12
|
const { token: temporaryToken } = req.params;
|
|
18
|
-
const
|
|
19
|
-
const user = await
|
|
13
|
+
const decoded = context.decodeTemporaryToken(temporaryToken);
|
|
14
|
+
const user = await context.getUserById(decoded.payload.uid);
|
|
20
15
|
try {
|
|
21
16
|
await context.verifyTemporaryToken(temporaryToken, user);
|
|
22
17
|
}
|
|
23
18
|
catch {
|
|
24
19
|
throw Unauthorized('Invalid token');
|
|
25
20
|
}
|
|
26
|
-
const
|
|
21
|
+
const accessToken = await context.login(req, res, user);
|
|
27
22
|
logger.info('passwordless login');
|
|
28
23
|
if (req.accepts('html')) {
|
|
29
24
|
return res.redirect('/');
|
|
30
25
|
}
|
|
31
|
-
return
|
|
26
|
+
return createTokenResponse(accessToken);
|
|
32
27
|
}));
|
|
33
28
|
/**
|
|
34
29
|
* Request a one-time password for login.
|
package/dist/auth/token.d.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { type Response, type Request } from 'express';
|
|
2
2
|
import { type ValidationSchema } from '../validate.js';
|
|
3
3
|
export declare const tokenSchema: ValidationSchema;
|
|
4
|
+
export declare function createTokenResponse(accessToken: AccessToken): readonly [201, {
|
|
5
|
+
readonly token: string;
|
|
6
|
+
}, {
|
|
7
|
+
readonly 'Cache-Control': "no-store";
|
|
8
|
+
}];
|
|
9
|
+
export type AccessToken = ReturnType<typeof createAccessToken>;
|
|
4
10
|
/**
|
|
5
11
|
* Returns a signed JWT token.
|
|
6
12
|
*/
|
|
7
|
-
export declare function
|
|
13
|
+
export declare function createAccessToken(payload: any, version?: string, secret?: string): {
|
|
14
|
+
token: string;
|
|
15
|
+
tokenType: "Bearer";
|
|
16
|
+
expiresIn: number;
|
|
17
|
+
};
|
|
8
18
|
/**
|
|
9
19
|
* Decode the given JWT token.
|
|
10
20
|
*/
|
|
11
|
-
export declare function
|
|
21
|
+
export declare function decodeAccessToken(token: string, secret?: string): {
|
|
12
22
|
[x: string]: any;
|
|
13
23
|
iss?: string | undefined;
|
|
14
24
|
sub?: string | undefined;
|
package/dist/auth/token.js
CHANGED
|
@@ -4,23 +4,37 @@ import { v4 } from 'uuid';
|
|
|
4
4
|
import { InvalidToken } from './errors.js';
|
|
5
5
|
import { config } from '../config.js';
|
|
6
6
|
import {} from '../validate.js';
|
|
7
|
+
const ACCESS_TOKEN_COOKIE_NAME = 'access_token';
|
|
7
8
|
export const tokenSchema = {
|
|
8
9
|
type: 'string',
|
|
9
10
|
minLength: 6,
|
|
10
11
|
maxLength: 256,
|
|
11
12
|
};
|
|
13
|
+
export function createTokenResponse(accessToken) {
|
|
14
|
+
return [
|
|
15
|
+
201,
|
|
16
|
+
{
|
|
17
|
+
token: accessToken.token,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
'Cache-Control': 'no-store',
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
}
|
|
12
24
|
/**
|
|
13
25
|
* Returns a signed JWT token.
|
|
14
26
|
*/
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
export function createAccessToken(payload, version = '1', secret = config.value.AUTH_SECRET) {
|
|
28
|
+
const expiresIn = config.value.AUTH_TOKEN_EXPIRES_IN;
|
|
29
|
+
const token = jwt.sign({ v: version, ...payload }, secret, {
|
|
30
|
+
expiresIn,
|
|
18
31
|
});
|
|
32
|
+
return { token, tokenType: 'Bearer', expiresIn };
|
|
19
33
|
}
|
|
20
34
|
/**
|
|
21
35
|
* Decode the given JWT token.
|
|
22
36
|
*/
|
|
23
|
-
export function
|
|
37
|
+
export function decodeAccessToken(token, secret = config.value.AUTH_SECRET) {
|
|
24
38
|
try {
|
|
25
39
|
const decoded = jwt.verify(token.trim(), secret);
|
|
26
40
|
/* c8 ignore next 3 */
|
|
@@ -70,8 +84,8 @@ export function extractAccessTokenFromRequest(req) {
|
|
|
70
84
|
if (authorizationHeader) {
|
|
71
85
|
return authorizationHeader;
|
|
72
86
|
}
|
|
73
|
-
if (req.cookies &&
|
|
74
|
-
return req.cookies[
|
|
87
|
+
if (req.cookies && ACCESS_TOKEN_COOKIE_NAME in req.cookies) {
|
|
88
|
+
return req.cookies[ACCESS_TOKEN_COOKIE_NAME];
|
|
75
89
|
}
|
|
76
90
|
return null;
|
|
77
91
|
}
|
|
@@ -82,14 +96,14 @@ export function extractAccessTokenFromRequest(req) {
|
|
|
82
96
|
*/
|
|
83
97
|
export function setAccessTokenCookie(res, token) {
|
|
84
98
|
if (token == null) {
|
|
85
|
-
res.cookie(
|
|
99
|
+
res.cookie(ACCESS_TOKEN_COOKIE_NAME, v4(), {
|
|
86
100
|
expires: new Date(1970, 0, 1),
|
|
87
101
|
sameSite: 'strict',
|
|
88
102
|
secure: process.env.NODE_ENV === 'production',
|
|
89
103
|
});
|
|
90
104
|
}
|
|
91
105
|
else {
|
|
92
|
-
res.cookie(
|
|
106
|
+
res.cookie(ACCESS_TOKEN_COOKIE_NAME, token, {
|
|
93
107
|
httpOnly: true,
|
|
94
108
|
sameSite: 'strict',
|
|
95
109
|
secure: process.env.NODE_ENV === 'production',
|
package/dist/auth/types.d.ts
CHANGED
|
@@ -1,32 +1,46 @@
|
|
|
1
|
-
import { type RequestHandler, type Router, type Response } from 'express';
|
|
2
|
-
import { type
|
|
1
|
+
import { type RequestHandler, type Router, type Request, type Response } from 'express';
|
|
2
|
+
import { type AccessToken } from './token.js';
|
|
3
3
|
export interface AnyUser {
|
|
4
4
|
id: string | number;
|
|
5
5
|
[key: string]: any;
|
|
6
6
|
}
|
|
7
|
+
export declare abstract class TokenStorage {
|
|
8
|
+
abstract set(token: string, userId: string, expiresIn: number): Promise<void>;
|
|
9
|
+
abstract get(token: string): Promise<string | null>;
|
|
10
|
+
abstract invalidate(token: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface AuthLoginMetadata {
|
|
13
|
+
ip: string;
|
|
14
|
+
userAgent: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
}
|
|
7
17
|
export interface AuthOptions<UserModel extends AnyUser> {
|
|
8
|
-
|
|
18
|
+
getUserById(id: string): Promise<UserModel>;
|
|
9
19
|
providers: AuthProvider<UserModel>[];
|
|
10
|
-
|
|
20
|
+
tokenStorage: TokenStorage;
|
|
11
21
|
filterUser(user: UserModel): Partial<UserModel>;
|
|
12
22
|
loginUrl?: string;
|
|
23
|
+
onUserLoggedIn?: (user: UserModel, metadata: AuthLoginMetadata) => Promise<void>;
|
|
24
|
+
sessionTokenVersion?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface DecodedTemporaryToken {
|
|
27
|
+
payload: {
|
|
28
|
+
v: '1';
|
|
29
|
+
uid: string;
|
|
30
|
+
};
|
|
31
|
+
signature: string;
|
|
13
32
|
}
|
|
14
33
|
export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
15
34
|
filterUser: AuthOptions<UserModel>['filterUser'];
|
|
16
|
-
|
|
35
|
+
tokenStorage: AuthOptions<UserModel>['tokenStorage'];
|
|
17
36
|
setCurrentUser: (user: UserModel) => void;
|
|
18
37
|
createTemporaryToken(user: UserModel): Promise<string>;
|
|
19
38
|
verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
|
|
20
|
-
decodeTemporaryToken(token: string):
|
|
21
|
-
payload: {
|
|
22
|
-
uid: string;
|
|
23
|
-
};
|
|
24
|
-
signature: string;
|
|
25
|
-
};
|
|
39
|
+
decodeTemporaryToken(token: string): DecodedTemporaryToken;
|
|
26
40
|
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
27
|
-
login: (res: Response, user: UserModel) => Promise<
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
login: (req: Request, res: Response, user: UserModel) => Promise<AccessToken>;
|
|
42
|
+
getUserById(id: string): Promise<UserModel>;
|
|
43
|
+
jitter(duration?: number): Promise<void>;
|
|
30
44
|
}
|
|
31
45
|
export interface AuthProvider<UserModel extends AnyUser> {
|
|
32
46
|
setupRoutes(router: Router, context: AuthProviderContext<UserModel>): void;
|
package/dist/auth/types.js
CHANGED
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RequestHandler, Router } from 'express';
|
|
2
|
-
import { type AnyUser, type AuthMiddlewareOptions, type AuthOptions } from './auth/types.js';
|
|
2
|
+
import { TokenStorage, type AnyUser, type AuthMiddlewareOptions, type AuthOptions, type DecodedTemporaryToken } from './auth/types.js';
|
|
3
|
+
import { type RedisClient } from './redis.js';
|
|
3
4
|
export * from './auth/types.js';
|
|
4
5
|
export * from './auth/emailAndPassword.js';
|
|
5
6
|
export * from './auth/passwordless.js';
|
|
@@ -10,11 +11,14 @@ export interface CreateAuth<UserModel extends AnyUser> {
|
|
|
10
11
|
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
11
12
|
createTemporaryToken(user: UserModel): Promise<string>;
|
|
12
13
|
verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
|
|
13
|
-
decodeTemporaryToken(token: string):
|
|
14
|
-
payload: {
|
|
15
|
-
uid: string;
|
|
16
|
-
};
|
|
17
|
-
signature: string;
|
|
18
|
-
};
|
|
14
|
+
decodeTemporaryToken(token: string): DecodedTemporaryToken;
|
|
19
15
|
}
|
|
20
|
-
export declare
|
|
16
|
+
export declare class RedisTokenStorage extends TokenStorage {
|
|
17
|
+
private readonly redis;
|
|
18
|
+
private readonly keyPrefix;
|
|
19
|
+
constructor(redis: RedisClient, keyPrefix?: string);
|
|
20
|
+
set(token: string, userId: string, expiresIn: number): Promise<void>;
|
|
21
|
+
get(token: string): Promise<string | null>;
|
|
22
|
+
invalidate(token: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function createAuth<UserModel extends AnyUser>({ getUserById, filterUser, tokenStorage, providers, loginUrl, onUserLoggedIn, sessionTokenVersion, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
|
package/dist/auth.js
CHANGED
|
@@ -1,41 +1,75 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
|
-
import { Router } from 'express';
|
|
2
|
+
import { Router, } from 'express';
|
|
3
3
|
import { InvalidToken, PermissionDenied } from './auth/errors.js';
|
|
4
|
-
import {
|
|
5
|
-
import {} from './auth/types.js';
|
|
4
|
+
import { createAccessToken, decodeAccessToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
|
|
5
|
+
import { TokenStorage, } from './auth/types.js';
|
|
6
6
|
import { config } from './config.js';
|
|
7
7
|
import { setContext, useContext } from './context.js';
|
|
8
8
|
import { Forbidden, NotFound, Unauthorized } from './errors.js';
|
|
9
9
|
import { getLogger } from './logger.js';
|
|
10
10
|
import { route } from './middleware.js';
|
|
11
|
+
import {} from './redis.js';
|
|
11
12
|
import { validateParams } from './validate.js';
|
|
12
13
|
const logger = getLogger('auth');
|
|
13
14
|
export * from './auth/types.js';
|
|
14
15
|
export * from './auth/emailAndPassword.js';
|
|
15
16
|
export * from './auth/passwordless.js';
|
|
16
17
|
export * from './auth/token.js';
|
|
17
|
-
export
|
|
18
|
+
export class RedisTokenStorage extends TokenStorage {
|
|
19
|
+
constructor(redis, keyPrefix = 'auth:') {
|
|
20
|
+
super();
|
|
21
|
+
this.redis = redis;
|
|
22
|
+
this.keyPrefix = keyPrefix;
|
|
23
|
+
}
|
|
24
|
+
async set(token, userId, expiresIn) {
|
|
25
|
+
await this.redis.set(`${this.keyPrefix}${token}`, userId, {
|
|
26
|
+
EX: expiresIn,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async get(token) {
|
|
30
|
+
return this.redis.get(`${this.keyPrefix}${token}`);
|
|
31
|
+
}
|
|
32
|
+
async invalidate(token) {
|
|
33
|
+
await this.redis.del(`${this.keyPrefix}${token}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
37
|
+
export function createAuth({ getUserById, filterUser, tokenStorage, providers, loginUrl = '/', onUserLoggedIn, sessionTokenVersion = '1', }) {
|
|
18
38
|
const useCurrentUser = () => useContext('currentUser');
|
|
19
39
|
const setCurrentUser = (user) => setContext('currentUser', user);
|
|
20
|
-
const login = async (res, user) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
const login = async (req, res, user) => {
|
|
41
|
+
const accessToken = createAccessToken({ uid: user.id }, sessionTokenVersion);
|
|
42
|
+
const sessionId = sha256(accessToken.token);
|
|
43
|
+
await tokenStorage.set(sessionId, String(user.id), accessToken.expiresIn);
|
|
44
|
+
setAccessTokenCookie(res, accessToken.token);
|
|
24
45
|
setCurrentUser(user);
|
|
25
|
-
|
|
46
|
+
await onUserLoggedIn?.(user, {
|
|
47
|
+
ip: req.ip ?? '',
|
|
48
|
+
userAgent: req.headers['user-agent'] ?? '',
|
|
49
|
+
sessionId,
|
|
50
|
+
});
|
|
51
|
+
return accessToken;
|
|
52
|
+
};
|
|
53
|
+
const logout = async (res, sessionId) => {
|
|
54
|
+
await tokenStorage.invalidate(sessionId);
|
|
55
|
+
setAccessTokenCookie(res, null);
|
|
26
56
|
};
|
|
57
|
+
// Make timing attacks harder
|
|
58
|
+
const jitter = (duration = 0) => new Promise((resolve) => void setTimeout(resolve, Math.max((0.5 - duration) * 1000, 200)));
|
|
27
59
|
/**
|
|
28
60
|
* API for auth providers to use
|
|
29
61
|
*/
|
|
30
62
|
const providerContext = {
|
|
31
63
|
filterUser,
|
|
32
|
-
|
|
64
|
+
tokenStorage,
|
|
33
65
|
setCurrentUser,
|
|
34
66
|
createTemporaryToken,
|
|
35
67
|
verifyTemporaryToken,
|
|
36
68
|
decodeTemporaryToken,
|
|
37
69
|
middleware,
|
|
38
70
|
login,
|
|
71
|
+
getUserById,
|
|
72
|
+
jitter,
|
|
39
73
|
};
|
|
40
74
|
const createRouter = () => {
|
|
41
75
|
const router = Router();
|
|
@@ -46,9 +80,8 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
46
80
|
router.get('/logout', route(async (req, res) => {
|
|
47
81
|
const token = extractAccessTokenFromRequest(req);
|
|
48
82
|
if (token) {
|
|
49
|
-
await
|
|
83
|
+
await logout(res, sha256(token));
|
|
50
84
|
}
|
|
51
|
-
setAccessTokenCookie(res, null);
|
|
52
85
|
res.redirect(loginUrl);
|
|
53
86
|
}));
|
|
54
87
|
/**
|
|
@@ -60,8 +93,8 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
60
93
|
required: ['token'],
|
|
61
94
|
}), route(async (req) => {
|
|
62
95
|
const { token } = req.params;
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
96
|
+
const value = await tokenStorage.get(token);
|
|
97
|
+
if (!value) {
|
|
65
98
|
logger.info('invalid reset token', { token });
|
|
66
99
|
throw NotFound();
|
|
67
100
|
}
|
|
@@ -73,7 +106,7 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
73
106
|
setAccessTokenCookie(res, null);
|
|
74
107
|
throw Unauthorized();
|
|
75
108
|
}
|
|
76
|
-
const { token } = await providerContext.login(res, user);
|
|
109
|
+
const { token } = await providerContext.login(req, res, user);
|
|
77
110
|
logger.info('refreshed token');
|
|
78
111
|
return { token, user: filterUser(user) };
|
|
79
112
|
}));
|
|
@@ -97,20 +130,27 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
97
130
|
}
|
|
98
131
|
throw new InvalidToken('token missing');
|
|
99
132
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
133
|
+
const sessionId = sha256(token);
|
|
134
|
+
const value = await tokenStorage.get(sessionId);
|
|
135
|
+
if (!value) {
|
|
102
136
|
if (!authenticate) {
|
|
103
137
|
logger.warn('request with invalid access token');
|
|
104
138
|
return next();
|
|
105
139
|
}
|
|
106
|
-
|
|
140
|
+
await logout(res, sessionId);
|
|
141
|
+
throw new InvalidToken('session token expired');
|
|
142
|
+
}
|
|
143
|
+
const decoded = decodeAccessToken(token);
|
|
144
|
+
if (decoded.v !== sessionTokenVersion) {
|
|
145
|
+
await logout(res, token);
|
|
146
|
+
throw new InvalidToken('Invalid token version');
|
|
107
147
|
}
|
|
108
|
-
const decoded = decodeAuthToken(token);
|
|
109
148
|
let user;
|
|
110
149
|
try {
|
|
111
|
-
user = await
|
|
150
|
+
user = await getUserById(decoded.uid);
|
|
112
151
|
}
|
|
113
152
|
catch {
|
|
153
|
+
await logout(res, token);
|
|
114
154
|
throw new InvalidToken('User not found');
|
|
115
155
|
}
|
|
116
156
|
if (options.isAuthorized && !(await options.isAuthorized(user))) {
|
|
@@ -131,7 +171,7 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
131
171
|
if (req.accepts('html')) {
|
|
132
172
|
return res.redirect(loginUrl);
|
|
133
173
|
}
|
|
134
|
-
return next(Unauthorized(
|
|
174
|
+
return next(Unauthorized(`Invalid token: ${error.message}`));
|
|
135
175
|
}
|
|
136
176
|
next(error);
|
|
137
177
|
}
|
|
@@ -143,17 +183,18 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
143
183
|
*/
|
|
144
184
|
async function createTemporaryToken(user) {
|
|
145
185
|
try {
|
|
146
|
-
const randomBytes = crypto.randomBytes(
|
|
147
|
-
const payload = { uid: user.id, r: randomBytes };
|
|
186
|
+
const randomBytes = crypto.randomBytes(32).toString('hex');
|
|
187
|
+
const payload = { v: '1', uid: user.id, r: randomBytes };
|
|
148
188
|
const signature = crypto
|
|
149
189
|
.createHmac('sha256', config.value.AUTH_SECRET)
|
|
150
190
|
.update(JSON.stringify(payload))
|
|
151
191
|
.digest('base64')
|
|
152
192
|
.slice(0, 12);
|
|
153
193
|
const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
194
|
+
if (!user.id) {
|
|
195
|
+
throw new Error('User ID is required to create a token');
|
|
196
|
+
}
|
|
197
|
+
await tokenStorage.set(temporaryToken, String(user.id), config.value.AUTH_TEMP_TOKEN_EXPIRES_IN);
|
|
157
198
|
return temporaryToken;
|
|
158
199
|
}
|
|
159
200
|
catch (error) {
|
|
@@ -175,13 +216,13 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
175
216
|
throw new InvalidToken('Invalid signature');
|
|
176
217
|
}
|
|
177
218
|
if (decoded.payload.uid !== user.id) {
|
|
178
|
-
throw new InvalidToken(
|
|
219
|
+
throw new InvalidToken(`Invalid user ${user.id}`);
|
|
179
220
|
}
|
|
180
|
-
const storedUid = await
|
|
221
|
+
const storedUid = await tokenStorage.get(token);
|
|
181
222
|
if (storedUid !== user.id) {
|
|
182
|
-
throw new InvalidToken(
|
|
223
|
+
throw new InvalidToken(`Invalid user ${user.id}`);
|
|
183
224
|
}
|
|
184
|
-
await
|
|
225
|
+
await tokenStorage.invalidate(token);
|
|
185
226
|
}
|
|
186
227
|
return {
|
|
187
228
|
createRouter,
|
package/dist/cache.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface RedisCacheAdapterOptions {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function createRedisCacheAdapter(redis: RedisClient, options?: RedisCacheAdapterOptions): CacheAdapter;
|
|
15
15
|
export declare function createMemoryCacheAdapter(): CacheAdapter;
|
|
16
|
+
export type Cache = ReturnType<typeof createCache>;
|
|
16
17
|
export declare function createCache(adapter: CacheAdapter): {
|
|
17
18
|
set: (key: string, value: any, expires?: number | null | undefined) => Promise<void>;
|
|
18
19
|
get: (key: string) => Promise<any>;
|
package/dist/config.d.ts
CHANGED
|
@@ -17,6 +17,15 @@ export declare const config: {
|
|
|
17
17
|
GRACE_PERIOD_MS: number;
|
|
18
18
|
SLACK_WEBHOOK_URL: string;
|
|
19
19
|
VITE_USE_DEV_SERVER: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Email configuration
|
|
22
|
+
*/
|
|
23
|
+
EMAIL_HOST: string;
|
|
24
|
+
EMAIL_PORT: string;
|
|
25
|
+
EMAIL_USER: string;
|
|
26
|
+
EMAIL_PASS: string;
|
|
27
|
+
EMAIL_SENDER_NAME: string;
|
|
28
|
+
EMAIL_SENDER_ADDRESS: string;
|
|
20
29
|
};
|
|
21
30
|
};
|
|
22
31
|
export declare function extendConfig<T extends Record<string, unknown>>(appConfig: T): {
|
|
@@ -38,5 +47,14 @@ export declare function extendConfig<T extends Record<string, unknown>>(appConfi
|
|
|
38
47
|
GRACE_PERIOD_MS: number;
|
|
39
48
|
SLACK_WEBHOOK_URL: string;
|
|
40
49
|
VITE_USE_DEV_SERVER: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Email configuration
|
|
52
|
+
*/
|
|
53
|
+
EMAIL_HOST: string;
|
|
54
|
+
EMAIL_PORT: string;
|
|
55
|
+
EMAIL_USER: string;
|
|
56
|
+
EMAIL_PASS: string;
|
|
57
|
+
EMAIL_SENDER_NAME: string;
|
|
58
|
+
EMAIL_SENDER_ADDRESS: string;
|
|
41
59
|
} & T;
|
|
42
60
|
};
|
package/dist/config.js
CHANGED
|
@@ -20,6 +20,15 @@ const baseConfig = {
|
|
|
20
20
|
GRACE_PERIOD_MS: readEnv.number('GRACE_PERIOD_MS', 20000),
|
|
21
21
|
SLACK_WEBHOOK_URL: readEnv('SLACK_WEBHOOK_URL', ''),
|
|
22
22
|
VITE_USE_DEV_SERVER: readEnv.boolean('VITE_USE_DEV_SERVER', false),
|
|
23
|
+
/**
|
|
24
|
+
* Email configuration
|
|
25
|
+
*/
|
|
26
|
+
EMAIL_HOST: readEnv('EMAIL_HOST', ''),
|
|
27
|
+
EMAIL_PORT: readEnv('EMAIL_PORT', '587'),
|
|
28
|
+
EMAIL_USER: readEnv('EMAIL_USER', ''),
|
|
29
|
+
EMAIL_PASS: readEnv('EMAIL_PASS', ''),
|
|
30
|
+
EMAIL_SENDER_NAME: readEnv('EMAIL_SENDER_NAME', ''),
|
|
31
|
+
EMAIL_SENDER_ADDRESS: readEnv('EMAIL_SENDER_ADDRESS', ''),
|
|
23
32
|
};
|
|
24
33
|
export const config = lazy(() => baseConfig);
|
|
25
34
|
export function extendConfig(appConfig) {
|
package/dist/email.d.ts
CHANGED
|
@@ -4,14 +4,14 @@ export interface EmailOptions {
|
|
|
4
4
|
port?: string;
|
|
5
5
|
user?: string;
|
|
6
6
|
pass?: string;
|
|
7
|
-
senderName
|
|
8
|
-
senderAddress
|
|
7
|
+
senderName?: string;
|
|
8
|
+
senderAddress?: string;
|
|
9
9
|
onEmailSent?: (payload: Record<string, any>) => void | Promise<void>;
|
|
10
10
|
}
|
|
11
11
|
export interface SendEmailOptions extends nodemailer.SendMailOptions {
|
|
12
12
|
message: string;
|
|
13
13
|
}
|
|
14
|
-
export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }
|
|
14
|
+
export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }?: EmailOptions): {
|
|
15
15
|
send: ({ to, subject, message, ...rest }: SendEmailOptions) => Promise<void>;
|
|
16
16
|
transporter: nodemailer.Transporter<import("nodemailer/lib/smtp-transport/index.js").SentMessageInfo, import("nodemailer/lib/smtp-transport/index.js").Options>;
|
|
17
17
|
};
|
package/dist/email.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import * as nodemailer from 'nodemailer';
|
|
2
|
+
import { config } from './config.js';
|
|
2
3
|
import { getLogger } from './logger.js';
|
|
3
4
|
const logger = getLogger('email');
|
|
4
|
-
export function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }) {
|
|
5
|
+
export function createEmail({ host = config.value.EMAIL_HOST, port = config.value.EMAIL_PORT, user = config.value.EMAIL_USER, pass = config.value.EMAIL_PASS, senderName = config.value.EMAIL_SENDER_NAME, senderAddress = config.value.EMAIL_SENDER_ADDRESS, onEmailSent, } = {}) {
|
|
5
6
|
const transporter = nodemailer.createTransport({
|
|
6
7
|
host,
|
|
7
8
|
port: parseInt(port || '587', 10),
|
|
8
9
|
secure: false,
|
|
9
|
-
auth: { user, pass },
|
|
10
|
+
auth: user && pass ? { user, pass } : undefined,
|
|
10
11
|
});
|
|
12
|
+
if (!senderName || !senderAddress) {
|
|
13
|
+
throw new Error('Email: missing sender name or address');
|
|
14
|
+
}
|
|
11
15
|
const send = async ({ to, subject, message, ...rest }) => {
|
|
12
16
|
const payload = {
|
|
13
17
|
from: `${senderName} <${senderAddress}>`,
|
package/dist/listen.js
CHANGED
|
@@ -4,6 +4,7 @@ import {} from 'node:net';
|
|
|
4
4
|
import { delay } from '@devmoods/fetch';
|
|
5
5
|
import {} from 'express';
|
|
6
6
|
import { config } from './config.js';
|
|
7
|
+
import { BadRequest } from './errors.js';
|
|
7
8
|
import { getLogger } from './logger.js';
|
|
8
9
|
import { before, route } from './middleware.js';
|
|
9
10
|
import { envPort, startTimer } from './utils.js';
|
|
@@ -100,8 +101,9 @@ export function createExpressServer(app, options = {}) {
|
|
|
100
101
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
101
102
|
process.on(signal, onSignal);
|
|
102
103
|
}
|
|
104
|
+
const healthTypes = ['live', 'ready'];
|
|
103
105
|
if (!app.get('__healthAttached')) {
|
|
104
|
-
app.get('/health', before(), validateQuery({
|
|
106
|
+
app.get('/health/:type?', before(), validateQuery({
|
|
105
107
|
type: 'object',
|
|
106
108
|
properties: {
|
|
107
109
|
type: { type: 'string', enum: ['live', 'ready'] },
|
|
@@ -112,7 +114,13 @@ export function createExpressServer(app, options = {}) {
|
|
|
112
114
|
if (stopping) {
|
|
113
115
|
throw new Error();
|
|
114
116
|
}
|
|
115
|
-
const
|
|
117
|
+
const validateType = (type) => {
|
|
118
|
+
return healthTypes.includes(type);
|
|
119
|
+
};
|
|
120
|
+
const type = req.params.type || req.query.type || 'ready';
|
|
121
|
+
if (!validateType(type)) {
|
|
122
|
+
throw BadRequest('Invalid health type');
|
|
123
|
+
}
|
|
116
124
|
const meta = await options.healthCheck?.(type);
|
|
117
125
|
return { status: 'ok', type, meta, timestamp };
|
|
118
126
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { type ServerResponse, type IncomingMessage } from 'node:http';
|
|
2
|
-
import bodyParser from 'body-parser';
|
|
3
2
|
import { compose } from 'compose-middleware';
|
|
4
|
-
import { type NextFunction, type RequestHandler, type Request, type Response } from 'express';
|
|
3
|
+
import { type NextFunction, type RequestHandler, type Request, type Response, json as jsonParser, urlencoded as urlencodedParser } from 'express';
|
|
5
4
|
import { type CsrfMiddlewareOptions } from './csrf.js';
|
|
6
|
-
|
|
7
|
-
type JsonParser = typeof bodyParser.json;
|
|
8
|
-
export declare const urlencodedParser: UrlencodedParser;
|
|
9
|
-
export declare const jsonParser: JsonParser;
|
|
5
|
+
export { urlencodedParser, jsonParser };
|
|
10
6
|
export { asyncMiddleware as route };
|
|
11
7
|
export { compose as composeMiddleware };
|
|
12
8
|
type ContentSecurityPolicyDirectiveValue = string | ((req: IncomingMessage, res: ServerResponse) => string);
|
package/dist/middleware.js
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
2
|
import {} from 'node:http';
|
|
3
|
-
import bodyParser from 'body-parser';
|
|
4
3
|
import { compose } from 'compose-middleware';
|
|
5
4
|
import cookieParser from 'cookie-parser';
|
|
6
|
-
import {} from 'express';
|
|
5
|
+
import { json as jsonParser, urlencoded as urlencodedParser, } from 'express';
|
|
7
6
|
import helmet from 'helmet';
|
|
8
7
|
import { setContext, useContext, withRequestContext } from './context.js';
|
|
9
8
|
import { csrf, doubleCsrfProtection, } from './csrf.js';
|
|
10
9
|
import { NotFound } from './errors.js';
|
|
11
10
|
import { getLogger } from './logger.js';
|
|
12
11
|
import { startTimer } from './utils.js';
|
|
13
|
-
export
|
|
14
|
-
export const jsonParser = bodyParser.json;
|
|
12
|
+
export { urlencodedParser, jsonParser };
|
|
15
13
|
const logger = getLogger('http');
|
|
16
14
|
export { asyncMiddleware as route };
|
|
17
15
|
export { compose as composeMiddleware };
|
|
18
16
|
const CSP_NONCE_KEY = 'csp-nonce';
|
|
19
17
|
export function useCspNonce() {
|
|
20
|
-
return useContext(CSP_NONCE_KEY) ?? '
|
|
18
|
+
return useContext(CSP_NONCE_KEY) ?? '__CSP_NOT_SET__';
|
|
21
19
|
}
|
|
22
20
|
/**
|
|
23
21
|
* Register middleware that must come before the app routes.
|
|
@@ -67,12 +65,16 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
67
65
|
},
|
|
68
66
|
}),
|
|
69
67
|
useJson && jsonParser(),
|
|
68
|
+
urlencodedParser({ extended: true }),
|
|
70
69
|
(cookies || useCsrf) && cookieParser(),
|
|
71
70
|
analyticsProxy(),
|
|
72
71
|
].filter(Boolean));
|
|
73
72
|
}
|
|
74
73
|
function logRequestDuration() {
|
|
75
74
|
return (req, res, next) => {
|
|
75
|
+
if (req.path.startsWith('/src')) {
|
|
76
|
+
return next();
|
|
77
|
+
}
|
|
76
78
|
const timer = startTimer();
|
|
77
79
|
logger.info('http request', {
|
|
78
80
|
method: req.method,
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { type EventEmitter } from 'node:events';
|
|
2
2
|
/**
|
|
3
3
|
* Start a high resolution timer
|
|
4
|
-
*
|
|
5
4
|
*/
|
|
6
5
|
export declare function startTimer(): {
|
|
6
|
+
/**
|
|
7
|
+
* Reset the timer
|
|
8
|
+
*/
|
|
7
9
|
reset(): void;
|
|
10
|
+
/**
|
|
11
|
+
* Get duration in seconds
|
|
12
|
+
*/
|
|
8
13
|
getDuration(): number;
|
|
9
14
|
};
|
|
10
15
|
export declare function readEnv(key: string): string | undefined;
|
package/dist/utils.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import {} from 'node:events';
|
|
2
2
|
/**
|
|
3
3
|
* Start a high resolution timer
|
|
4
|
-
*
|
|
5
4
|
*/
|
|
6
5
|
export function startTimer() {
|
|
7
6
|
let start = process.hrtime();
|
|
8
7
|
return {
|
|
8
|
+
/**
|
|
9
|
+
* Reset the timer
|
|
10
|
+
*/
|
|
9
11
|
reset() {
|
|
10
12
|
start = process.hrtime();
|
|
11
13
|
},
|
|
14
|
+
/**
|
|
15
|
+
* Get duration in seconds
|
|
16
|
+
*/
|
|
12
17
|
getDuration() {
|
|
13
18
|
const elapsed = process.hrtime(start);
|
|
14
19
|
return (elapsed[0] * 1e9 + elapsed[1]) / 1e9;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type UserConfig } from 'vite';
|
|
2
|
-
export declare function defineViteConfig({ base, reactCompiler, ssr, }: {
|
|
2
|
+
export declare function defineViteConfig({ base, reactCompiler, ssr, analyze, }: {
|
|
3
3
|
base?: string;
|
|
4
4
|
reactCompiler?: boolean;
|
|
5
5
|
ssr?: boolean;
|
|
6
|
+
analyze?: boolean;
|
|
6
7
|
}): UserConfig;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/// <reference types="vitest" />
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import react from '@vitejs/plugin-react';
|
|
4
|
+
import Sonda from 'sonda/vite';
|
|
4
5
|
import { defineConfig } from 'vite';
|
|
5
6
|
import { readEnv } from '../utils.js';
|
|
6
|
-
export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, }) {
|
|
7
|
+
export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, analyze = true, }) {
|
|
7
8
|
const port = readEnv.number('VITE_PORT', 5173);
|
|
8
9
|
return defineConfig({
|
|
9
10
|
plugins: [
|
|
@@ -16,11 +17,18 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
|
|
|
16
17
|
}
|
|
17
18
|
: {}),
|
|
18
19
|
}),
|
|
20
|
+
Sonda({
|
|
21
|
+
enabled: analyze,
|
|
22
|
+
open: false,
|
|
23
|
+
detailed: true,
|
|
24
|
+
sources: true,
|
|
25
|
+
}),
|
|
19
26
|
],
|
|
20
27
|
base,
|
|
21
28
|
build: {
|
|
22
29
|
chunkSizeWarningLimit: 750,
|
|
23
30
|
manifest: true,
|
|
31
|
+
sourcemap: true,
|
|
24
32
|
rollupOptions: {
|
|
25
33
|
input: {
|
|
26
34
|
index: path.resolve(process.cwd(), 'src/entry-client.tsx'),
|
|
@@ -41,6 +49,7 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
|
|
|
41
49
|
test: {
|
|
42
50
|
setupFiles: ['./src/setupTests'],
|
|
43
51
|
include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
|
|
52
|
+
globals: true,
|
|
44
53
|
},
|
|
45
54
|
css: {
|
|
46
55
|
preprocessorOptions: {
|
package/dist/vite/ssr.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
1
|
export interface SsrMiddleWareOptions {
|
|
3
2
|
root?: string;
|
|
4
3
|
getInitialData?: (req: string) => Promise<unknown>;
|
|
5
4
|
useDevServer?: boolean;
|
|
6
5
|
}
|
|
7
|
-
export declare function createSsrMiddleware({ root, getInitialData, useDevServer, }?: SsrMiddleWareOptions): Promise<
|
|
6
|
+
export declare function createSsrMiddleware({ root, getInitialData, useDevServer, }?: SsrMiddleWareOptions): Promise<import("compose-middleware").RequestHandler<unknown, unknown, void>>;
|
package/dist/vite/ssr.js
CHANGED
|
@@ -4,7 +4,7 @@ import express, {} from 'express';
|
|
|
4
4
|
import { createServer as createViteServer } from 'vite';
|
|
5
5
|
import {} from './types.js';
|
|
6
6
|
import { config } from '../config.js';
|
|
7
|
-
import { route, useCspNonce } from '../middleware.js';
|
|
7
|
+
import { composeMiddleware, route, useCspNonce } from '../middleware.js';
|
|
8
8
|
export async function createSsrMiddleware({ root = process.cwd(), getInitialData = async () => ({}), useDevServer = config.value.VITE_USE_DEV_SERVER, } = {}) {
|
|
9
9
|
const resolve = (p) => path.resolve(root, p);
|
|
10
10
|
let vite;
|
|
@@ -30,6 +30,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
30
30
|
const result = await import(path.join(root, 'dist/server/entry-server.js'));
|
|
31
31
|
render = result.render;
|
|
32
32
|
}
|
|
33
|
+
const nonce = useCspNonce();
|
|
33
34
|
const rendered = await render(req, res, {
|
|
34
35
|
useDevServer,
|
|
35
36
|
getInitialData,
|
|
@@ -40,7 +41,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
40
41
|
{{ ssr-js }}
|
|
41
42
|
{{ ssr-css }}
|
|
42
43
|
`,
|
|
43
|
-
nonce
|
|
44
|
+
nonce,
|
|
44
45
|
preamble: `
|
|
45
46
|
import RefreshRuntime from "/@react-refresh"
|
|
46
47
|
RefreshRuntime.injectIntoGlobalHook(window)
|
|
@@ -59,6 +60,8 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
59
60
|
let html = rendered.html;
|
|
60
61
|
const context = Object.assign({}, rendered.context, await readManifest());
|
|
61
62
|
const ssrPlaceholders = Object.fromEntries(Object.entries(context).map(([k, v]) => [`ssr-${k}`, v.toString()]));
|
|
63
|
+
// Add nonce to all bare script tags
|
|
64
|
+
html = html.replace(/<script>/g, `<script nonce="${nonce}">`);
|
|
62
65
|
html = replaceSsrPlaceholders(html, ssrPlaceholders);
|
|
63
66
|
html = `<!DOCTYPE html>\n${html}`;
|
|
64
67
|
return [200, html, { 'Content-type': 'text/html' }];
|
|
@@ -75,7 +78,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
75
78
|
throw error;
|
|
76
79
|
}
|
|
77
80
|
}));
|
|
78
|
-
return middlewares;
|
|
81
|
+
return composeMiddleware(middlewares);
|
|
79
82
|
}
|
|
80
83
|
function replaceSsrPlaceholders(template, values) {
|
|
81
84
|
return template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (match, variableName) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -8,26 +8,26 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"import": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"./vite": {
|
|
16
16
|
"import": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
17
|
+
"types": "./dist/vite/types.js",
|
|
18
|
+
"default": "./dist/vite.js"
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"./vite/ssr": {
|
|
22
22
|
"import": {
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"types": "./dist/vite/ssr.d.ts",
|
|
24
|
+
"default": "./dist/vite/ssr.js"
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"./vite/types": {
|
|
28
28
|
"import": {
|
|
29
|
-
"
|
|
30
|
-
"
|
|
29
|
+
"types": "./dist/vite/types.d.ts",
|
|
30
|
+
"default": "./dist/vite/types.js"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
},
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@devmoods/eslint-config": "^2.5.0",
|
|
48
48
|
"@types/koa-compose": "^3.2.8",
|
|
49
|
-
"@types/node": "22.10.
|
|
49
|
+
"@types/node": "22.10.5",
|
|
50
50
|
"@types/supertest": "^6.0.2",
|
|
51
51
|
"@vitejs/plugin-react": "^4.3.4",
|
|
52
52
|
"@vitest/coverage-v8": "^2.1.8",
|
|
@@ -54,20 +54,20 @@
|
|
|
54
54
|
"eslint": "^9.17.0",
|
|
55
55
|
"faktory-worker": "^4.6.0",
|
|
56
56
|
"prettier": "3.4.2",
|
|
57
|
+
"sonda": "^0.7.1",
|
|
57
58
|
"supertest": "7.0.0",
|
|
58
59
|
"ts-node": "10.9.2",
|
|
59
60
|
"tsx": "^4.19.2",
|
|
60
|
-
"typescript": "5.7.
|
|
61
|
-
"vite": "^6.0.
|
|
61
|
+
"typescript": "5.7.3",
|
|
62
|
+
"vite": "^6.0.7",
|
|
62
63
|
"vitest": "^2.1.8",
|
|
63
64
|
"vitest-fail-on-console": "^0.7.1"
|
|
64
65
|
},
|
|
65
66
|
"dependencies": {
|
|
66
67
|
"@devmoods/fetch": "^4.0.1",
|
|
67
|
-
"@sentry/node": "^8.
|
|
68
|
-
"@sentry/profiling-node": "^8.
|
|
68
|
+
"@sentry/node": "^8.48.0",
|
|
69
|
+
"@sentry/profiling-node": "^8.48.0",
|
|
69
70
|
"@types/bcrypt": "^5.0.2",
|
|
70
|
-
"@types/body-parser": "^1.19.5",
|
|
71
71
|
"@types/cookie-parser": "^1.4.8",
|
|
72
72
|
"@types/express": "^5.0.0",
|
|
73
73
|
"@types/jsonwebtoken": "^9.0.7",
|
|
@@ -76,10 +76,10 @@
|
|
|
76
76
|
"@types/superagent": "^8.1.9",
|
|
77
77
|
"@types/uuid": "^10.0.0",
|
|
78
78
|
"ajv": "^8.17.1",
|
|
79
|
+
"argon2": "^0.41.1",
|
|
79
80
|
"bcrypt": "^5.1.1",
|
|
80
|
-
"body-parser": "^1.20.3",
|
|
81
81
|
"chalk": "^5.4.1",
|
|
82
|
-
"commander": "^
|
|
82
|
+
"commander": "^13.0.0",
|
|
83
83
|
"compose-middleware": "^5.0.1",
|
|
84
84
|
"cookie-parser": "^1.4.7",
|
|
85
85
|
"dotenv": "^16.4.7",
|
|
@@ -89,8 +89,8 @@
|
|
|
89
89
|
"nodemailer": "^6.9.16",
|
|
90
90
|
"pg": "^8.13.1",
|
|
91
91
|
"redis": "^4.7.0",
|
|
92
|
-
"sql-formatter": "^15.4.
|
|
93
|
-
"uuid": "^11.0.
|
|
92
|
+
"sql-formatter": "^15.4.9",
|
|
93
|
+
"uuid": "^11.0.4"
|
|
94
94
|
},
|
|
95
95
|
"prettier": {
|
|
96
96
|
"singleQuote": true
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"vite": ">=6.0.5"
|
|
102
102
|
},
|
|
103
103
|
"engines": {
|
|
104
|
-
"node": ">=20
|
|
104
|
+
"node": ">=20 <24"
|
|
105
105
|
},
|
|
106
106
|
"scripts": {
|
|
107
107
|
"start": "pushd example; pnpm run start --watch; popd example;",
|