@prosopo/api-express-router 3.2.0 → 3.2.2

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.
@@ -1,449 +0,0 @@
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 type { IncomingHttpHeaders } from "node:http";
16
- import { ProsopoApiError } from "@prosopo/common";
17
- import type { Logger } from "@prosopo/logger";
18
- import type { ApiJsonError, KeyringPair } from "@prosopo/types";
19
- import type {
20
- JWT,
21
- JWTVerifyOptions,
22
- JWTVerifyResult,
23
- } from "@prosopo/util-crypto";
24
- import type { Request, Response } from "express";
25
- import { beforeEach, describe, expect, test, vi } from "vitest";
26
- import {
27
- type AuthMiddlewareOptions,
28
- authMiddleware,
29
- verifySignature,
30
- } from "../../../middlewares/authMiddleware.js";
31
- import { createMemoryJwtReplayGuard } from "../../../middlewares/jwtReplayGuard.js";
32
- import { type NextCapture, captureNext } from "../testDoubles.js";
33
-
34
- /**
35
- * Everything this middleware protects is behind a single decision: call next()
36
- * or answer 401. It must never fall through to the handler when a token is
37
- * absent, malformed, or verified by neither key — including when verification
38
- * itself throws.
39
- */
40
-
41
- type Verify = (
42
- jwt: JWT,
43
- options?: JWTVerifyOptions,
44
- ) => Pick<JWTVerifyResult, "isValid" | "payload">;
45
-
46
- /** A pair that only implements the parts the middleware actually calls. */
47
- const pairThat = (verify: Verify): KeyringPair =>
48
- ({
49
- jwtVerify: vi.fn<Verify>(verify),
50
- address: "5Test",
51
- publicKey: new Uint8Array([1, 2, 3]),
52
- verify: vi.fn<
53
- (message: string, signature: Uint8Array, key: Uint8Array) => boolean
54
- >(() => true),
55
- }) as unknown as KeyringPair;
56
-
57
- const accepts = (): KeyringPair => pairThat(() => ({ isValid: true }));
58
- const rejects = (): KeyringPair => pairThat(() => ({ isValid: false }));
59
- const explodes = (): KeyringPair =>
60
- pairThat(() => {
61
- throw new Error("verifier unavailable");
62
- });
63
-
64
- interface Harness {
65
- req: Request;
66
- res: Response;
67
- next: NextCapture;
68
- status: ReturnType<typeof vi.fn<(code: number) => Response>>;
69
- json: ReturnType<typeof vi.fn<(body: unknown) => Response>>;
70
- logged: ReturnType<typeof vi.fn<(entry: () => unknown) => void>>;
71
- }
72
-
73
- const build = (
74
- headers: IncomingHttpHeaders = {},
75
- i18n?: { t: (key: string) => string },
76
- ): Harness => {
77
- const json = vi.fn<(body: unknown) => Response>();
78
- const status = vi.fn<(code: number) => Response>();
79
- const res = { status, json } as unknown as Response;
80
- status.mockReturnValue(res);
81
-
82
- const logged = vi.fn<(entry: () => unknown) => void>();
83
- const req = {
84
- headers,
85
- i18n,
86
- logger: { error: logged } as unknown as Logger,
87
- } as unknown as Request;
88
-
89
- return { req, res, next: captureNext(), status, json, logged };
90
- };
91
-
92
- const bearer = (token: string): IncomingHttpHeaders => ({
93
- authorization: `Bearer ${token}`,
94
- });
95
-
96
- let harness: Harness;
97
-
98
- beforeEach(() => {
99
- harness = build(bearer("token-1"));
100
- });
101
-
102
- const run = async (
103
- pair: KeyringPair | undefined,
104
- authAccount?: KeyringPair | undefined,
105
- options?: AuthMiddlewareOptions,
106
- ): Promise<void> => {
107
- await authMiddleware(pair, authAccount, options)(
108
- harness.req,
109
- harness.res,
110
- harness.next.fn,
111
- );
112
- };
113
-
114
- describe("a token one of the keys accepts", () => {
115
- test("passes the request through on the auth account", async () => {
116
- await run(rejects(), accepts());
117
- expect(harness.next.calls).toHaveLength(1);
118
- expect(harness.status).not.toHaveBeenCalled();
119
- });
120
-
121
- test("passes the request through on the provider pair", async () => {
122
- await run(accepts(), rejects());
123
- expect(harness.next.calls).toHaveLength(1);
124
- });
125
-
126
- test("does not consult the provider pair once the auth account accepts", async () => {
127
- // Short-circuiting matters: the second verify is a signature check and
128
- // there is no reason to pay for it twice.
129
- const pair = accepts();
130
- await run(pair, accepts());
131
- expect(pair.jwtVerify).not.toHaveBeenCalled();
132
- });
133
-
134
- test("works when only the provider pair is configured", async () => {
135
- await run(accepts(), undefined);
136
- expect(harness.next.calls).toHaveLength(1);
137
- });
138
-
139
- test("works when only the auth account is configured", async () => {
140
- await run(undefined, accepts());
141
- expect(harness.next.calls).toHaveLength(1);
142
- });
143
-
144
- test("hands the verifier the token without its Bearer prefix", async () => {
145
- const pair = accepts();
146
- await run(pair, undefined);
147
- expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", undefined);
148
- });
149
- });
150
-
151
- describe("a token neither key accepts", () => {
152
- beforeEach(async () => {
153
- await run(rejects(), rejects());
154
- });
155
-
156
- test("is answered 401 and never reaches the handler", () => {
157
- expect(harness.status).toHaveBeenCalledWith(401);
158
- expect(harness.next.calls).toHaveLength(0);
159
- });
160
-
161
- test("is answered with an error body carrying the 401 code", () => {
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
- });
215
- });
216
- });
217
-
218
- describe("a request with no usable credentials", () => {
219
- test("no configured keys at all is rejected, not allowed through", async () => {
220
- // A misconfigured provider must fail closed.
221
- await run(undefined, undefined);
222
- expect(harness.status).toHaveBeenCalledWith(401);
223
- expect(harness.next.calls).toHaveLength(0);
224
- });
225
-
226
- test("a missing Authorization header is rejected", async () => {
227
- harness = build({});
228
- await run(accepts(), accepts());
229
- expect(harness.status).toHaveBeenCalledWith(401);
230
- expect(harness.next.calls).toHaveLength(0);
231
- });
232
-
233
- test("an Authorization header that is not a string is rejected", async () => {
234
- // Node hands back an array for a repeated header; verifying that would
235
- // throw deeper in, so it is refused here.
236
- harness = build({
237
- authorization: ["Bearer a", "Bearer b"] as unknown as string,
238
- });
239
- await run(accepts(), accepts());
240
- expect(harness.status).toHaveBeenCalledWith(401);
241
- });
242
-
243
- test("an empty Authorization header is rejected", async () => {
244
- harness = build({ authorization: "" });
245
- await run(accepts(), accepts());
246
- expect(harness.status).toHaveBeenCalledWith(401);
247
- });
248
-
249
- test("a Bearer prefix with no token after it is rejected", async () => {
250
- harness = build({ authorization: "Bearer " });
251
- await run(accepts(), accepts());
252
- expect(harness.status).toHaveBeenCalledWith(401);
253
- expect(harness.next.calls).toHaveLength(0);
254
- });
255
-
256
- test("the verifier is never reached for a malformed header", async () => {
257
- harness = build({});
258
- const pair = accepts();
259
- await run(pair, undefined);
260
- expect(pair.jwtVerify).not.toHaveBeenCalled();
261
- });
262
-
263
- test("a header rejection is logged", async () => {
264
- harness = build({});
265
- await run(accepts(), undefined);
266
- expect(harness.logged).toHaveBeenCalledTimes(1);
267
- });
268
- });
269
-
270
- describe("header handling quirks worth pinning down", () => {
271
- test("a bare token with no Bearer prefix is accepted as the token", async () => {
272
- // The prefix is stripped rather than required, so a client that omits it
273
- // still authenticates. Recorded so a change here is deliberate.
274
- harness = build({ authorization: "token-1" });
275
- const pair = accepts();
276
- await run(pair, undefined);
277
- expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", undefined);
278
- expect(harness.next.calls).toHaveLength(1);
279
- });
280
-
281
- test("a capitalised Authorization key is read as well as the lowercase one", async () => {
282
- // Node normalises real inbound header names to lowercase, so only the
283
- // second lookup fires in production — but both are honoured, which is
284
- // what lets a hand-built request object work in a test or a shim.
285
- harness = build({ Authorization: "Bearer token-1" } as IncomingHttpHeaders);
286
- const pair = accepts();
287
- await run(pair, undefined);
288
- expect(pair.jwtVerify).toHaveBeenCalledWith("token-1", undefined);
289
- expect(harness.next.calls).toHaveLength(1);
290
- });
291
-
292
- test("a Bearer prefix appearing inside the token is also stripped", async () => {
293
- // replace() is unanchored, so it removes the first occurrence wherever it
294
- // sits rather than only at the front.
295
- harness = build({ authorization: "abcBearer xyz" });
296
- const pair = accepts();
297
- await run(pair, undefined);
298
- expect(pair.jwtVerify).toHaveBeenCalledWith("abcxyz", undefined);
299
- });
300
- });
301
-
302
- describe("a verifier that throws", () => {
303
- test("is answered 401 rather than crashing the request", async () => {
304
- // A key backend being unavailable must not turn into a 500 or an
305
- // unhandled rejection.
306
- await run(explodes(), undefined);
307
- expect(harness.status).toHaveBeenCalledWith(401);
308
- expect(harness.next.calls).toHaveLength(0);
309
- });
310
-
311
- test("is logged against the request logger", async () => {
312
- await run(explodes(), undefined);
313
- expect(harness.logged).toHaveBeenCalledTimes(1);
314
- const entry = harness.logged.mock.calls[0]?.[0] as () => {
315
- msg: string;
316
- err: unknown;
317
- };
318
- expect(entry().msg).toBe("Auth Middleware Error");
319
- });
320
-
321
- test("a throwing auth account does not fall through to the provider pair", async () => {
322
- // The throw escapes the whole chain, so a working second key cannot
323
- // rescue the request.
324
- const pair = accepts();
325
- await run(pair, explodes());
326
- expect(pair.jwtVerify).not.toHaveBeenCalled();
327
- expect(harness.next.calls).toHaveLength(0);
328
- });
329
-
330
- test("does not resolve before it has answered", async () => {
331
- const middleware = authMiddleware(explodes(), undefined);
332
- await middleware(harness.req, harness.res, harness.next.fn);
333
- expect(harness.json).toHaveBeenCalledTimes(1);
334
- });
335
- });
336
-
337
- describe("verifySignature", () => {
338
- const message = "sign me";
339
- const signature = "0x0102";
340
-
341
- test("returns quietly when the pair verifies the signature", () => {
342
- const pair = pairThat(() => ({ isValid: true }));
343
- expect(() => verifySignature(signature, message, pair)).not.toThrow();
344
- });
345
-
346
- test("passes the decoded signature bytes to the pair", () => {
347
- const pair = pairThat(() => ({ isValid: true }));
348
- verifySignature(signature, message, pair);
349
- expect(pair.verify).toHaveBeenCalledWith(
350
- message,
351
- new Uint8Array([1, 2]),
352
- pair.publicKey,
353
- );
354
- });
355
-
356
- test("throws a 401 error when verification fails", () => {
357
- const pair = pairThat(() => ({ isValid: true }));
358
- vi.mocked(pair.verify).mockReturnValue(false);
359
- try {
360
- verifySignature(signature, message, pair);
361
- expect.unreachable("verifySignature should have thrown");
362
- } catch (error) {
363
- expect(error).toBeInstanceOf(ProsopoApiError);
364
- expect((error as ProsopoApiError).context?.code).toBe(401);
365
- }
366
- });
367
-
368
- test("reports which account and message failed", () => {
369
- // Without the account in the context a failure is untraceable across the
370
- // several keys a provider holds.
371
- const pair = pairThat(() => ({ isValid: true }));
372
- vi.mocked(pair.verify).mockReturnValue(false);
373
- try {
374
- verifySignature(signature, message, pair);
375
- expect.unreachable("verifySignature should have thrown");
376
- } catch (error) {
377
- const context = (error as ProsopoApiError).context;
378
- expect(context?.account).toBe("5Test");
379
- expect(context?.message).toBe(message);
380
- expect(context?.signature).toBe(signature);
381
- }
382
- });
383
-
384
- test("a non-hex signature is decoded to garbage rather than rejected", () => {
385
- // hexToU8a is lenient: it silently produces bytes for input that is not
386
- // hex at all. Nothing here validates the shape, so a malformed signature
387
- // reaches the pair and fails there instead of being refused up front.
388
- const pair = pairThat(() => ({ isValid: true }));
389
- vi.mocked(pair.verify).mockReturnValue(false);
390
- expect(() => verifySignature("not hex", message, pair)).toThrow(
391
- ProsopoApiError,
392
- );
393
- expect(pair.verify).toHaveBeenCalledTimes(1);
394
- });
395
-
396
- test("an empty message is still passed through to the pair", () => {
397
- // Nothing guards against it, so an empty message verifies or fails purely
398
- // on the pair's answer rather than being rejected up front.
399
- const pair = pairThat(() => ({ isValid: true }));
400
- verifySignature(signature, "", pair);
401
- expect(pair.verify).toHaveBeenCalledWith(
402
- "",
403
- new Uint8Array([1, 2]),
404
- pair.publicKey,
405
- );
406
- });
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
- });
@@ -1,53 +0,0 @@
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
- });