@devmoods/express-extras 0.44.1 → 0.45.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 +18 -0
- package/dist/auth/emailAndPassword.js +120 -0
- package/dist/auth/errors.d.ts +6 -0
- package/dist/auth/errors.js +12 -0
- package/dist/auth/passwordless.d.ts +8 -0
- package/dist/auth/passwordless.js +52 -0
- package/dist/auth/token.d.ts +40 -0
- package/dist/auth/token.js +98 -0
- package/dist/auth/types.d.ts +36 -0
- package/dist/auth/types.js +2 -0
- package/dist/auth.d.ts +13 -79
- package/dist/auth.js +51 -238
- package/dist/cache.js +0 -1
- package/dist/config.js +8 -7
- package/dist/context.d.ts +3 -0
- package/dist/context.js +3 -0
- package/dist/errors.d.ts +4 -2
- package/dist/listen.js +6 -3
- package/dist/middleware.d.ts +8 -5
- package/dist/middleware.js +12 -1
- package/dist/migrate.js +10 -10
- package/dist/postgres.d.ts +3 -0
- package/dist/postgres.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type AnyUser, type AuthProvider } from './types.js';
|
|
2
|
+
export interface EmailAndPasswordOptions<UserModel = AnyUser> {
|
|
3
|
+
getUserById: (id: string) => Promise<UserModel>;
|
|
4
|
+
login: (email: string, verifyPassword: (realPassword: string) => Promise<boolean>) => Promise<UserModel>;
|
|
5
|
+
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
6
|
+
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
7
|
+
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
8
|
+
onEmailVerified?(user: UserModel): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function EmailAndPasswordAuthProvider<UserModel extends AnyUser>(options: EmailAndPasswordOptions<UserModel>): AuthProvider<UserModel>;
|
|
11
|
+
/**
|
|
12
|
+
* Compare two password hashes using bcrypt.
|
|
13
|
+
*/
|
|
14
|
+
export declare function checkIfPasswordsMatch(realPassword: string, attemptedPassword: string): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Returns a hash of a password suitable for storage.
|
|
17
|
+
*/
|
|
18
|
+
export declare function hashPassword(password: string): Promise<string>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt';
|
|
2
|
+
import { tokenSchema } from './token.js';
|
|
3
|
+
import {} from './types.js';
|
|
4
|
+
import { Unauthorized } from '../errors.js';
|
|
5
|
+
import { getLogger } from '../logger.js';
|
|
6
|
+
import { route } from '../middleware.js';
|
|
7
|
+
import { validateBody } from '../validate.js';
|
|
8
|
+
const passwordSchema = { type: 'string', minLength: 4 };
|
|
9
|
+
const emailSchema = { type: 'string', minLength: 4 };
|
|
10
|
+
const logger = getLogger('auth');
|
|
11
|
+
export function EmailAndPasswordAuthProvider(options) {
|
|
12
|
+
const skipPasswordCheck = async () => true;
|
|
13
|
+
return {
|
|
14
|
+
setupRoutes(router, context) {
|
|
15
|
+
router.post('/login', validateBody({
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
email: emailSchema,
|
|
19
|
+
password: passwordSchema,
|
|
20
|
+
},
|
|
21
|
+
required: ['email', 'password'],
|
|
22
|
+
}), route(async ({ body: { email, password } }, res) => {
|
|
23
|
+
try {
|
|
24
|
+
const user = await options.login(email, (realPassword) => checkIfPasswordsMatch(realPassword, password));
|
|
25
|
+
const { token } = await context.login(res, user);
|
|
26
|
+
logger.info('access token created');
|
|
27
|
+
return [201, { token }];
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
logger.error(error);
|
|
31
|
+
throw Unauthorized(error.message);
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
router.post('/change-password', validateBody({
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: { token: tokenSchema, password: passwordSchema },
|
|
37
|
+
required: ['token', 'password'],
|
|
38
|
+
}), route(async (req) => {
|
|
39
|
+
const { password, token } = req.body;
|
|
40
|
+
const decoded = context.decodeTemporaryToken(token);
|
|
41
|
+
const user = await options.getUserById(decoded.payload.uid);
|
|
42
|
+
try {
|
|
43
|
+
await context.verifyTemporaryToken(token, user);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw Unauthorized('Invalid token');
|
|
47
|
+
}
|
|
48
|
+
context.setCurrentUser(user);
|
|
49
|
+
await options.onPasswordChanged?.(user, await hashPassword(password));
|
|
50
|
+
logger.info('user changed password');
|
|
51
|
+
return 200;
|
|
52
|
+
}));
|
|
53
|
+
router.post('/verify-email-request', validateBody({
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: { email: emailSchema },
|
|
56
|
+
required: ['email'],
|
|
57
|
+
}), route(async (req) => {
|
|
58
|
+
const { email } = req.body;
|
|
59
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
60
|
+
const temporaryToken = await context.createTemporaryToken(user);
|
|
61
|
+
await options.onEmailVerificationRequest?.(user, temporaryToken);
|
|
62
|
+
return 202;
|
|
63
|
+
}));
|
|
64
|
+
router.post('/verify-email', validateBody({
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: { token: tokenSchema, email: emailSchema },
|
|
67
|
+
required: ['token', 'email'],
|
|
68
|
+
}), route(async (req) => {
|
|
69
|
+
const { token, email } = req.body;
|
|
70
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
71
|
+
try {
|
|
72
|
+
await context.verifyTemporaryToken(token, user);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw Unauthorized('Invalid token');
|
|
76
|
+
}
|
|
77
|
+
context.setCurrentUser(user);
|
|
78
|
+
await options.onEmailVerified?.(user);
|
|
79
|
+
logger.info('user verified');
|
|
80
|
+
return 200;
|
|
81
|
+
}));
|
|
82
|
+
router.post('/reset', context.middleware(false), validateBody({
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: { email: emailSchema },
|
|
85
|
+
required: ['email'],
|
|
86
|
+
}), route(async (req) => {
|
|
87
|
+
const { email } = req.body;
|
|
88
|
+
try {
|
|
89
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
90
|
+
const temporaryToken = await context.createTemporaryToken(user);
|
|
91
|
+
await options.onPasswordResetRequest?.(user, temporaryToken);
|
|
92
|
+
logger.info('password reset request', { email });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error(error, 'auth reset failed');
|
|
96
|
+
}
|
|
97
|
+
return 202;
|
|
98
|
+
}));
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Compare two password hashes using bcrypt.
|
|
104
|
+
*/
|
|
105
|
+
export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
|
|
106
|
+
if (!realPassword) {
|
|
107
|
+
throw new Error('User has no defined password');
|
|
108
|
+
}
|
|
109
|
+
const validPassword = await bcrypt.compare(attemptedPassword, realPassword);
|
|
110
|
+
if (!validPassword) {
|
|
111
|
+
throw new Error(`Passwords do not match`);
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Returns a hash of a password suitable for storage.
|
|
117
|
+
*/
|
|
118
|
+
export function hashPassword(password) {
|
|
119
|
+
return bcrypt.hash(password, 10);
|
|
120
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class InvalidToken extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'Invalid Token';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class PermissionDenied extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'Permission Denied';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type AnyUser, type AuthProvider } from './types.js';
|
|
2
|
+
export interface PasswordlessAuthProviderOptions<UserModel = AnyUser> {
|
|
3
|
+
getUserByEmail(email: string): Promise<UserModel>;
|
|
4
|
+
onLoginRequest(user: UserModel, temporaryToken: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function PasswordlessAuthProvider<UserModel extends AnyUser & {
|
|
7
|
+
email: string;
|
|
8
|
+
}>(options: PasswordlessAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {} from './types.js';
|
|
2
|
+
import { Unauthorized } from '../errors.js';
|
|
3
|
+
import { getLogger } from '../logger.js';
|
|
4
|
+
import { route } from '../middleware.js';
|
|
5
|
+
import { validateBody, validateQuery } from '../validate.js';
|
|
6
|
+
const logger = getLogger('auth');
|
|
7
|
+
export function PasswordlessAuthProvider(options) {
|
|
8
|
+
return {
|
|
9
|
+
setupRoutes(router, context) {
|
|
10
|
+
router.use('/passwordless/login/:token', validateQuery({
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
email: { type: 'string', minLength: 3 },
|
|
14
|
+
},
|
|
15
|
+
required: ['email'],
|
|
16
|
+
}), route(async (req, res) => {
|
|
17
|
+
const { token: temporaryToken } = req.params;
|
|
18
|
+
const { email } = req.query;
|
|
19
|
+
const user = await options.getUserByEmail(email);
|
|
20
|
+
try {
|
|
21
|
+
await context.verifyTemporaryToken(temporaryToken, user);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw Unauthorized('Invalid token');
|
|
25
|
+
}
|
|
26
|
+
const { token } = await context.login(res, user);
|
|
27
|
+
logger.info('passwordless login');
|
|
28
|
+
if (req.accepts('html')) {
|
|
29
|
+
return res.redirect('/');
|
|
30
|
+
}
|
|
31
|
+
return { token };
|
|
32
|
+
}));
|
|
33
|
+
/**
|
|
34
|
+
* Request a one-time password for login.
|
|
35
|
+
*/
|
|
36
|
+
router.post('/passwordless/request-token', validateBody({
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
email: { type: 'string' },
|
|
40
|
+
},
|
|
41
|
+
required: ['email'],
|
|
42
|
+
}), route(async (req) => {
|
|
43
|
+
const { email } = req.body;
|
|
44
|
+
const user = await options.getUserByEmail(email);
|
|
45
|
+
const token = await context.createTemporaryToken(user);
|
|
46
|
+
await options.onLoginRequest(user, token);
|
|
47
|
+
logger.info('passwordless login requested', { email });
|
|
48
|
+
return 202;
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Response, type Request } from 'express';
|
|
2
|
+
import { type ValidationSchema } from '../validate.js';
|
|
3
|
+
export declare const tokenSchema: ValidationSchema;
|
|
4
|
+
/**
|
|
5
|
+
* Returns a signed JWT token.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createAuthToken(payload: any, secret?: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Decode the given JWT token.
|
|
10
|
+
*/
|
|
11
|
+
export declare function decodeAuthToken(token: string, secret?: string): {
|
|
12
|
+
[x: string]: any;
|
|
13
|
+
iss?: string | undefined;
|
|
14
|
+
sub?: string | undefined;
|
|
15
|
+
aud?: string | string[] | undefined;
|
|
16
|
+
exp?: number | undefined;
|
|
17
|
+
nbf?: number | undefined;
|
|
18
|
+
iat?: number | undefined;
|
|
19
|
+
jti?: string | undefined;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Extract a Bearer token from the HTTP Authorization header
|
|
23
|
+
* of `req`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseAuthorizationHeader(req: Request): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Extract an access token from the `Request`-object.
|
|
28
|
+
*
|
|
29
|
+
* Checks multiple places and uses the first match. Priority:
|
|
30
|
+
* 1. Request header `Authorization: `
|
|
31
|
+
* 2. Cookie named `access_token`
|
|
32
|
+
*
|
|
33
|
+
*/
|
|
34
|
+
export declare function extractAccessTokenFromRequest<T extends Request>(req: T): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Set or expire the access token cookie.
|
|
37
|
+
*
|
|
38
|
+
* If the provided token is `null` the cookie will be cleared.
|
|
39
|
+
*/
|
|
40
|
+
export declare function setAccessTokenCookie(res: Response, token: string | null): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {} from 'express';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { v4 } from 'uuid';
|
|
4
|
+
import { InvalidToken } from './errors.js';
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
import {} from '../validate.js';
|
|
7
|
+
export const tokenSchema = {
|
|
8
|
+
type: 'string',
|
|
9
|
+
minLength: 6,
|
|
10
|
+
maxLength: 256,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Returns a signed JWT token.
|
|
14
|
+
*/
|
|
15
|
+
export function createAuthToken(payload, secret = config.value.AUTH_SECRET) {
|
|
16
|
+
return jwt.sign(payload, secret, {
|
|
17
|
+
expiresIn: config.value.AUTH_TOKEN_EXPIRES_IN,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Decode the given JWT token.
|
|
22
|
+
*/
|
|
23
|
+
export function decodeAuthToken(token, secret = config.value.AUTH_SECRET) {
|
|
24
|
+
try {
|
|
25
|
+
const decoded = jwt.verify(token.trim(), secret);
|
|
26
|
+
/* c8 ignore next 3 */
|
|
27
|
+
if (typeof decoded === 'string') {
|
|
28
|
+
throw new Error('decoded value not an object');
|
|
29
|
+
}
|
|
30
|
+
return { ...decoded };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new InvalidToken(`Failed to decode auth token: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Extract a Bearer token from the HTTP Authorization header
|
|
38
|
+
* of `req`.
|
|
39
|
+
*/
|
|
40
|
+
export function parseAuthorizationHeader(req) {
|
|
41
|
+
if (!req.headers.authorization) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const [type, token] = req.headers.authorization
|
|
45
|
+
.split(' ')
|
|
46
|
+
.map((s) => s.trim());
|
|
47
|
+
if (token && type !== 'Bearer') {
|
|
48
|
+
throw new InvalidToken('Only Bearer type supported in jwt()');
|
|
49
|
+
}
|
|
50
|
+
// No token given, this is not necessarilly an error case
|
|
51
|
+
if (!token) {
|
|
52
|
+
if (type) {
|
|
53
|
+
return type;
|
|
54
|
+
/* c8 ignore next 3 */
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return token;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Extract an access token from the `Request`-object.
|
|
62
|
+
*
|
|
63
|
+
* Checks multiple places and uses the first match. Priority:
|
|
64
|
+
* 1. Request header `Authorization: `
|
|
65
|
+
* 2. Cookie named `access_token`
|
|
66
|
+
*
|
|
67
|
+
*/
|
|
68
|
+
export function extractAccessTokenFromRequest(req) {
|
|
69
|
+
const authorizationHeader = parseAuthorizationHeader(req);
|
|
70
|
+
if (authorizationHeader) {
|
|
71
|
+
return authorizationHeader;
|
|
72
|
+
}
|
|
73
|
+
if (req.cookies && 'access_token' in req.cookies) {
|
|
74
|
+
return req.cookies['access_token'];
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Set or expire the access token cookie.
|
|
80
|
+
*
|
|
81
|
+
* If the provided token is `null` the cookie will be cleared.
|
|
82
|
+
*/
|
|
83
|
+
export function setAccessTokenCookie(res, token) {
|
|
84
|
+
if (token == null) {
|
|
85
|
+
res.cookie('access_token', v4(), {
|
|
86
|
+
expires: new Date(1970, 0, 1),
|
|
87
|
+
sameSite: 'strict',
|
|
88
|
+
secure: process.env.NODE_ENV === 'production',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
res.cookie('access_token', token, {
|
|
93
|
+
httpOnly: true,
|
|
94
|
+
sameSite: 'strict',
|
|
95
|
+
secure: process.env.NODE_ENV === 'production',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RequestHandler, type Router, type Response } from 'express';
|
|
2
|
+
import { type RedisClient } from '../redis.js';
|
|
3
|
+
export interface AnyUser {
|
|
4
|
+
id: string | number;
|
|
5
|
+
[key: string]: any;
|
|
6
|
+
}
|
|
7
|
+
export interface AuthOptions<UserModel extends AnyUser> {
|
|
8
|
+
getUserByToken(token: Record<string, any>): Promise<UserModel>;
|
|
9
|
+
providers: AuthProvider<UserModel>[];
|
|
10
|
+
redis: RedisClient;
|
|
11
|
+
filterUser(user: UserModel): Partial<UserModel>;
|
|
12
|
+
loginUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
15
|
+
filterUser: AuthOptions<UserModel>['filterUser'];
|
|
16
|
+
redis: AuthOptions<UserModel>['redis'];
|
|
17
|
+
setCurrentUser: (user: UserModel) => void;
|
|
18
|
+
createTemporaryToken(user: UserModel): Promise<string>;
|
|
19
|
+
verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
|
|
20
|
+
decodeTemporaryToken(token: string): {
|
|
21
|
+
payload: {
|
|
22
|
+
uid: string;
|
|
23
|
+
};
|
|
24
|
+
signature: string;
|
|
25
|
+
};
|
|
26
|
+
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
27
|
+
login: (res: Response, user: UserModel) => Promise<{
|
|
28
|
+
token: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export interface AuthProvider<UserModel extends AnyUser> {
|
|
32
|
+
setupRoutes(router: Router, context: AuthProviderContext<UserModel>): void;
|
|
33
|
+
}
|
|
34
|
+
export type AuthMiddlewareOptions<UserModel extends AnyUser> = false | {
|
|
35
|
+
isAuthorized?: (user: UserModel) => Promise<boolean> | boolean;
|
|
36
|
+
};
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,86 +1,20 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
/**
|
|
8
|
-
* Decode the given JWT token.
|
|
9
|
-
*/
|
|
10
|
-
export declare function decodeAuthToken(token: string, secret?: string): {
|
|
11
|
-
[x: string]: any;
|
|
12
|
-
iss?: string | undefined;
|
|
13
|
-
sub?: string | undefined;
|
|
14
|
-
aud?: string | string[] | undefined;
|
|
15
|
-
exp?: number | undefined;
|
|
16
|
-
nbf?: number | undefined;
|
|
17
|
-
iat?: number | undefined;
|
|
18
|
-
jti?: string | undefined;
|
|
19
|
-
};
|
|
20
|
-
export interface EmailAndPasswordOptions<UserModel = AnyUser> {
|
|
21
|
-
login: (email: string, verifyPassword: (realPassword: string) => Promise<boolean>) => Promise<UserModel>;
|
|
22
|
-
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
23
|
-
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
24
|
-
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
25
|
-
onEmailVerified?(user: UserModel): Promise<void>;
|
|
26
|
-
}
|
|
27
|
-
export declare function EmailAndPasswordAuthProvider<UserModel extends AnyUser>(options: EmailAndPasswordOptions<UserModel>): AuthProvider<UserModel>;
|
|
28
|
-
export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
29
|
-
filterUser: AuthOptions<UserModel>['filterUser'];
|
|
30
|
-
redis: AuthOptions<UserModel>['redis'];
|
|
31
|
-
setCurrentUser: (user: UserModel) => void;
|
|
32
|
-
createTemporaryToken(user: UserModel): Promise<string>;
|
|
33
|
-
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
34
|
-
login: (res: Response, user: UserModel) => Promise<{
|
|
35
|
-
token: string;
|
|
36
|
-
}>;
|
|
37
|
-
}
|
|
38
|
-
export type AnyUser = Record<string, any>;
|
|
39
|
-
export interface AuthProvider<UserModel extends AnyUser> {
|
|
40
|
-
setupRoutes(router: Router, context: AuthProviderContext<UserModel>): void;
|
|
41
|
-
}
|
|
42
|
-
export interface AuthOptions<UserModel extends AnyUser> {
|
|
43
|
-
getUserByToken(token: Record<string, any>): Promise<UserModel>;
|
|
44
|
-
providers: AuthProvider<UserModel>[];
|
|
45
|
-
redis: RedisClient;
|
|
46
|
-
filterUser(user: UserModel): Partial<UserModel>;
|
|
47
|
-
loginUrl?: string;
|
|
48
|
-
}
|
|
1
|
+
import { type RequestHandler, Router } from 'express';
|
|
2
|
+
import { type AnyUser, type AuthMiddlewareOptions, type AuthOptions } from './auth/types.js';
|
|
3
|
+
export * from './auth/types.js';
|
|
4
|
+
export * from './auth/emailAndPassword.js';
|
|
5
|
+
export * from './auth/passwordless.js';
|
|
6
|
+
export * from './auth/token.js';
|
|
49
7
|
export interface CreateAuth<UserModel extends AnyUser> {
|
|
50
8
|
createRouter: () => Router;
|
|
51
9
|
useCurrentUser: () => UserModel | undefined;
|
|
52
10
|
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
53
11
|
createTemporaryToken(user: UserModel): Promise<string>;
|
|
12
|
+
verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
|
|
13
|
+
decodeTemporaryToken(token: string): {
|
|
14
|
+
payload: {
|
|
15
|
+
uid: string;
|
|
16
|
+
};
|
|
17
|
+
signature: string;
|
|
18
|
+
};
|
|
54
19
|
}
|
|
55
|
-
export type AuthMiddlewareOptions<UserModel extends AnyUser> = false | {
|
|
56
|
-
isAuthorized?: (user: UserModel) => Promise<boolean> | boolean;
|
|
57
|
-
};
|
|
58
20
|
export declare function createAuth<UserModel extends AnyUser>({ getUserByToken, filterUser, redis, providers, loginUrl, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
|
|
59
|
-
/**
|
|
60
|
-
* Extract a Bearer token from the HTTP Authorization header
|
|
61
|
-
* of `req`.
|
|
62
|
-
*/
|
|
63
|
-
export declare function parseAuthorizationHeader(req: Request): string | null;
|
|
64
|
-
/**
|
|
65
|
-
* Extract an access token from the `Request`-object.
|
|
66
|
-
*
|
|
67
|
-
* Checks multiple places and uses the first match. Priority:
|
|
68
|
-
* 1. Request header `Authorization: `
|
|
69
|
-
* 2. Cookie named `access_token`
|
|
70
|
-
*
|
|
71
|
-
*/
|
|
72
|
-
export declare function extractAccessTokenFromRequest(req: Request): string | null;
|
|
73
|
-
/**
|
|
74
|
-
* Set or expire the access token cookie.
|
|
75
|
-
*
|
|
76
|
-
* If the provided token is `null` the cookie will be cleared.
|
|
77
|
-
*/
|
|
78
|
-
export declare function setAccessTokenCookie(res: Response, token: string | null): void;
|
|
79
|
-
/**
|
|
80
|
-
* Compare two password hashes using bcrypt.
|
|
81
|
-
*/
|
|
82
|
-
export declare function checkIfPasswordsMatch(realPassword: string, attemptedPassword: string): Promise<boolean>;
|
|
83
|
-
/**
|
|
84
|
-
* Returns a hash of a password suitable for storage.
|
|
85
|
-
*/
|
|
86
|
-
export declare function hashPassword(password: string): Promise<string>;
|
package/dist/auth.js
CHANGED
|
@@ -1,160 +1,39 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { InvalidToken, PermissionDenied } from './auth/errors.js';
|
|
4
|
+
import { createAuthToken, decodeAuthToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
|
|
5
|
+
import {} from './auth/types.js';
|
|
6
6
|
import { config } from './config.js';
|
|
7
7
|
import { setContext, useContext } from './context.js';
|
|
8
|
-
import {
|
|
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 './
|
|
12
|
-
import { validateBody, validateParams, } from './validate.js';
|
|
11
|
+
import { validateParams } from './validate.js';
|
|
13
12
|
const logger = getLogger('auth');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
type: 'string',
|
|
19
|
-
minLength: 50,
|
|
20
|
-
maxLength: 256,
|
|
21
|
-
};
|
|
22
|
-
const passwordSchema = { type: 'string', minLength: 8 };
|
|
23
|
-
const emailSchema = { type: 'string', minLength: 4 };
|
|
24
|
-
/**
|
|
25
|
-
* Returns a signed JWT token.
|
|
26
|
-
*/
|
|
27
|
-
export function createAuthToken(payload, secret = AUTH_SECRET) {
|
|
28
|
-
return jwt.sign(payload, secret, { expiresIn: TOKEN_EXPIRES_IN });
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Decode the given JWT token.
|
|
32
|
-
*/
|
|
33
|
-
export function decodeAuthToken(token, secret = AUTH_SECRET) {
|
|
34
|
-
try {
|
|
35
|
-
const decoded = jwt.verify(token.trim(), secret);
|
|
36
|
-
/* c8 ignore next 3 */
|
|
37
|
-
if (typeof decoded === 'string') {
|
|
38
|
-
throw new Error('decoded value not an object');
|
|
39
|
-
}
|
|
40
|
-
return { ...decoded };
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
throw new InvalidToken(`Failed to decode auth token: ${error.message}`);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
export function EmailAndPasswordAuthProvider(options) {
|
|
47
|
-
const skipPasswordCheck = async () => true;
|
|
48
|
-
return {
|
|
49
|
-
setupRoutes(router, context) {
|
|
50
|
-
router.post('/login', validateBody({
|
|
51
|
-
type: 'object',
|
|
52
|
-
properties: {
|
|
53
|
-
email: { type: 'string' },
|
|
54
|
-
password: { type: 'string' },
|
|
55
|
-
},
|
|
56
|
-
required: ['email', 'password'],
|
|
57
|
-
}), route(async ({ body: { email, password } }, res) => {
|
|
58
|
-
try {
|
|
59
|
-
const user = await options.login(email, (realPassword) => checkIfPasswordsMatch(realPassword, password));
|
|
60
|
-
const { token } = await context.login(res, user);
|
|
61
|
-
logger.info('access token created');
|
|
62
|
-
return [201, { token }];
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
logger.error(error);
|
|
66
|
-
throw Unauthorized(error.message);
|
|
67
|
-
}
|
|
68
|
-
}));
|
|
69
|
-
router.post('/change-password', validateBody({
|
|
70
|
-
type: 'object',
|
|
71
|
-
properties: { token: tokenSchema, password: passwordSchema },
|
|
72
|
-
required: ['token', 'password'],
|
|
73
|
-
}), route(async (req) => {
|
|
74
|
-
const { password, token } = req.body;
|
|
75
|
-
const email = await context.redis.get(token);
|
|
76
|
-
if (!email) {
|
|
77
|
-
logger.info('invalid reset token', { token });
|
|
78
|
-
throw BadRequest('invalid reset token');
|
|
79
|
-
}
|
|
80
|
-
const user = await options.login(email, skipPasswordCheck);
|
|
81
|
-
context.setCurrentUser(user);
|
|
82
|
-
await options.onPasswordChanged?.(user, await hashPassword(password));
|
|
83
|
-
await context.redis.del(token);
|
|
84
|
-
logger.info('user changed password');
|
|
85
|
-
return 200;
|
|
86
|
-
}));
|
|
87
|
-
router.post('/verify-email-request', validateBody({
|
|
88
|
-
type: 'object',
|
|
89
|
-
properties: { email: emailSchema },
|
|
90
|
-
required: ['email'],
|
|
91
|
-
}), route(async (req) => {
|
|
92
|
-
const { email } = req.body;
|
|
93
|
-
const user = await options.login(email, skipPasswordCheck);
|
|
94
|
-
const temporaryToken = await context.createTemporaryToken(user);
|
|
95
|
-
await options.onEmailVerificationRequest?.(user, temporaryToken);
|
|
96
|
-
return 202;
|
|
97
|
-
}));
|
|
98
|
-
router.post('/verify-email', validateBody({
|
|
99
|
-
type: 'object',
|
|
100
|
-
properties: { token: tokenSchema, email: emailSchema },
|
|
101
|
-
required: ['token', 'email'],
|
|
102
|
-
}), route(async (req) => {
|
|
103
|
-
const { token, email } = req.body;
|
|
104
|
-
const storedEmail = await context.redis.get(token);
|
|
105
|
-
if (!storedEmail) {
|
|
106
|
-
logger.info('invalid verification token', { token });
|
|
107
|
-
throw BadRequest('invalid verification token');
|
|
108
|
-
}
|
|
109
|
-
if (storedEmail !== email) {
|
|
110
|
-
logger.info('email verification mismatch', {
|
|
111
|
-
expected: storedEmail,
|
|
112
|
-
actual: email,
|
|
113
|
-
});
|
|
114
|
-
throw BadRequest('invalid verification token');
|
|
115
|
-
}
|
|
116
|
-
const user = await options.login(storedEmail, skipPasswordCheck);
|
|
117
|
-
context.setCurrentUser(user);
|
|
118
|
-
await options.onEmailVerified?.(user);
|
|
119
|
-
await context.redis.del(token);
|
|
120
|
-
logger.info('user verified');
|
|
121
|
-
return 200;
|
|
122
|
-
}));
|
|
123
|
-
router.post('/reset', context.middleware(false), validateBody({
|
|
124
|
-
type: 'object',
|
|
125
|
-
properties: { email: emailSchema },
|
|
126
|
-
required: ['email'],
|
|
127
|
-
}), route(async (req) => {
|
|
128
|
-
const { email } = req.body;
|
|
129
|
-
try {
|
|
130
|
-
const user = await options.login(email, skipPasswordCheck);
|
|
131
|
-
const temporaryToken = await context.createTemporaryToken(user);
|
|
132
|
-
await options.onPasswordResetRequest?.(user, temporaryToken);
|
|
133
|
-
logger.info('password reset request', { email });
|
|
134
|
-
}
|
|
135
|
-
catch (error) {
|
|
136
|
-
logger.error(error, 'auth reset failed');
|
|
137
|
-
}
|
|
138
|
-
return 202;
|
|
139
|
-
}));
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
}
|
|
13
|
+
export * from './auth/types.js';
|
|
14
|
+
export * from './auth/emailAndPassword.js';
|
|
15
|
+
export * from './auth/passwordless.js';
|
|
16
|
+
export * from './auth/token.js';
|
|
143
17
|
export function createAuth({ getUserByToken, filterUser, redis, providers, loginUrl = '/', }) {
|
|
144
18
|
const useCurrentUser = () => useContext('currentUser');
|
|
145
19
|
const setCurrentUser = (user) => setContext('currentUser', user);
|
|
146
20
|
const login = async (res, user) => {
|
|
147
21
|
const token = createAuthToken(filterUser(user));
|
|
148
|
-
await redis.set(token, '1', { EX:
|
|
22
|
+
await redis.set(token, '1', { EX: config.value.AUTH_TOKEN_EXPIRES_IN });
|
|
149
23
|
setAccessTokenCookie(res, token);
|
|
150
24
|
setCurrentUser(user);
|
|
151
25
|
return { token };
|
|
152
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* API for auth providers to use
|
|
29
|
+
*/
|
|
153
30
|
const providerContext = {
|
|
154
31
|
filterUser,
|
|
155
32
|
redis,
|
|
156
33
|
setCurrentUser,
|
|
157
34
|
createTemporaryToken,
|
|
35
|
+
verifyTemporaryToken,
|
|
36
|
+
decodeTemporaryToken,
|
|
158
37
|
middleware,
|
|
159
38
|
login,
|
|
160
39
|
};
|
|
@@ -194,10 +73,7 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
194
73
|
setAccessTokenCookie(res, null);
|
|
195
74
|
throw Unauthorized();
|
|
196
75
|
}
|
|
197
|
-
const {
|
|
198
|
-
const token = createAuthToken(rest);
|
|
199
|
-
await redis.set(token, '1', { EX: TOKEN_EXPIRES_IN });
|
|
200
|
-
setAccessTokenCookie(res, token);
|
|
76
|
+
const { token } = await providerContext.login(res, user);
|
|
201
77
|
logger.info('refreshed token');
|
|
202
78
|
return { token, user: filterUser(user) };
|
|
203
79
|
}));
|
|
@@ -267,9 +143,16 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
267
143
|
*/
|
|
268
144
|
async function createTemporaryToken(user) {
|
|
269
145
|
try {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
146
|
+
const randomBytes = crypto.randomBytes(8).toString('hex');
|
|
147
|
+
const payload = { uid: user.id, r: randomBytes };
|
|
148
|
+
const signature = crypto
|
|
149
|
+
.createHmac('sha256', config.value.AUTH_SECRET)
|
|
150
|
+
.update(JSON.stringify(payload))
|
|
151
|
+
.digest('base64')
|
|
152
|
+
.slice(0, 12);
|
|
153
|
+
const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
|
|
154
|
+
await redis.set(temporaryToken, user.id, {
|
|
155
|
+
EX: config.value.AUTH_TEMP_TOKEN_EXPIRES_IN,
|
|
273
156
|
});
|
|
274
157
|
return temporaryToken;
|
|
275
158
|
}
|
|
@@ -278,104 +161,34 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
|
|
|
278
161
|
throw error;
|
|
279
162
|
}
|
|
280
163
|
}
|
|
164
|
+
function decodeTemporaryToken(token) {
|
|
165
|
+
return JSON.parse(Buffer.from(token, 'base64url').toString('utf-8'));
|
|
166
|
+
}
|
|
167
|
+
async function verifyTemporaryToken(token, user) {
|
|
168
|
+
const decoded = decodeTemporaryToken(token);
|
|
169
|
+
const expectedSignature = crypto
|
|
170
|
+
.createHmac('sha256', config.value.AUTH_SECRET)
|
|
171
|
+
.update(JSON.stringify(decoded.payload))
|
|
172
|
+
.digest('base64')
|
|
173
|
+
.slice(0, 12);
|
|
174
|
+
if (expectedSignature !== decoded.signature) {
|
|
175
|
+
throw new InvalidToken('Invalid signature');
|
|
176
|
+
}
|
|
177
|
+
if (decoded.payload.uid !== user.id) {
|
|
178
|
+
throw new InvalidToken('Invalid user');
|
|
179
|
+
}
|
|
180
|
+
const storedUid = await redis.get(token);
|
|
181
|
+
if (storedUid !== user.id) {
|
|
182
|
+
throw new InvalidToken('Invalid user');
|
|
183
|
+
}
|
|
184
|
+
await redis.del(token);
|
|
185
|
+
}
|
|
281
186
|
return {
|
|
282
187
|
createRouter,
|
|
283
188
|
useCurrentUser,
|
|
284
189
|
middleware,
|
|
285
190
|
createTemporaryToken,
|
|
191
|
+
verifyTemporaryToken,
|
|
192
|
+
decodeTemporaryToken,
|
|
286
193
|
};
|
|
287
194
|
}
|
|
288
|
-
/**
|
|
289
|
-
* Extract a Bearer token from the HTTP Authorization header
|
|
290
|
-
* of `req`.
|
|
291
|
-
*/
|
|
292
|
-
export function parseAuthorizationHeader(req) {
|
|
293
|
-
if (!req.headers.authorization) {
|
|
294
|
-
return null;
|
|
295
|
-
}
|
|
296
|
-
const [type, token] = req.headers.authorization
|
|
297
|
-
.split(' ')
|
|
298
|
-
.map((s) => s.trim());
|
|
299
|
-
if (token && type !== 'Bearer') {
|
|
300
|
-
throw new InvalidToken('Only Bearer type supported in jwt()');
|
|
301
|
-
}
|
|
302
|
-
// No token given, this is not necessarilly an error case
|
|
303
|
-
if (!token) {
|
|
304
|
-
if (type) {
|
|
305
|
-
return type;
|
|
306
|
-
/* c8 ignore next 3 */
|
|
307
|
-
}
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
return token;
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Extract an access token from the `Request`-object.
|
|
314
|
-
*
|
|
315
|
-
* Checks multiple places and uses the first match. Priority:
|
|
316
|
-
* 1. Request header `Authorization: `
|
|
317
|
-
* 2. Cookie named `access_token`
|
|
318
|
-
*
|
|
319
|
-
*/
|
|
320
|
-
export function extractAccessTokenFromRequest(req) {
|
|
321
|
-
const authorizationHeader = parseAuthorizationHeader(req);
|
|
322
|
-
if (authorizationHeader) {
|
|
323
|
-
return authorizationHeader;
|
|
324
|
-
}
|
|
325
|
-
if (req.cookies && 'access_token' in req.cookies) {
|
|
326
|
-
return req.cookies['access_token'];
|
|
327
|
-
}
|
|
328
|
-
return null;
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Set or expire the access token cookie.
|
|
332
|
-
*
|
|
333
|
-
* If the provided token is `null` the cookie will be cleared.
|
|
334
|
-
*/
|
|
335
|
-
export function setAccessTokenCookie(res, token) {
|
|
336
|
-
if (token == null) {
|
|
337
|
-
res.cookie('access_token', v4(), {
|
|
338
|
-
expires: new Date(1970, 0, 1),
|
|
339
|
-
sameSite: 'strict',
|
|
340
|
-
secure: process.env.NODE_ENV === 'production',
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
res.cookie('access_token', token, {
|
|
345
|
-
httpOnly: true,
|
|
346
|
-
sameSite: 'strict',
|
|
347
|
-
secure: process.env.NODE_ENV === 'production',
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
class InvalidToken extends Error {
|
|
352
|
-
constructor(message) {
|
|
353
|
-
super(message);
|
|
354
|
-
this.name = 'Invalid Token';
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
class PermissionDenied extends Error {
|
|
358
|
-
constructor(message) {
|
|
359
|
-
super(message);
|
|
360
|
-
this.name = 'Permission Denied';
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Compare two password hashes using bcrypt.
|
|
365
|
-
*/
|
|
366
|
-
export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
|
|
367
|
-
if (!realPassword) {
|
|
368
|
-
throw new Error('User has no defined password');
|
|
369
|
-
}
|
|
370
|
-
const validPassword = await bcrypt.compare(attemptedPassword, realPassword);
|
|
371
|
-
if (!validPassword) {
|
|
372
|
-
throw new Error(`Passwords do not match`);
|
|
373
|
-
}
|
|
374
|
-
return true;
|
|
375
|
-
}
|
|
376
|
-
/**
|
|
377
|
-
* Returns a hash of a password suitable for storage.
|
|
378
|
-
*/
|
|
379
|
-
export function hashPassword(password) {
|
|
380
|
-
return bcrypt.hash(password, 10);
|
|
381
|
-
}
|
package/dist/cache.js
CHANGED
|
@@ -78,7 +78,6 @@ export function createCache(adapter) {
|
|
|
78
78
|
const expiresMs = typeof options === 'number' || options == null
|
|
79
79
|
? options
|
|
80
80
|
: options.expiresMs;
|
|
81
|
-
console.log({ expiresMs });
|
|
82
81
|
const result = await fn(...args);
|
|
83
82
|
await adapter.set(cacheKey, result, expiresMs);
|
|
84
83
|
return result;
|
package/dist/config.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readEnv } from './utils.js';
|
|
2
2
|
function lazy(fn) {
|
|
3
|
-
return {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
3
|
+
return { value: fn() };
|
|
4
|
+
// return {
|
|
5
|
+
// get value() {
|
|
6
|
+
// const value = fn();
|
|
7
|
+
// Object.defineProperty(this, 'value', { value });
|
|
8
|
+
// return value;
|
|
9
|
+
// },
|
|
10
|
+
// };
|
|
10
11
|
}
|
|
11
12
|
export const config = lazy(() => ({
|
|
12
13
|
CI: readEnv.boolean('CI', false),
|
package/dist/context.d.ts
CHANGED
|
@@ -14,4 +14,7 @@ export declare function useContext<T>(key: string): T | undefined;
|
|
|
14
14
|
* Set a value on the request context
|
|
15
15
|
*/
|
|
16
16
|
export declare function setContext<T>(key: string, value: T): void;
|
|
17
|
+
/**
|
|
18
|
+
* Obtain the requestId from the current async request context.
|
|
19
|
+
*/
|
|
17
20
|
export declare function useRequestId(): string | undefined;
|
package/dist/context.js
CHANGED
package/dist/errors.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
type AnyPayload = Record<string, unknown>;
|
|
1
2
|
export declare class ApiError extends Error {
|
|
2
3
|
status: number | null | undefined;
|
|
3
|
-
payload:
|
|
4
|
+
payload: AnyPayload | undefined;
|
|
4
5
|
}
|
|
5
6
|
export declare const BadRequest: (reason: any) => ApiError;
|
|
6
7
|
export declare const Forbidden: (detail?: string) => ApiError;
|
|
@@ -10,7 +11,7 @@ export declare const Conflict: (detail?: string) => ApiError;
|
|
|
10
11
|
/**
|
|
11
12
|
* Create a new ApiError instance
|
|
12
13
|
*/
|
|
13
|
-
export declare function makeError(status: number, detail?: string, payload?:
|
|
14
|
+
export declare function makeError(status: number, detail?: string, payload?: AnyPayload): ApiError;
|
|
14
15
|
/**
|
|
15
16
|
* Problem Details for HTTP APIs
|
|
16
17
|
*
|
|
@@ -37,3 +38,4 @@ export interface ErrorHandlerOptions {
|
|
|
37
38
|
* Composed together with the Sentry error handler.
|
|
38
39
|
*/
|
|
39
40
|
export declare function errorHandler({ onError }?: ErrorHandlerOptions): import("compose-middleware").ErrorHandler<unknown, unknown, void>;
|
|
41
|
+
export {};
|
package/dist/listen.js
CHANGED
|
@@ -6,7 +6,7 @@ import {} from 'express';
|
|
|
6
6
|
import { config } from './config.js';
|
|
7
7
|
import { getLogger } from './logger.js';
|
|
8
8
|
import { before, route } from './middleware.js';
|
|
9
|
-
import { envPort } from './utils.js';
|
|
9
|
+
import { envPort, startTimer } from './utils.js';
|
|
10
10
|
import { validateQuery } from './validate.js';
|
|
11
11
|
const logger = getLogger('http');
|
|
12
12
|
const READINESS_PERIOD = config.value.READINESS_PERIOD_MS;
|
|
@@ -81,16 +81,19 @@ export function createExpressServer(app, options = {}) {
|
|
|
81
81
|
return;
|
|
82
82
|
}
|
|
83
83
|
stopping = true;
|
|
84
|
+
const timer = startTimer();
|
|
84
85
|
try {
|
|
85
86
|
logger.info(`app shutting down due to ${signal}`);
|
|
86
87
|
await delay(READINESS_PERIOD + 1000);
|
|
87
88
|
await server.stop();
|
|
88
89
|
await options.onShutdown?.();
|
|
89
|
-
logger.info('app shut down');
|
|
90
|
+
logger.info('app shut down', { duration: timer.getDuration() });
|
|
90
91
|
process.exit(0);
|
|
91
92
|
}
|
|
92
93
|
catch (error) {
|
|
93
|
-
logger.error(error, 'error during shutdown'
|
|
94
|
+
logger.error(error, 'error during shutdown', {
|
|
95
|
+
duration: timer.getDuration(),
|
|
96
|
+
});
|
|
94
97
|
process.exit(1);
|
|
95
98
|
}
|
|
96
99
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ServerResponse, type IncomingMessage } from 'node:http';
|
|
1
2
|
import bodyParser from 'body-parser';
|
|
2
3
|
import { type NextFunction, type RequestHandler, type Request, type Response } from 'express';
|
|
3
4
|
import { type CsrfMiddlewareOptions } from './csrf.js';
|
|
@@ -6,11 +7,12 @@ type JsonParser = typeof bodyParser.json;
|
|
|
6
7
|
export declare const urlencodedParser: UrlencodedParser;
|
|
7
8
|
export declare const jsonParser: JsonParser;
|
|
8
9
|
export { asyncMiddleware as route };
|
|
10
|
+
type ContentSecurityPolicyDirectiveValue = string | ((req: IncomingMessage, res: ServerResponse) => string);
|
|
9
11
|
export interface BeforeMiddlewareCSPDirectives {
|
|
10
|
-
'script-src':
|
|
11
|
-
'style-src':
|
|
12
|
-
'connect-src':
|
|
13
|
-
[key: string]:
|
|
12
|
+
'script-src': ContentSecurityPolicyDirectiveValue[];
|
|
13
|
+
'style-src': ContentSecurityPolicyDirectiveValue[];
|
|
14
|
+
'connect-src': ContentSecurityPolicyDirectiveValue[];
|
|
15
|
+
[key: string]: ContentSecurityPolicyDirectiveValue[];
|
|
14
16
|
}
|
|
15
17
|
export interface BeforeMiddlewareOptions {
|
|
16
18
|
cookies?: boolean;
|
|
@@ -20,6 +22,7 @@ export interface BeforeMiddlewareOptions {
|
|
|
20
22
|
allowUnsafeInlineScripts?: boolean;
|
|
21
23
|
cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
|
|
22
24
|
}
|
|
25
|
+
export declare function useCspNonce(): string;
|
|
23
26
|
/**
|
|
24
27
|
* Register middleware that must come before the app routes.
|
|
25
28
|
*/
|
|
@@ -42,4 +45,4 @@ type ResponseWrapper<TResponseBody extends AnyResponseBody> = TResponseBody | TR
|
|
|
42
45
|
/**
|
|
43
46
|
* Alias of `route()`
|
|
44
47
|
*/
|
|
45
|
-
export declare function asyncMiddleware<RequestBody, RequestQuery =
|
|
48
|
+
export declare function asyncMiddleware<RequestBody, RequestQuery extends Record<string, any> = Record<string, any>, ResponseBody extends AnyResponseBody = Record<string, any>>(fn: (req: Request<Record<string, string>, ResponseBody, RequestBody, RequestQuery, Record<string, any>>, res: Response<ResponseBody, object>, next: NextFunction) => ResponseWrapper<ResponseBody> | Promise<ResponseWrapper<ResponseBody>>): RequestHandler<Record<string, string>, ResponseBody, RequestBody, RequestQuery>;
|
package/dist/middleware.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import {} from 'node:http';
|
|
1
3
|
import bodyParser from 'body-parser';
|
|
2
4
|
import { compose } from 'compose-middleware';
|
|
3
5
|
import cookieParser from 'cookie-parser';
|
|
4
6
|
import {} from 'express';
|
|
5
7
|
import helmet from 'helmet';
|
|
6
|
-
import { withRequestContext } from './context.js';
|
|
8
|
+
import { setContext, useContext, withRequestContext } from './context.js';
|
|
7
9
|
import { csrf, doubleCsrfProtection, } from './csrf.js';
|
|
8
10
|
import { NotFound } from './errors.js';
|
|
9
11
|
import { getLogger } from './logger.js';
|
|
@@ -12,6 +14,10 @@ export const urlencodedParser = bodyParser.urlencoded;
|
|
|
12
14
|
export const jsonParser = bodyParser.json;
|
|
13
15
|
const logger = getLogger('http');
|
|
14
16
|
export { asyncMiddleware as route };
|
|
17
|
+
const CSP_NONCE_KEY = 'csp-nonce';
|
|
18
|
+
export function useCspNonce() {
|
|
19
|
+
return useContext(CSP_NONCE_KEY) ?? 'fuck';
|
|
20
|
+
}
|
|
15
21
|
/**
|
|
16
22
|
* Register middleware that must come before the app routes.
|
|
17
23
|
*/
|
|
@@ -24,6 +30,10 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
24
30
|
? csrf()
|
|
25
31
|
: undefined,
|
|
26
32
|
logRequestDuration(),
|
|
33
|
+
((req, res, next) => {
|
|
34
|
+
setContext(CSP_NONCE_KEY, crypto.randomBytes(16).toString('base64'));
|
|
35
|
+
next();
|
|
36
|
+
}),
|
|
27
37
|
useHelmet &&
|
|
28
38
|
helmet({
|
|
29
39
|
contentSecurityPolicy: {
|
|
@@ -44,6 +54,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
44
54
|
'object-src': ["'none'"],
|
|
45
55
|
'script-src': [
|
|
46
56
|
"'self'",
|
|
57
|
+
() => `'nonce-${useCspNonce()}'`,
|
|
47
58
|
...(allowUnsafeInlineScripts ? ["'unsafe-inline'"] : []),
|
|
48
59
|
'*.cdn-apple.com',
|
|
49
60
|
],
|
package/dist/migrate.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as path from 'node:path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import {} from 'commander';
|
|
5
5
|
import {} from './cli.js';
|
|
6
|
-
import { createPostgres } from './postgres.js';
|
|
6
|
+
import { createPostgres, formatSql, sql } from './postgres.js';
|
|
7
7
|
export const asCli = (command, cli) => {
|
|
8
8
|
command.command('up').action(cli.wrap(async () => {
|
|
9
9
|
const migrator = new Migrator();
|
|
@@ -54,7 +54,7 @@ export const asCli = (command, cli) => {
|
|
|
54
54
|
}));
|
|
55
55
|
};
|
|
56
56
|
export class Migrator {
|
|
57
|
-
constructor({ postgres = createPostgres(), pathToMigrations = path.resolve(process.cwd(), 'db', 'migrations'), migrationTableName = 'migrations', verbose = true, } = {}) {
|
|
57
|
+
constructor({ postgres = createPostgres({ onLogStatement: () => { } }), pathToMigrations = path.resolve(process.cwd(), 'db', 'migrations'), migrationTableName = 'migrations', verbose = true, } = {}) {
|
|
58
58
|
this.filenameRegExp = /(\d+)_(.*)\.(js|ts|mjs|mts|sql)/;
|
|
59
59
|
this.postgres = postgres;
|
|
60
60
|
this.pathToMigrations = pathToMigrations;
|
|
@@ -69,18 +69,18 @@ export class Migrator {
|
|
|
69
69
|
return false;
|
|
70
70
|
}
|
|
71
71
|
await fs.mkdir(this.pathToMigrations, { recursive: true });
|
|
72
|
-
await this.postgres.query(`
|
|
73
|
-
CREATE TABLE IF NOT EXISTS ${this.migrationTableName} (
|
|
74
|
-
id
|
|
72
|
+
await this.postgres.query(sql `
|
|
73
|
+
CREATE TABLE IF NOT EXISTS ${sql.identifier(this.migrationTableName)} (
|
|
74
|
+
id bigserial PRIMARY KEY,
|
|
75
75
|
version int NOT NULL,
|
|
76
|
-
run_at timestamp NOT NULL
|
|
76
|
+
run_at timestamp with time zone NOT NULL
|
|
77
77
|
);
|
|
78
78
|
`);
|
|
79
79
|
return true;
|
|
80
80
|
}
|
|
81
81
|
async isInstalled() {
|
|
82
82
|
try {
|
|
83
|
-
await this.postgres.query(`SELECT * FROM ${this.migrationTableName}`);
|
|
83
|
+
await this.postgres.query(sql `SELECT * FROM ${sql.identifier(this.migrationTableName)}`);
|
|
84
84
|
await fs.stat(this.pathToMigrations);
|
|
85
85
|
return true;
|
|
86
86
|
}
|
|
@@ -199,9 +199,9 @@ export class Migrator {
|
|
|
199
199
|
}
|
|
200
200
|
async migrate(connection, migrator, direction) {
|
|
201
201
|
try {
|
|
202
|
-
const queryWithLog = (
|
|
203
|
-
this.log(chalk.yellow(
|
|
204
|
-
return connection.query(
|
|
202
|
+
const queryWithLog = (statement) => {
|
|
203
|
+
this.log(chalk.yellow(formatSql(statement)));
|
|
204
|
+
return connection.query(statement);
|
|
205
205
|
};
|
|
206
206
|
const returnValue = await migrator[direction](queryWithLog.bind(connection));
|
|
207
207
|
if (typeof returnValue === 'string') {
|
package/dist/postgres.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import pg, { type PoolClient, type PoolConfig, type QueryResult, type QueryResultRow } from 'pg';
|
|
2
2
|
export declare const escapePostgresIdentifier: typeof pg.escapeIdentifier;
|
|
3
3
|
export declare const escapePostgresLiteral: typeof pg.escapeLiteral;
|
|
4
|
+
/**
|
|
5
|
+
* Returns a pretty-printed SQL statement.
|
|
6
|
+
*/
|
|
4
7
|
export declare const formatSql: (statement: SqlStatement | string) => string;
|
|
5
8
|
export interface PostgresOptions extends PoolConfig, ConnectionOptions {
|
|
6
9
|
onError?: (error: Error, client: any) => void;
|
package/dist/postgres.js
CHANGED
|
@@ -4,6 +4,9 @@ import { config } from './config.js';
|
|
|
4
4
|
import { startTimer } from './utils.js';
|
|
5
5
|
export const escapePostgresIdentifier = pg.escapeIdentifier;
|
|
6
6
|
export const escapePostgresLiteral = pg.escapeLiteral;
|
|
7
|
+
/**
|
|
8
|
+
* Returns a pretty-printed SQL statement.
|
|
9
|
+
*/
|
|
7
10
|
export const formatSql = (statement) => {
|
|
8
11
|
const value = statement instanceof SqlStatement ? statement.text : statement;
|
|
9
12
|
return format(value, {
|