@prosopo/api-express-router 3.1.97 → 3.2.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/.turbo/turbo-build$colon$cjs.log +6 -5
- package/.turbo/turbo-build$colon$tsc.log +21 -21
- package/.turbo/turbo-build.log +7 -6
- package/CHANGELOG.md +40 -0
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/middlewares/authMiddleware.cjs +25 -14
- package/dist/cjs/middlewares/jwtReplayGuard.cjs +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/middlewares/authMiddleware.d.ts +7 -1
- package/dist/middlewares/authMiddleware.d.ts.map +1 -1
- package/dist/middlewares/authMiddleware.js +26 -15
- package/dist/middlewares/authMiddleware.js.map +1 -1
- package/dist/middlewares/jwtReplayGuard.d.ts +5 -0
- package/dist/middlewares/jwtReplayGuard.d.ts.map +1 -0
- package/dist/middlewares/jwtReplayGuard.js +28 -0
- package/dist/middlewares/jwtReplayGuard.js.map +1 -0
- package/dist/tests/apiExpressRouter.test-d.js +1 -1
- package/dist/tests/apiExpressRouter.test-d.js.map +1 -1
- package/dist/tests/unit/middlewares/authMiddleware.unit.test.js +94 -8
- package/dist/tests/unit/middlewares/authMiddleware.unit.test.js.map +1 -1
- package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.d.ts +2 -0
- package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.d.ts.map +1 -0
- package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.js +36 -0
- package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.js.map +1 -0
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/middlewares/authMiddleware.ts +73 -16
- package/src/middlewares/jwtReplayGuard.ts +56 -0
- package/src/tests/apiExpressRouter.test-d.ts +8 -2
- package/src/tests/unit/middlewares/authMiddleware.unit.test.ts +118 -11
- package/src/tests/unit/middlewares/jwtReplayGuard.unit.test.ts +53 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -15,14 +15,20 @@
|
|
|
15
15
|
import type { IncomingHttpHeaders } from "node:http";
|
|
16
16
|
import { ProsopoApiError } from "@prosopo/common";
|
|
17
17
|
import type { Logger } from "@prosopo/logger";
|
|
18
|
-
import type { KeyringPair } from "@prosopo/types";
|
|
19
|
-
import type {
|
|
18
|
+
import type { ApiJsonError, KeyringPair } from "@prosopo/types";
|
|
19
|
+
import type {
|
|
20
|
+
JWT,
|
|
21
|
+
JWTVerifyOptions,
|
|
22
|
+
JWTVerifyResult,
|
|
23
|
+
} from "@prosopo/util-crypto";
|
|
20
24
|
import type { Request, Response } from "express";
|
|
21
25
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
22
26
|
import {
|
|
27
|
+
type AuthMiddlewareOptions,
|
|
23
28
|
authMiddleware,
|
|
24
29
|
verifySignature,
|
|
25
30
|
} from "../../../middlewares/authMiddleware.js";
|
|
31
|
+
import { createMemoryJwtReplayGuard } from "../../../middlewares/jwtReplayGuard.js";
|
|
26
32
|
import { type NextCapture, captureNext } from "../testDoubles.js";
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -32,7 +38,10 @@ import { type NextCapture, captureNext } from "../testDoubles.js";
|
|
|
32
38
|
* itself throws.
|
|
33
39
|
*/
|
|
34
40
|
|
|
35
|
-
type Verify = (
|
|
41
|
+
type Verify = (
|
|
42
|
+
jwt: JWT,
|
|
43
|
+
options?: JWTVerifyOptions,
|
|
44
|
+
) => Pick<JWTVerifyResult, "isValid" | "payload">;
|
|
36
45
|
|
|
37
46
|
/** A pair that only implements the parts the middleware actually calls. */
|
|
38
47
|
const pairThat = (verify: Verify): KeyringPair =>
|
|
@@ -61,7 +70,10 @@ interface Harness {
|
|
|
61
70
|
logged: ReturnType<typeof vi.fn<(entry: () => unknown) => void>>;
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
const build = (
|
|
73
|
+
const build = (
|
|
74
|
+
headers: IncomingHttpHeaders = {},
|
|
75
|
+
i18n?: { t: (key: string) => string },
|
|
76
|
+
): Harness => {
|
|
65
77
|
const json = vi.fn<(body: unknown) => Response>();
|
|
66
78
|
const status = vi.fn<(code: number) => Response>();
|
|
67
79
|
const res = { status, json } as unknown as Response;
|
|
@@ -70,6 +82,7 @@ const build = (headers: IncomingHttpHeaders = {}): Harness => {
|
|
|
70
82
|
const logged = vi.fn<(entry: () => unknown) => void>();
|
|
71
83
|
const req = {
|
|
72
84
|
headers,
|
|
85
|
+
i18n,
|
|
73
86
|
logger: { error: logged } as unknown as Logger,
|
|
74
87
|
} as unknown as Request;
|
|
75
88
|
|
|
@@ -89,8 +102,9 @@ beforeEach(() => {
|
|
|
89
102
|
const run = async (
|
|
90
103
|
pair: KeyringPair | undefined,
|
|
91
104
|
authAccount?: KeyringPair | undefined,
|
|
105
|
+
options?: AuthMiddlewareOptions,
|
|
92
106
|
): Promise<void> => {
|
|
93
|
-
await authMiddleware(pair, authAccount)(
|
|
107
|
+
await authMiddleware(pair, authAccount, options)(
|
|
94
108
|
harness.req,
|
|
95
109
|
harness.res,
|
|
96
110
|
harness.next.fn,
|
|
@@ -130,7 +144,7 @@ describe("a token one of the keys accepts", () => {
|
|
|
130
144
|
test("hands the verifier the token without its Bearer prefix", async () => {
|
|
131
145
|
const pair = accepts();
|
|
132
146
|
await run(pair, undefined);
|
|
133
|
-
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1");
|
|
147
|
+
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", undefined);
|
|
134
148
|
});
|
|
135
149
|
});
|
|
136
150
|
|
|
@@ -145,8 +159,59 @@ describe("a token neither key accepts", () => {
|
|
|
145
159
|
});
|
|
146
160
|
|
|
147
161
|
test("is answered with an error body carrying the 401 code", () => {
|
|
148
|
-
const body = harness.json.mock.calls[0]?.[0] as { error:
|
|
149
|
-
expect(body.error
|
|
162
|
+
const body = harness.json.mock.calls[0]?.[0] as { error: ApiJsonError };
|
|
163
|
+
expect(body.error).toEqual({
|
|
164
|
+
code: 401,
|
|
165
|
+
key: "API.UNAUTHORIZED",
|
|
166
|
+
message: "API.UNAUTHORIZED",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("does not serialise the request's i18n instance or error context", () => {
|
|
171
|
+
harness = build(bearer("token-1"), { t: (key: string) => `t:${key}` });
|
|
172
|
+
return run(rejects(), rejects()).then(() => {
|
|
173
|
+
const body = harness.json.mock.calls[0]?.[0];
|
|
174
|
+
expect(JSON.stringify(body)).not.toContain("context");
|
|
175
|
+
expect(body).toEqual({
|
|
176
|
+
error: {
|
|
177
|
+
code: 401,
|
|
178
|
+
key: "API.UNAUTHORIZED",
|
|
179
|
+
message: "t:API.UNAUTHORIZED",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("the 401 body for a thrown error", () => {
|
|
187
|
+
test("does not echo what a verifier threw", async () => {
|
|
188
|
+
const leaky = pairThat(() => {
|
|
189
|
+
throw new Error("mongodb://admin:hunter2@10.0.0.5/provider");
|
|
190
|
+
});
|
|
191
|
+
await run(leaky, undefined);
|
|
192
|
+
const body = harness.json.mock.calls[0]?.[0];
|
|
193
|
+
expect(JSON.stringify(body)).not.toContain("hunter2");
|
|
194
|
+
expect(body).toEqual({
|
|
195
|
+
error: {
|
|
196
|
+
code: 401,
|
|
197
|
+
key: "API.UNAUTHORIZED",
|
|
198
|
+
message: "API.UNAUTHORIZED",
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("reports only the translation key of a Prosopo error", async () => {
|
|
204
|
+
harness = build({});
|
|
205
|
+
await run(accepts(), undefined);
|
|
206
|
+
const body = harness.json.mock.calls[0]?.[0];
|
|
207
|
+
expect(JSON.stringify(body)).not.toContain("Missing Authorization header");
|
|
208
|
+
expect(body).toEqual({
|
|
209
|
+
error: {
|
|
210
|
+
code: 401,
|
|
211
|
+
key: "GENERAL.MISSING_AUTH_HEADER",
|
|
212
|
+
message: "GENERAL.MISSING_AUTH_HEADER",
|
|
213
|
+
},
|
|
214
|
+
});
|
|
150
215
|
});
|
|
151
216
|
});
|
|
152
217
|
|
|
@@ -209,7 +274,7 @@ describe("header handling quirks worth pinning down", () => {
|
|
|
209
274
|
harness = build({ authorization: "token-1" });
|
|
210
275
|
const pair = accepts();
|
|
211
276
|
await run(pair, undefined);
|
|
212
|
-
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1");
|
|
277
|
+
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", undefined);
|
|
213
278
|
expect(harness.next.calls).toHaveLength(1);
|
|
214
279
|
});
|
|
215
280
|
|
|
@@ -220,7 +285,7 @@ describe("header handling quirks worth pinning down", () => {
|
|
|
220
285
|
harness = build({ Authorization: "Bearer token-1" } as IncomingHttpHeaders);
|
|
221
286
|
const pair = accepts();
|
|
222
287
|
await run(pair, undefined);
|
|
223
|
-
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1");
|
|
288
|
+
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", undefined);
|
|
224
289
|
expect(harness.next.calls).toHaveLength(1);
|
|
225
290
|
});
|
|
226
291
|
|
|
@@ -230,7 +295,7 @@ describe("header handling quirks worth pinning down", () => {
|
|
|
230
295
|
harness = build({ authorization: "abcBearer xyz" });
|
|
231
296
|
const pair = accepts();
|
|
232
297
|
await run(pair, undefined);
|
|
233
|
-
expect(pair.jwtVerify).toHaveBeenCalledWith("abcxyz");
|
|
298
|
+
expect(pair.jwtVerify).toHaveBeenCalledWith("abcxyz", undefined);
|
|
234
299
|
});
|
|
235
300
|
});
|
|
236
301
|
|
|
@@ -340,3 +405,45 @@ describe("verifySignature", () => {
|
|
|
340
405
|
);
|
|
341
406
|
});
|
|
342
407
|
});
|
|
408
|
+
|
|
409
|
+
describe("admin token claim options", () => {
|
|
410
|
+
const payload = (jti?: string): JWTVerifyResult["payload"] => ({
|
|
411
|
+
sub: "0x01",
|
|
412
|
+
iat: 1,
|
|
413
|
+
exp: Date.now() / 1000 + 300,
|
|
414
|
+
...(jti ? { jti } : {}),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("passes the verify options to the key", async () => {
|
|
418
|
+
const pair = accepts();
|
|
419
|
+
const verify: JWTVerifyOptions = { audience: ["https://p1.example"] };
|
|
420
|
+
await run(pair, undefined, { verify });
|
|
421
|
+
expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", verify);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("a token with a jti is accepted once and refused on replay", async () => {
|
|
425
|
+
const pair = pairThat(() => ({ isValid: true, payload: payload("n-1") }));
|
|
426
|
+
const options: AuthMiddlewareOptions = {
|
|
427
|
+
replayGuard: createMemoryJwtReplayGuard(),
|
|
428
|
+
};
|
|
429
|
+
await run(pair, undefined, options);
|
|
430
|
+
expect(harness.next.calls).toHaveLength(1);
|
|
431
|
+
|
|
432
|
+
harness = build(bearer("token-1"));
|
|
433
|
+
await run(pair, undefined, options);
|
|
434
|
+
expect(harness.next.calls).toHaveLength(0);
|
|
435
|
+
expect(harness.status).toHaveBeenCalledWith(401);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("a token without a jti can still be reused", async () => {
|
|
439
|
+
// Existing issuers reuse one token across a batch of requests.
|
|
440
|
+
const pair = pairThat(() => ({ isValid: true, payload: payload() }));
|
|
441
|
+
const options: AuthMiddlewareOptions = {
|
|
442
|
+
replayGuard: createMemoryJwtReplayGuard(),
|
|
443
|
+
};
|
|
444
|
+
await run(pair, undefined, options);
|
|
445
|
+
harness = build(bearer("token-1"));
|
|
446
|
+
await run(pair, undefined, options);
|
|
447
|
+
expect(harness.next.calls).toHaveLength(1);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "vitest";
|
|
16
|
+
import { createMemoryJwtReplayGuard } from "../../../middlewares/jwtReplayGuard.js";
|
|
17
|
+
|
|
18
|
+
describe("createMemoryJwtReplayGuard", () => {
|
|
19
|
+
test("accepts an id once and refuses it until it expires", () => {
|
|
20
|
+
let now = 1000;
|
|
21
|
+
const guard = createMemoryJwtReplayGuard(10, () => now);
|
|
22
|
+
expect(guard.claim("a", 1300)).toBe(true);
|
|
23
|
+
expect(guard.claim("a", 1300)).toBe(false);
|
|
24
|
+
now = 1301;
|
|
25
|
+
expect(guard.claim("a", 1600)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("tracks ids independently", () => {
|
|
29
|
+
const guard = createMemoryJwtReplayGuard(10, () => 0);
|
|
30
|
+
expect(guard.claim("a", 100)).toBe(true);
|
|
31
|
+
expect(guard.claim("b", 100)).toBe(true);
|
|
32
|
+
expect(guard.claim("b", 100)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("drops expired entries before evicting live ones when full", () => {
|
|
36
|
+
let now = 0;
|
|
37
|
+
const guard = createMemoryJwtReplayGuard(2, () => now);
|
|
38
|
+
guard.claim("short", 10);
|
|
39
|
+
guard.claim("long", 1000);
|
|
40
|
+
now = 20;
|
|
41
|
+
expect(guard.claim("new", 1000)).toBe(true);
|
|
42
|
+
expect(guard.claim("long", 1000)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("evicts the oldest live entry rather than growing past the cap", () => {
|
|
46
|
+
const guard = createMemoryJwtReplayGuard(2, () => 0);
|
|
47
|
+
guard.claim("a", 100);
|
|
48
|
+
guard.claim("b", 100);
|
|
49
|
+
guard.claim("c", 100);
|
|
50
|
+
expect(guard.claim("a", 100)).toBe(true);
|
|
51
|
+
expect(guard.claim("c", 100)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|