@zimic/interceptor 0.17.0-canary.2 → 0.17.0-canary.4
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/{chunk-TYHJPU5G.js → chunk-MXHLBRPB.js} +521 -61
- package/dist/chunk-MXHLBRPB.js.map +1 -0
- package/dist/{chunk-3SKHNQLL.mjs → chunk-OGL76CKO.mjs} +510 -60
- package/dist/chunk-OGL76CKO.mjs.map +1 -0
- package/dist/cli.js +141 -17
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +137 -13
- package/dist/cli.mjs.map +1 -1
- package/dist/http.d.ts +23 -2
- package/dist/http.js +473 -270
- package/dist/http.js.map +1 -1
- package/dist/http.mjs +473 -270
- package/dist/http.mjs.map +1 -1
- package/dist/scripts/postinstall.js +6 -6
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/scripts/postinstall.mjs +5 -5
- package/dist/scripts/postinstall.mjs.map +1 -1
- package/dist/server.d.ts +16 -0
- package/dist/server.js +6 -6
- package/dist/server.mjs +1 -1
- package/package.json +12 -11
- package/src/cli/browser/init.ts +5 -6
- package/src/cli/cli.ts +140 -55
- package/src/cli/server/start.ts +22 -7
- package/src/cli/server/token/create.ts +33 -0
- package/src/cli/server/token/list.ts +23 -0
- package/src/cli/server/token/remove.ts +22 -0
- package/src/http/interceptor/HttpInterceptorClient.ts +49 -27
- package/src/http/interceptor/HttpInterceptorStore.ts +25 -9
- package/src/http/interceptor/LocalHttpInterceptor.ts +6 -3
- package/src/http/interceptor/RemoteHttpInterceptor.ts +41 -5
- package/src/http/interceptor/errors/RunningHttpInterceptorError.ts +1 -1
- package/src/http/interceptor/types/options.ts +15 -0
- package/src/http/interceptor/types/public.ts +11 -3
- package/src/http/interceptorWorker/HttpInterceptorWorker.ts +14 -16
- package/src/http/interceptorWorker/RemoteHttpInterceptorWorker.ts +17 -12
- package/src/http/interceptorWorker/types/options.ts +1 -0
- package/src/http/requestHandler/errors/TimesCheckError.ts +1 -1
- package/src/server/InterceptorServer.ts +52 -8
- package/src/server/constants.ts +1 -1
- package/src/server/errors/InvalidInterceptorTokenError.ts +13 -0
- package/src/server/errors/InvalidInterceptorTokenFileError.ts +13 -0
- package/src/server/errors/InvalidInterceptorTokenValueError.ts +13 -0
- package/src/server/types/options.ts +9 -0
- package/src/server/types/public.ts +9 -0
- package/src/server/types/schema.ts +4 -4
- package/src/server/utils/auth.ts +304 -0
- package/src/server/utils/fetch.ts +3 -1
- package/src/utils/data.ts +13 -0
- package/src/utils/files.ts +14 -0
- package/src/utils/{console.ts → logging.ts} +5 -7
- package/src/utils/webSocket.ts +57 -11
- package/src/webSocket/WebSocketClient.ts +11 -5
- package/src/webSocket/WebSocketHandler.ts +72 -51
- package/src/webSocket/WebSocketServer.ts +25 -4
- package/src/webSocket/constants.ts +2 -0
- package/src/webSocket/errors/UnauthorizedWebSocketConnectionError.ts +11 -0
- package/src/webSocket/types.ts +49 -52
- package/dist/chunk-3SKHNQLL.mjs.map +0 -1
- package/dist/chunk-TYHJPU5G.js.map +0 -1
|
@@ -10,8 +10,8 @@ import { removeArrayIndex } from '@/utils/arrays';
|
|
|
10
10
|
import { deserializeResponse, SerializedHttpRequest, serializeRequest } from '@/utils/fetch';
|
|
11
11
|
import { getHttpServerPort, startHttpServer, stopHttpServer } from '@/utils/http';
|
|
12
12
|
import { WebSocketMessageAbortError } from '@/utils/webSocket';
|
|
13
|
-
import {
|
|
14
|
-
import WebSocketServer from '@/webSocket/WebSocketServer';
|
|
13
|
+
import { WebSocketEventMessage } from '@/webSocket/types';
|
|
14
|
+
import WebSocketServer, { WebSocketServerAuthenticate } from '@/webSocket/WebSocketServer';
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
DEFAULT_ACCESS_CONTROL_HEADERS,
|
|
@@ -24,6 +24,7 @@ import RunningInterceptorServerError from './errors/RunningInterceptorServerErro
|
|
|
24
24
|
import { InterceptorServerOptions } from './types/options';
|
|
25
25
|
import { InterceptorServer as PublicInterceptorServer } from './types/public';
|
|
26
26
|
import { HttpHandlerCommit, InterceptorServerWebSocketSchema } from './types/schema';
|
|
27
|
+
import { validateInterceptorToken } from './utils/auth';
|
|
27
28
|
import { getFetchAPI } from './utils/fetch';
|
|
28
29
|
|
|
29
30
|
interface HttpHandler {
|
|
@@ -42,6 +43,7 @@ class InterceptorServer implements PublicInterceptorServer {
|
|
|
42
43
|
_hostname: string;
|
|
43
44
|
_port: number | undefined;
|
|
44
45
|
logUnhandledRequests: boolean;
|
|
46
|
+
tokensDirectory?: string;
|
|
45
47
|
|
|
46
48
|
private httpHandlerGroups: {
|
|
47
49
|
[Method in HttpMethod]: HttpHandler[];
|
|
@@ -61,6 +63,7 @@ class InterceptorServer implements PublicInterceptorServer {
|
|
|
61
63
|
this._hostname = options.hostname ?? DEFAULT_HOSTNAME;
|
|
62
64
|
this._port = options.port;
|
|
63
65
|
this.logUnhandledRequests = options.logUnhandledRequests ?? DEFAULT_LOG_UNHANDLED_REQUESTS;
|
|
66
|
+
this.tokensDirectory = options.tokensDirectory;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
get hostname() {
|
|
@@ -120,10 +123,48 @@ class InterceptorServer implements PublicInterceptorServer {
|
|
|
120
123
|
|
|
121
124
|
this.webSocketServer = new WebSocketServer({
|
|
122
125
|
httpServer: this.httpServer,
|
|
126
|
+
authenticate: this.authenticateWebSocketConnection,
|
|
123
127
|
});
|
|
128
|
+
|
|
124
129
|
this.startWebSocketServer();
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
private authenticateWebSocketConnection: WebSocketServerAuthenticate = async (_socket, request) => {
|
|
133
|
+
if (!this.tokensDirectory) {
|
|
134
|
+
return { isValid: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const tokenValue = this.getWebSocketRequestTokenValue(request);
|
|
138
|
+
|
|
139
|
+
if (!tokenValue) {
|
|
140
|
+
return { isValid: false, message: 'An interceptor token is required, but none was provided.' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await validateInterceptorToken(tokenValue, { tokensDirectory: this.tokensDirectory });
|
|
145
|
+
return { isValid: true };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(error);
|
|
148
|
+
return { isValid: false, message: 'The interceptor token is not valid.' };
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
private getWebSocketRequestTokenValue(request: IncomingMessage) {
|
|
153
|
+
const protocols = request.headers['sec-websocket-protocol'] ?? '';
|
|
154
|
+
const parametersAsString = decodeURIComponent(protocols).split(', ');
|
|
155
|
+
|
|
156
|
+
for (const parameterAsString of parametersAsString) {
|
|
157
|
+
const tokenValueMatch = /^token=(?<tokenValue>.+?)$/.exec(parameterAsString);
|
|
158
|
+
const tokenValue = tokenValueMatch?.groups?.tokenValue;
|
|
159
|
+
|
|
160
|
+
if (tokenValue) {
|
|
161
|
+
return tokenValue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
127
168
|
private async startHttpServer() {
|
|
128
169
|
await startHttpServer(this.httpServerOrThrow, {
|
|
129
170
|
hostname: this.hostname,
|
|
@@ -136,22 +177,25 @@ class InterceptorServer implements PublicInterceptorServer {
|
|
|
136
177
|
|
|
137
178
|
private startWebSocketServer() {
|
|
138
179
|
this.webSocketServerOrThrow.start();
|
|
139
|
-
|
|
140
|
-
this.webSocketServerOrThrow.onEvent('interceptors/workers/
|
|
180
|
+
|
|
181
|
+
this.webSocketServerOrThrow.onEvent('interceptors/workers/commit', this.commitWorker);
|
|
182
|
+
this.webSocketServerOrThrow.onEvent('interceptors/workers/reset', this.resetWorker);
|
|
141
183
|
}
|
|
142
184
|
|
|
143
185
|
private commitWorker = (
|
|
144
|
-
message:
|
|
186
|
+
message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/commit'>,
|
|
145
187
|
socket: Socket,
|
|
146
188
|
) => {
|
|
147
189
|
const commit = message.data;
|
|
190
|
+
|
|
148
191
|
this.registerHttpHandler(commit, socket);
|
|
149
192
|
this.registerWorkerSocketIfUnknown(socket);
|
|
193
|
+
|
|
150
194
|
return {};
|
|
151
195
|
};
|
|
152
196
|
|
|
153
197
|
private resetWorker = (
|
|
154
|
-
message:
|
|
198
|
+
message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/reset'>,
|
|
155
199
|
socket: Socket,
|
|
156
200
|
) => {
|
|
157
201
|
this.removeHttpHandlersBySocket(socket);
|
|
@@ -226,8 +270,8 @@ class InterceptorServer implements PublicInterceptorServer {
|
|
|
226
270
|
}
|
|
227
271
|
|
|
228
272
|
private async stopWebSocketServer() {
|
|
229
|
-
this.webSocketServerOrThrow.offEvent('interceptors/workers/
|
|
230
|
-
this.webSocketServerOrThrow.offEvent('interceptors/workers/
|
|
273
|
+
this.webSocketServerOrThrow.offEvent('interceptors/workers/commit', this.commitWorker);
|
|
274
|
+
this.webSocketServerOrThrow.offEvent('interceptors/workers/reset', this.resetWorker);
|
|
231
275
|
|
|
232
276
|
await this.webSocketServerOrThrow.stop();
|
|
233
277
|
|
package/src/server/constants.ts
CHANGED
|
@@ -22,7 +22,7 @@ export const DEFAULT_ACCESS_CONTROL_HEADERS = Object.freeze({
|
|
|
22
22
|
'access-control-allow-methods': ALLOWED_ACCESS_CONTROL_HTTP_METHODS,
|
|
23
23
|
'access-control-allow-headers': '*',
|
|
24
24
|
'access-control-expose-headers': '*',
|
|
25
|
-
'access-control-max-age': process.env.
|
|
25
|
+
'access-control-max-age': process.env.INTERCEPTOR_SERVER_ACCESS_CONTROL_MAX_AGE,
|
|
26
26
|
}) satisfies AccessControlHeaders;
|
|
27
27
|
|
|
28
28
|
/** The default status code for the preflight request. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when an interceptor token is invalid.
|
|
3
|
+
*
|
|
4
|
+
* @see {@link https://github.com/zimicjs/zimic/wiki/cli‐zimic‐interceptor‐server#authentication Interceptor server authentication}
|
|
5
|
+
*/
|
|
6
|
+
class InvalidInterceptorTokenError extends Error {
|
|
7
|
+
constructor(tokenId: string) {
|
|
8
|
+
super(`Invalid interceptor token: ${tokenId}`);
|
|
9
|
+
this.name = 'InvalidInterceptorTokenError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default InvalidInterceptorTokenError;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when an interceptor token file is invalid.
|
|
3
|
+
*
|
|
4
|
+
* @see {@link https://github.com/zimicjs/zimic/wiki/cli‐zimic‐interceptor‐server#authentication Interceptor server authentication}
|
|
5
|
+
*/
|
|
6
|
+
class InvalidInterceptorTokenFileError extends Error {
|
|
7
|
+
constructor(tokenFilePath: string, validationErrorMessage: string) {
|
|
8
|
+
super(`Invalid interceptor token file ${tokenFilePath}: ${validationErrorMessage}`);
|
|
9
|
+
this.name = 'InvalidInterceptorTokenFileError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default InvalidInterceptorTokenFileError;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error thrown when an interceptor token value is invalid.
|
|
3
|
+
*
|
|
4
|
+
* @see {@link https://github.com/zimicjs/zimic/wiki/cli‐zimic‐interceptor‐server#authentication Interceptor server authentication}
|
|
5
|
+
*/
|
|
6
|
+
class InvalidInterceptorTokenValueError extends Error {
|
|
7
|
+
constructor(tokenValue: string) {
|
|
8
|
+
super(`Invalid interceptor token value: ${tokenValue}`);
|
|
9
|
+
this.name = 'InvalidInterceptorTokenValueError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default InvalidInterceptorTokenValueError;
|
|
@@ -21,4 +21,13 @@ export interface InterceptorServerOptions {
|
|
|
21
21
|
* @default true
|
|
22
22
|
*/
|
|
23
23
|
logUnhandledRequests?: boolean;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The directory where the authorized interceptor authentication tokens are saved. If provided, only remote
|
|
27
|
+
* interceptors bearing a valid token will be accepted. This option is essential if you are exposing your interceptor
|
|
28
|
+
* server publicly. For local development and testing, though, `--tokens-dir` is optional.
|
|
29
|
+
*
|
|
30
|
+
* @default undefined
|
|
31
|
+
*/
|
|
32
|
+
tokensDirectory?: string;
|
|
24
33
|
}
|
|
@@ -29,6 +29,15 @@ export interface InterceptorServer {
|
|
|
29
29
|
*/
|
|
30
30
|
logUnhandledRequests: boolean;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The directory where the authorized interceptor authentication tokens are saved. If provided, only remote
|
|
34
|
+
* interceptors bearing a valid token will be accepted. This option is essential if you are exposing your interceptor
|
|
35
|
+
* server publicly. For local development and testing, though, `--tokens-dir` is optional.
|
|
36
|
+
*
|
|
37
|
+
* @default undefined
|
|
38
|
+
*/
|
|
39
|
+
tokensDirectory?: string;
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* Whether the server is running.
|
|
34
43
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HttpMethod } from '@zimic/http';
|
|
2
2
|
|
|
3
3
|
import { SerializedHttpRequest, SerializedResponse } from '@/utils/fetch';
|
|
4
|
-
import {
|
|
4
|
+
import { WebSocketSchema } from '@/webSocket/types';
|
|
5
5
|
|
|
6
6
|
export interface HttpHandlerCommit {
|
|
7
7
|
id: string;
|
|
@@ -9,13 +9,13 @@ export interface HttpHandlerCommit {
|
|
|
9
9
|
method: HttpMethod;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export type InterceptorServerWebSocketSchema =
|
|
13
|
-
'interceptors/workers/
|
|
12
|
+
export type InterceptorServerWebSocketSchema = WebSocketSchema<{
|
|
13
|
+
'interceptors/workers/commit': {
|
|
14
14
|
event: HttpHandlerCommit;
|
|
15
15
|
reply: {};
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
'interceptors/workers/
|
|
18
|
+
'interceptors/workers/reset': {
|
|
19
19
|
event?: HttpHandlerCommit[];
|
|
20
20
|
reply: {};
|
|
21
21
|
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import color from 'picocolors';
|
|
6
|
+
import util from 'util';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
BASE64URL_REGEX,
|
|
11
|
+
convertHexLengthToBase64urlLength,
|
|
12
|
+
convertHexLengthToByteLength,
|
|
13
|
+
HEX_REGEX,
|
|
14
|
+
} from '@/utils/data';
|
|
15
|
+
import { pathExists } from '@/utils/files';
|
|
16
|
+
import { logger } from '@/utils/logging';
|
|
17
|
+
|
|
18
|
+
import InvalidInterceptorTokenError from '../errors/InvalidInterceptorTokenError';
|
|
19
|
+
import InvalidInterceptorTokenFileError from '../errors/InvalidInterceptorTokenFileError';
|
|
20
|
+
import InvalidInterceptorTokenValueError from '../errors/InvalidInterceptorTokenValueError';
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY = path.join(
|
|
23
|
+
'.zimic',
|
|
24
|
+
'interceptor',
|
|
25
|
+
'server',
|
|
26
|
+
`tokens${process.env.VITEST_POOL_ID}`,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export const INTERCEPTOR_TOKEN_ID_HEX_LENGTH = 32;
|
|
30
|
+
|
|
31
|
+
export const INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH = 64;
|
|
32
|
+
export const INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH = INTERCEPTOR_TOKEN_ID_HEX_LENGTH + INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH;
|
|
33
|
+
export const INTERCEPTOR_TOKEN_VALUE_BASE64URL_LENGTH = convertHexLengthToBase64urlLength(
|
|
34
|
+
INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const INTERCEPTOR_TOKEN_SALT_HEX_LENGTH = 64;
|
|
38
|
+
export const INTERCEPTOR_TOKEN_HASH_ITERATIONS = Number(process.env.INTERCEPTOR_TOKEN_HASH_ITERATIONS);
|
|
39
|
+
export const INTERCEPTOR_TOKEN_HASH_HEX_LENGTH = 128;
|
|
40
|
+
export const INTERCEPTOR_TOKEN_HASH_ALGORITHM = 'sha512';
|
|
41
|
+
|
|
42
|
+
const pbkdf2 = util.promisify(crypto.pbkdf2);
|
|
43
|
+
|
|
44
|
+
async function hashInterceptorToken(plainToken: string, salt: string) {
|
|
45
|
+
const hashBuffer = await pbkdf2(
|
|
46
|
+
plainToken,
|
|
47
|
+
salt,
|
|
48
|
+
INTERCEPTOR_TOKEN_HASH_ITERATIONS,
|
|
49
|
+
convertHexLengthToByteLength(INTERCEPTOR_TOKEN_HASH_HEX_LENGTH),
|
|
50
|
+
INTERCEPTOR_TOKEN_HASH_ALGORITHM,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const hash = hashBuffer.toString('hex');
|
|
54
|
+
return hash;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface InterceptorTokenSecret {
|
|
58
|
+
hash: string;
|
|
59
|
+
salt: string;
|
|
60
|
+
value: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface InterceptorToken {
|
|
64
|
+
id: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
secret: InterceptorTokenSecret;
|
|
67
|
+
value: string;
|
|
68
|
+
createdAt: Date;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createInterceptorTokenId() {
|
|
72
|
+
return crypto.randomUUID().replace(/[^a-z0-9]/g, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isValidInterceptorTokenId(tokenId: string) {
|
|
76
|
+
return tokenId.length === INTERCEPTOR_TOKEN_ID_HEX_LENGTH && HEX_REGEX.test(tokenId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isValidInterceptorTokenValue(tokenValue: string) {
|
|
80
|
+
return tokenValue.length === INTERCEPTOR_TOKEN_VALUE_BASE64URL_LENGTH && BASE64URL_REGEX.test(tokenValue);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function createInterceptorTokensDirectory(tokensDirectory: string) {
|
|
84
|
+
try {
|
|
85
|
+
const parentTokensDirectory = path.dirname(tokensDirectory);
|
|
86
|
+
await fs.promises.mkdir(parentTokensDirectory, { recursive: true });
|
|
87
|
+
await fs.promises.mkdir(tokensDirectory, { mode: 0o700, recursive: true });
|
|
88
|
+
await fs.promises.appendFile(path.join(tokensDirectory, '.gitignore'), `*${os.EOL}`, { encoding: 'utf-8' });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logger.error(
|
|
91
|
+
`${color.red(color.bold('✖'))} Failed to create the tokens directory: ${color.magenta(tokensDirectory)}`,
|
|
92
|
+
);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const interceptorTokenFileContentSchema = z.object({
|
|
98
|
+
version: z.literal(1),
|
|
99
|
+
token: z.object({
|
|
100
|
+
id: z.string().length(INTERCEPTOR_TOKEN_ID_HEX_LENGTH).regex(HEX_REGEX),
|
|
101
|
+
name: z.string().optional(),
|
|
102
|
+
secret: z.object({
|
|
103
|
+
hash: z.string().length(INTERCEPTOR_TOKEN_HASH_HEX_LENGTH).regex(HEX_REGEX),
|
|
104
|
+
salt: z.string().length(INTERCEPTOR_TOKEN_SALT_HEX_LENGTH).regex(HEX_REGEX),
|
|
105
|
+
}),
|
|
106
|
+
createdAt: z
|
|
107
|
+
.string()
|
|
108
|
+
.datetime()
|
|
109
|
+
.transform((value) => new Date(value)),
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type InterceptorTokenFileContent = z.infer<typeof interceptorTokenFileContentSchema>;
|
|
114
|
+
|
|
115
|
+
export namespace InterceptorTokenFileContent {
|
|
116
|
+
export type Input = z.input<typeof interceptorTokenFileContentSchema>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type PersistedInterceptorToken = InterceptorTokenFileContent['token'];
|
|
120
|
+
|
|
121
|
+
namespace PersistedInterceptorToken {
|
|
122
|
+
export type Input = InterceptorTokenFileContent.Input['token'];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function saveInterceptorTokenToFile(tokensDirectory: string, token: InterceptorToken) {
|
|
126
|
+
const tokeFilePath = path.join(tokensDirectory, token.id);
|
|
127
|
+
|
|
128
|
+
const persistedToken: PersistedInterceptorToken.Input = {
|
|
129
|
+
id: token.id,
|
|
130
|
+
name: token.name,
|
|
131
|
+
secret: {
|
|
132
|
+
hash: token.secret.hash,
|
|
133
|
+
salt: token.secret.salt,
|
|
134
|
+
},
|
|
135
|
+
createdAt: token.createdAt.toISOString(),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const tokenFileContent = interceptorTokenFileContentSchema.parse({
|
|
139
|
+
version: 1,
|
|
140
|
+
token: persistedToken,
|
|
141
|
+
} satisfies InterceptorTokenFileContent.Input);
|
|
142
|
+
|
|
143
|
+
await fs.promises.writeFile(tokeFilePath, JSON.stringify(tokenFileContent, null, 2), {
|
|
144
|
+
mode: 0o600,
|
|
145
|
+
encoding: 'utf-8',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return tokeFilePath;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function readInterceptorTokenFromFile(
|
|
152
|
+
tokenId: InterceptorToken['id'],
|
|
153
|
+
options: { tokensDirectory: string },
|
|
154
|
+
): Promise<PersistedInterceptorToken | null> {
|
|
155
|
+
if (!isValidInterceptorTokenId(tokenId)) {
|
|
156
|
+
throw new InvalidInterceptorTokenError(tokenId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tokenFilePath = path.join(options.tokensDirectory, tokenId);
|
|
160
|
+
const tokenFileExists = await pathExists(tokenFilePath);
|
|
161
|
+
|
|
162
|
+
if (!tokenFileExists) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tokenFileContentAsString = await fs.promises.readFile(tokenFilePath, { encoding: 'utf-8' });
|
|
167
|
+
|
|
168
|
+
const validation = interceptorTokenFileContentSchema.safeParse(JSON.parse(tokenFileContentAsString) as unknown);
|
|
169
|
+
|
|
170
|
+
if (!validation.success) {
|
|
171
|
+
throw new InvalidInterceptorTokenFileError(tokenFilePath, validation.error.message);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return validation.data.token;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function createInterceptorToken(
|
|
178
|
+
options: { name?: string; tokensDirectory?: string } = {},
|
|
179
|
+
): Promise<InterceptorToken> {
|
|
180
|
+
const { name, tokensDirectory = DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY } = options;
|
|
181
|
+
|
|
182
|
+
const tokensDirectoryExists = await pathExists(tokensDirectory);
|
|
183
|
+
|
|
184
|
+
if (!tokensDirectoryExists) {
|
|
185
|
+
await createInterceptorTokensDirectory(tokensDirectory);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const tokenId = createInterceptorTokenId();
|
|
189
|
+
|
|
190
|
+
/* istanbul ignore if -- @preserve
|
|
191
|
+
* This should never happen, but let's check that the token identifier is valid after generated. */
|
|
192
|
+
if (!isValidInterceptorTokenId(tokenId)) {
|
|
193
|
+
throw new InvalidInterceptorTokenError(tokenId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tokenSecretSizeInBytes = convertHexLengthToByteLength(INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH);
|
|
197
|
+
const tokenSecret = crypto.randomBytes(tokenSecretSizeInBytes).toString('hex');
|
|
198
|
+
|
|
199
|
+
const tokenSecretSaltSizeInBytes = convertHexLengthToByteLength(INTERCEPTOR_TOKEN_SALT_HEX_LENGTH);
|
|
200
|
+
const tokenSecretSalt = crypto.randomBytes(tokenSecretSaltSizeInBytes).toString('hex');
|
|
201
|
+
const tokenSecretHash = await hashInterceptorToken(tokenSecret, tokenSecretSalt);
|
|
202
|
+
|
|
203
|
+
const tokenValue = Buffer.from(`${tokenId}${tokenSecret}`, 'hex').toString('base64url');
|
|
204
|
+
|
|
205
|
+
/* istanbul ignore if -- @preserve
|
|
206
|
+
* This should never happen, but let's check that the token value is valid after generated. */
|
|
207
|
+
if (!isValidInterceptorTokenValue(tokenValue)) {
|
|
208
|
+
throw new InvalidInterceptorTokenValueError(tokenValue);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const token: InterceptorToken = {
|
|
212
|
+
id: tokenId,
|
|
213
|
+
name,
|
|
214
|
+
secret: {
|
|
215
|
+
hash: tokenSecretHash,
|
|
216
|
+
salt: tokenSecretSalt,
|
|
217
|
+
value: tokenSecret,
|
|
218
|
+
},
|
|
219
|
+
value: tokenValue,
|
|
220
|
+
createdAt: new Date(),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await saveInterceptorTokenToFile(tokensDirectory, token);
|
|
224
|
+
|
|
225
|
+
return token;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function listInterceptorTokens(options: { tokensDirectory?: string } = {}) {
|
|
229
|
+
const { tokensDirectory = DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY } = options;
|
|
230
|
+
|
|
231
|
+
const tokensDirectoryExists = await pathExists(tokensDirectory);
|
|
232
|
+
|
|
233
|
+
if (!tokensDirectoryExists) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const files = await fs.promises.readdir(tokensDirectory);
|
|
238
|
+
|
|
239
|
+
const tokenReadPromises = files.map(async (file) => {
|
|
240
|
+
if (!isValidInterceptorTokenId(file)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tokenId = file;
|
|
245
|
+
const token = await readInterceptorTokenFromFile(tokenId, { tokensDirectory });
|
|
246
|
+
return token;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const tokenCandidates = await Promise.allSettled(tokenReadPromises);
|
|
250
|
+
|
|
251
|
+
const tokens: PersistedInterceptorToken[] = [];
|
|
252
|
+
|
|
253
|
+
for (const tokenCandidate of tokenCandidates) {
|
|
254
|
+
if (tokenCandidate.status === 'rejected') {
|
|
255
|
+
console.error(tokenCandidate.reason);
|
|
256
|
+
} else if (tokenCandidate.value !== null) {
|
|
257
|
+
tokens.push(tokenCandidate.value);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
tokens.sort((token, otherToken) => token.createdAt.getTime() - otherToken.createdAt.getTime());
|
|
262
|
+
|
|
263
|
+
return tokens;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function validateInterceptorToken(tokenValue: string, options: { tokensDirectory: string }) {
|
|
267
|
+
if (!isValidInterceptorTokenValue(tokenValue)) {
|
|
268
|
+
throw new InvalidInterceptorTokenValueError(tokenValue);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const decodedTokenValue = Buffer.from(tokenValue, 'base64url').toString('hex');
|
|
272
|
+
|
|
273
|
+
const tokenId = decodedTokenValue.slice(0, INTERCEPTOR_TOKEN_ID_HEX_LENGTH);
|
|
274
|
+
const tokenSecret = decodedTokenValue.slice(
|
|
275
|
+
INTERCEPTOR_TOKEN_ID_HEX_LENGTH,
|
|
276
|
+
INTERCEPTOR_TOKEN_ID_HEX_LENGTH + INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const tokenFromFile = await readInterceptorTokenFromFile(tokenId, options);
|
|
280
|
+
|
|
281
|
+
if (!tokenFromFile) {
|
|
282
|
+
throw new InvalidInterceptorTokenValueError(tokenValue);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const tokenSecretHash = await hashInterceptorToken(tokenSecret, tokenFromFile.secret.salt);
|
|
286
|
+
|
|
287
|
+
if (tokenSecretHash !== tokenFromFile.secret.hash) {
|
|
288
|
+
throw new InvalidInterceptorTokenValueError(tokenValue);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function removeInterceptorToken(tokenId: string, options: { tokensDirectory?: string } = {}) {
|
|
293
|
+
const { tokensDirectory = DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY } = options;
|
|
294
|
+
|
|
295
|
+
/* istanbul ignore if -- @preserve
|
|
296
|
+
* At this point, we should have a valid tokenId. This is just a sanity check. */
|
|
297
|
+
if (!isValidInterceptorTokenId(tokenId)) {
|
|
298
|
+
throw new InvalidInterceptorTokenError(tokenId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const tokenFilePath = path.join(tokensDirectory, tokenId);
|
|
302
|
+
|
|
303
|
+
await fs.promises.rm(tokenFilePath, { force: true });
|
|
304
|
+
}
|
|
@@ -3,6 +3,8 @@ import { FetchAPI } from '@whatwg-node/server';
|
|
|
3
3
|
import { importFile } from '@/utils/files';
|
|
4
4
|
|
|
5
5
|
export async function getFetchAPI(): Promise<FetchAPI> {
|
|
6
|
+
const File = await importFile();
|
|
7
|
+
|
|
6
8
|
return {
|
|
7
9
|
fetch,
|
|
8
10
|
Request,
|
|
@@ -17,7 +19,7 @@ export async function getFetchAPI(): Promise<FetchAPI> {
|
|
|
17
19
|
TextDecoderStream,
|
|
18
20
|
TextEncoderStream,
|
|
19
21
|
Blob,
|
|
20
|
-
File
|
|
22
|
+
File,
|
|
21
23
|
crypto: globalThis.crypto,
|
|
22
24
|
btoa,
|
|
23
25
|
TextEncoder,
|
package/src/utils/data.ts
CHANGED
|
@@ -26,3 +26,16 @@ export function convertBase64ToArrayBuffer(base64Value: string) {
|
|
|
26
26
|
return Buffer.from(base64Value, 'base64');
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
export const HEX_REGEX = /^[a-z0-9]+$/;
|
|
31
|
+
|
|
32
|
+
export function convertHexLengthToByteLength(hexLength: number) {
|
|
33
|
+
return Math.ceil(hexLength / 2); // 1 byte = 2 hex characters
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const BASE64URL_REGEX = /^[a-zA-Z0-9-_]+$/;
|
|
37
|
+
|
|
38
|
+
export function convertHexLengthToBase64urlLength(hexLength: number) {
|
|
39
|
+
const byteLength = convertHexLengthToByteLength(hexLength);
|
|
40
|
+
return Math.ceil((byteLength * 4) / 3); // 1 byte = 4/3 base64url characters
|
|
41
|
+
}
|
package/src/utils/files.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import createCachedDynamicImport from '@zimic/utils/import/createCachedDynamicImport';
|
|
2
|
+
import type fs from 'fs';
|
|
2
3
|
|
|
3
4
|
export const importFile = createCachedDynamicImport(
|
|
4
5
|
/* istanbul ignore next -- @preserve
|
|
@@ -11,3 +12,16 @@ export function isGlobalFileAvailable() {
|
|
|
11
12
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
12
13
|
return globalThis.File !== undefined;
|
|
13
14
|
}
|
|
15
|
+
|
|
16
|
+
export const importFilesystem = createCachedDynamicImport<typeof fs>(() => import('fs'));
|
|
17
|
+
|
|
18
|
+
export async function pathExists(path: string) {
|
|
19
|
+
const fs = await importFilesystem();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await fs.promises.access(path);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { HttpFormData, HttpHeaders, HttpSearchParams } from '@zimic/http';
|
|
2
2
|
import createCachedDynamicImport from '@zimic/utils/import/createCachedDynamicImport';
|
|
3
|
+
import Logger from '@zimic/utils/logging/Logger';
|
|
3
4
|
import color from 'picocolors';
|
|
4
5
|
|
|
5
6
|
import { isClientSide } from './environment';
|
|
6
7
|
import { isGlobalFileAvailable } from './files';
|
|
7
8
|
|
|
9
|
+
export const logger = new Logger({
|
|
10
|
+
prefix: color.cyan('[@zimic/interceptor]'),
|
|
11
|
+
});
|
|
12
|
+
|
|
8
13
|
function stringifyJSONToLog(value: unknown): string {
|
|
9
14
|
return JSON.stringify(
|
|
10
15
|
value,
|
|
@@ -79,10 +84,3 @@ export async function formatValueToLog(value: unknown, options: { colors?: boole
|
|
|
79
84
|
sorted: true,
|
|
80
85
|
});
|
|
81
86
|
}
|
|
82
|
-
|
|
83
|
-
export function logWithPrefix(messageOrMessages: unknown, options: { method?: 'log' | 'warn' | 'error' } = {}) {
|
|
84
|
-
const { method = 'log' } = options;
|
|
85
|
-
|
|
86
|
-
const messages = Array.isArray(messageOrMessages) ? messageOrMessages : [messageOrMessages];
|
|
87
|
-
console[method](color.cyan('[@zimic/interceptor]'), ...messages);
|
|
88
|
-
}
|