@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.
Files changed (35) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +6 -5
  2. package/.turbo/turbo-build$colon$tsc.log +21 -21
  3. package/.turbo/turbo-build.log +7 -6
  4. package/CHANGELOG.md +40 -0
  5. package/dist/cjs/index.cjs +2 -0
  6. package/dist/cjs/middlewares/authMiddleware.cjs +25 -14
  7. package/dist/cjs/middlewares/jwtReplayGuard.cjs +28 -0
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/middlewares/authMiddleware.d.ts +7 -1
  13. package/dist/middlewares/authMiddleware.d.ts.map +1 -1
  14. package/dist/middlewares/authMiddleware.js +26 -15
  15. package/dist/middlewares/authMiddleware.js.map +1 -1
  16. package/dist/middlewares/jwtReplayGuard.d.ts +5 -0
  17. package/dist/middlewares/jwtReplayGuard.d.ts.map +1 -0
  18. package/dist/middlewares/jwtReplayGuard.js +28 -0
  19. package/dist/middlewares/jwtReplayGuard.js.map +1 -0
  20. package/dist/tests/apiExpressRouter.test-d.js +1 -1
  21. package/dist/tests/apiExpressRouter.test-d.js.map +1 -1
  22. package/dist/tests/unit/middlewares/authMiddleware.unit.test.js +94 -8
  23. package/dist/tests/unit/middlewares/authMiddleware.unit.test.js.map +1 -1
  24. package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.d.ts +2 -0
  25. package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.d.ts.map +1 -0
  26. package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.js +36 -0
  27. package/dist/tests/unit/middlewares/jwtReplayGuard.unit.test.js.map +1 -0
  28. package/package.json +9 -9
  29. package/src/index.ts +1 -0
  30. package/src/middlewares/authMiddleware.ts +73 -16
  31. package/src/middlewares/jwtReplayGuard.ts +56 -0
  32. package/src/tests/apiExpressRouter.test-d.ts +8 -2
  33. package/src/tests/unit/middlewares/authMiddleware.unit.test.ts +118 -11
  34. package/src/tests/unit/middlewares/jwtReplayGuard.unit.test.ts +53 -0
  35. 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 { JWT } from "@prosopo/util-crypto";
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 = (jwt: JWT) => { isValid: boolean };
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 = (headers: IncomingHttpHeaders = {}): Harness => {
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: ProsopoApiError };
149
- expect(body.error.context?.code).toBe(401);
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
+ });