@shipfox/api-auth 15.0.0 → 17.0.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 (101) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +41 -0
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +4 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/administration.d.ts +24 -2
  9. package/dist/core/administration.d.ts.map +1 -1
  10. package/dist/core/administration.js +126 -3
  11. package/dist/core/administration.js.map +1 -1
  12. package/dist/core/auth.d.ts +31 -0
  13. package/dist/core/auth.d.ts.map +1 -1
  14. package/dist/core/auth.js +70 -2
  15. package/dist/core/auth.js.map +1 -1
  16. package/dist/core/errors.d.ts +15 -0
  17. package/dist/core/errors.d.ts.map +1 -1
  18. package/dist/core/errors.js +30 -0
  19. package/dist/core/errors.js.map +1 -1
  20. package/dist/core/jwt.d.ts +2 -0
  21. package/dist/core/jwt.d.ts.map +1 -1
  22. package/dist/core/jwt.js +22 -1
  23. package/dist/core/jwt.js.map +1 -1
  24. package/dist/db/admin-command.d.ts +12 -4
  25. package/dist/db/admin-command.d.ts.map +1 -1
  26. package/dist/db/admin-command.js +10 -1
  27. package/dist/db/admin-command.js.map +1 -1
  28. package/dist/db/admin-grants.d.ts.map +1 -1
  29. package/dist/db/admin-grants.js +4 -1
  30. package/dist/db/admin-grants.js.map +1 -1
  31. package/dist/db/admin-user-moderation.d.ts +3 -0
  32. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  33. package/dist/db/admin-user-moderation.js +6 -2
  34. package/dist/db/admin-user-moderation.js.map +1 -1
  35. package/dist/db/impersonation.d.ts +55 -0
  36. package/dist/db/impersonation.d.ts.map +1 -0
  37. package/dist/db/impersonation.js +212 -0
  38. package/dist/db/impersonation.js.map +1 -0
  39. package/dist/db/schema/admin-command-results.d.ts +16 -0
  40. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  41. package/dist/db/schema/admin-command-results.js.map +1 -1
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -5
  45. package/dist/index.js.map +1 -1
  46. package/dist/metrics/index.d.ts +1 -1
  47. package/dist/metrics/index.d.ts.map +1 -1
  48. package/dist/metrics/index.js +1 -1
  49. package/dist/metrics/index.js.map +1 -1
  50. package/dist/metrics/instance.d.ts +4 -2
  51. package/dist/metrics/instance.d.ts.map +1 -1
  52. package/dist/metrics/instance.js +8 -0
  53. package/dist/metrics/instance.js.map +1 -1
  54. package/dist/presentation/auth/bearer-token-auth.d.ts +2 -2
  55. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  56. package/dist/presentation/auth/bearer-token-auth.js +2 -2
  57. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  58. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  59. package/dist/presentation/auth/jwt-auth.js +26 -2
  60. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  61. package/dist/presentation/routes/administration.d.ts +2 -1
  62. package/dist/presentation/routes/administration.d.ts.map +1 -1
  63. package/dist/presentation/routes/administration.js +127 -19
  64. package/dist/presentation/routes/administration.js.map +1 -1
  65. package/dist/presentation/routes/rate-limit.d.ts +8 -0
  66. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  67. package/dist/presentation/routes/rate-limit.js +44 -0
  68. package/dist/presentation/routes/rate-limit.js.map +1 -1
  69. package/dist/presentation/routes/session/me.d.ts.map +1 -1
  70. package/dist/presentation/routes/session/me.js +2 -1
  71. package/dist/presentation/routes/session/me.js.map +1 -1
  72. package/dist/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +5 -5
  74. package/src/config.ts +4 -0
  75. package/src/core/administration.ts +172 -1
  76. package/src/core/auth.test.ts +79 -0
  77. package/src/core/auth.ts +98 -1
  78. package/src/core/errors.ts +35 -0
  79. package/src/core/jwt.test.ts +92 -0
  80. package/src/core/jwt.ts +38 -9
  81. package/src/db/admin-command.ts +35 -6
  82. package/src/db/admin-grants.ts +5 -1
  83. package/src/db/admin-user-moderation.ts +8 -2
  84. package/src/db/impersonation.ts +323 -0
  85. package/src/db/schema/admin-command-results.ts +18 -0
  86. package/src/index.test.ts +1 -1
  87. package/src/index.ts +12 -3
  88. package/src/metrics/index.ts +2 -0
  89. package/src/metrics/instance.ts +13 -2
  90. package/src/presentation/auth/bearer-token-auth.ts +4 -4
  91. package/src/presentation/auth/jwt-auth.test.ts +191 -0
  92. package/src/presentation/auth/jwt-auth.ts +19 -1
  93. package/src/presentation/routes/administration.test.ts +828 -3
  94. package/src/presentation/routes/administration.ts +130 -11
  95. package/src/presentation/routes/rate-limit.ts +40 -0
  96. package/src/presentation/routes/session/login.test.ts +5 -0
  97. package/src/presentation/routes/session/me.test.ts +24 -0
  98. package/src/presentation/routes/session/me.ts +1 -0
  99. package/src/presentation/routes/session/refresh.test.ts +11 -0
  100. package/test/routes.ts +43 -16
  101. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import {type AuthMethod, ClientError, extractBearerToken} from '@shipfox/node-fastify';
2
- import type {FastifyRequest} from 'fastify';
2
+ import type {FastifyReply, FastifyRequest} from 'fastify';
3
3
 
4
4
  interface UnauthorizedErrorParams {
5
5
  message: string;
@@ -11,7 +11,7 @@ interface BearerTokenAuthMethodOptions<TClaims> {
11
11
  verifyToken: (token: string) => Promise<TClaims | null>;
12
12
  isInvalidTokenError?: (error: unknown) => boolean;
13
13
  invalidTokenError: UnauthorizedErrorParams;
14
- setContext: (request: FastifyRequest, claims: TClaims) => void;
14
+ setContext: (request: FastifyRequest, claims: TClaims, reply?: FastifyReply) => void;
15
15
  }
16
16
 
17
17
  const missingBearerError: UnauthorizedErrorParams = {
@@ -24,7 +24,7 @@ export function createBearerTokenAuthMethod<TClaims>(
24
24
  ): AuthMethod {
25
25
  return {
26
26
  name: options.name,
27
- authenticate: async (request) => {
27
+ authenticate: async (request, reply) => {
28
28
  const token = extractBearerToken(request.headers.authorization);
29
29
  if (!token) throwUnauthorized(missingBearerError);
30
30
 
@@ -35,7 +35,7 @@ export function createBearerTokenAuthMethod<TClaims>(
35
35
  );
36
36
  if (!claims) throwUnauthorized(options.invalidTokenError);
37
37
 
38
- options.setContext(request, claims);
38
+ options.setContext(request, claims, reply);
39
39
  },
40
40
  };
41
41
  }
@@ -1,4 +1,5 @@
1
1
  import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
2
+ import {contextWithMetadata, trace} from '@shipfox/node-opentelemetry';
2
3
  import {hashOpaqueToken} from '@shipfox/node-tokens';
3
4
  import type {FastifyInstance} from 'fastify';
4
5
  import Fastify from 'fastify';
@@ -61,6 +62,32 @@ describe('jwt-auth', () => {
61
62
  expect(res.json().client.name).toBe('JWT User');
62
63
  });
63
64
 
65
+ test('decorates request.client with impersonatorId for marked sessions', async () => {
66
+ const user = await createUser({email: emailFor('jwt-impersonator'), hashedPassword: 'h'});
67
+ const impersonatorId = crypto.randomUUID();
68
+ const token = await signUserToken({
69
+ userId: user.id,
70
+ email: user.email,
71
+ name: 'JWT User',
72
+ memberships: [],
73
+ impersonatorId,
74
+ secret: SECRET,
75
+ expiresIn: '7d',
76
+ });
77
+
78
+ const res = await app.inject({
79
+ method: 'GET',
80
+ url: '/protected',
81
+ headers: {authorization: `Bearer ${token}`},
82
+ });
83
+
84
+ expect(res.statusCode).toBe(200);
85
+ expect(res.json().client.userId).toBe(user.id);
86
+ expect(res.json().client.email).toBe(user.email);
87
+ expect(res.json().client.name).toBe('JWT User');
88
+ expect(res.json().client.impersonatorId).toBe(impersonatorId);
89
+ });
90
+
64
91
  test('401 on missing Authorization header', async () => {
65
92
  const res = await app.inject({method: 'GET', url: '/protected'});
66
93
 
@@ -261,3 +288,167 @@ describe('jwt-auth', () => {
261
288
  expect(res.statusCode).toBe(401);
262
289
  });
263
290
  });
291
+
292
+ describe('request-log context', () => {
293
+ let logApp: FastifyInstance;
294
+ const records: Array<Record<string, unknown>> = [];
295
+
296
+ beforeAll(async () => {
297
+ const stream = {
298
+ write: (chunk: string) => {
299
+ records.push(JSON.parse(chunk) as Record<string, unknown>);
300
+ },
301
+ };
302
+ logApp = Fastify({logger: {level: 'info', stream}});
303
+ logApp.setValidatorCompiler(validatorCompiler);
304
+ logApp.setSerializerCompiler(serializerCompiler);
305
+
306
+ const authMethod = createJwtAuthMethod();
307
+ logApp.addHook('onRequest', async (request, reply) => {
308
+ await authMethod.authenticate(request, reply);
309
+ });
310
+ logApp.get('/log-probe', (request) => {
311
+ request.log.info({}, 'probe line');
312
+ return {ok: true};
313
+ });
314
+ await logApp.ready();
315
+ });
316
+
317
+ afterAll(async () => {
318
+ await logApp.close();
319
+ });
320
+
321
+ beforeEach(() => {
322
+ records.length = 0;
323
+ });
324
+
325
+ test('binds impersonatorId on the request logger for marked sessions', async () => {
326
+ const user = await createUser({email: emailFor('jwt-imp-log'), hashedPassword: 'h'});
327
+ const impersonatorId = crypto.randomUUID();
328
+ const token = await signUserToken({
329
+ userId: user.id,
330
+ email: user.email,
331
+ memberships: [],
332
+ impersonatorId,
333
+ secret: SECRET,
334
+ expiresIn: '7d',
335
+ });
336
+
337
+ const res = await logApp.inject({
338
+ method: 'GET',
339
+ url: '/log-probe',
340
+ headers: {authorization: `Bearer ${token}`},
341
+ });
342
+
343
+ expect(res.statusCode).toBe(200);
344
+ const probe = [...records].reverse().find((record) => record.msg === 'probe line');
345
+ expect(probe).toBeDefined();
346
+ expect(probe?.impersonatorId).toBe(impersonatorId);
347
+ const completed = [...records].reverse().find((record) => record.msg === 'request completed');
348
+ expect(completed).toBeDefined();
349
+ expect(completed?.impersonatorId).toBe(impersonatorId);
350
+ });
351
+
352
+ test('leaves ordinary sessions unmarked in the request logger', async () => {
353
+ const user = await createUser({email: emailFor('jwt-plain-log'), hashedPassword: 'h'});
354
+ const token = await signUserToken({
355
+ userId: user.id,
356
+ email: user.email,
357
+ memberships: [],
358
+ secret: SECRET,
359
+ expiresIn: '7d',
360
+ });
361
+
362
+ const res = await logApp.inject({
363
+ method: 'GET',
364
+ url: '/log-probe',
365
+ headers: {authorization: `Bearer ${token}`},
366
+ });
367
+
368
+ expect(res.statusCode).toBe(200);
369
+ const probe = [...records].reverse().find((record) => record.msg === 'probe line');
370
+ expect(probe).toBeDefined();
371
+ expect(probe?.impersonatorId).toBeUndefined();
372
+ const completed = [...records].reverse().find((record) => record.msg === 'request completed');
373
+ expect(completed).toBeDefined();
374
+ expect(completed?.impersonatorId).toBeUndefined();
375
+ });
376
+ });
377
+
378
+ describe('span context', () => {
379
+ let spanApp: FastifyInstance;
380
+ let setAttribute: ReturnType<typeof vi.spyOn>;
381
+
382
+ beforeAll(async () => {
383
+ const span = trace.getTracer('test').startSpan('request');
384
+ setAttribute = vi.spyOn(span, 'setAttribute');
385
+ spanApp = Fastify();
386
+ spanApp.setValidatorCompiler(validatorCompiler);
387
+ spanApp.setSerializerCompiler(serializerCompiler);
388
+ spanApp.decorateRequest('opentelemetry', () => ({
389
+ enabled: true,
390
+ span,
391
+ tracer: trace.getTracer('test'),
392
+ context: contextWithMetadata({}),
393
+ inject: () => undefined,
394
+ extract: () => contextWithMetadata({}),
395
+ }));
396
+
397
+ const authMethod = createJwtAuthMethod();
398
+ spanApp.addHook('onRequest', async (request, reply) => {
399
+ await authMethod.authenticate(request, reply);
400
+ });
401
+ spanApp.get('/span-probe', () => ({ok: true}));
402
+ await spanApp.ready();
403
+ });
404
+
405
+ beforeEach(() => {
406
+ setAttribute.mockClear();
407
+ });
408
+
409
+ afterAll(async () => {
410
+ await spanApp.close();
411
+ });
412
+
413
+ test('sets impersonatorId on the request span for marked sessions', async () => {
414
+ const user = await createUser({email: emailFor('jwt-imp-span'), hashedPassword: 'h'});
415
+ const impersonatorId = crypto.randomUUID();
416
+ const token = await signUserToken({
417
+ userId: user.id,
418
+ email: user.email,
419
+ memberships: [],
420
+ impersonatorId,
421
+ secret: SECRET,
422
+ expiresIn: '7d',
423
+ });
424
+
425
+ const res = await spanApp.inject({
426
+ method: 'GET',
427
+ url: '/span-probe',
428
+ headers: {authorization: `Bearer ${token}`},
429
+ });
430
+
431
+ expect(res.statusCode).toBe(200);
432
+ expect(setAttribute).toHaveBeenCalledWith('impersonatorId', impersonatorId);
433
+ });
434
+
435
+ test('sets no impersonatorId attribute on the request span for ordinary sessions', async () => {
436
+ const user = await createUser({email: emailFor('jwt-plain-span'), hashedPassword: 'h'});
437
+ const token = await signUserToken({
438
+ userId: user.id,
439
+ email: user.email,
440
+ memberships: [],
441
+ secret: SECRET,
442
+ expiresIn: '7d',
443
+ });
444
+
445
+ const res = await spanApp.inject({
446
+ method: 'GET',
447
+ url: '/span-probe',
448
+ headers: {authorization: `Bearer ${token}`},
449
+ });
450
+
451
+ expect(res.statusCode).toBe(200);
452
+ expect(setAttribute).not.toHaveBeenCalled();
453
+ });
454
+ });
@@ -7,6 +7,7 @@ import {
7
7
  } from '@shipfox/api-auth-context';
8
8
  import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
9
9
  import {type AuthMethod, ClientError} from '@shipfox/node-fastify';
10
+ import {enrichSpanWithMetadata} from '@shipfox/node-opentelemetry';
10
11
  import type {FastifyRequest} from 'fastify';
11
12
  import type {RefreshToken} from '#core/entities/refresh-token.js';
12
13
  import type {User} from '#core/entities/user.js';
@@ -85,15 +86,32 @@ export function createJwtAuthMethod(): AuthMethod {
85
86
  },
86
87
  isInvalidTokenError: (error) => error instanceof InvalidJwtTokenError,
87
88
  invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
88
- setContext: (request, claims) => {
89
+ setContext: (request, claims, reply) => {
89
90
  const clientContext: ClientContext = buildUserContext({
90
91
  userId: claims.sub,
91
92
  email: claims.email,
92
93
  name: claims.name ?? null,
93
94
  memberships: claims.memberships,
95
+ impersonatorId: claims.impersonatorId,
94
96
  });
95
97
  setUserContext(request, clientContext);
96
98
  setAuthenticatedSessionContext(request, claims);
99
+ if (claims.impersonatorId !== undefined) {
100
+ const impersonatorId = claims.impersonatorId;
101
+ // Marked sessions must stay attributable: bind the marker on the
102
+ // request logger and on the reply logger (Fastify captured the reply's
103
+ // logger before hooks ran), and on the request span rather than the
104
+ // short-lived hook span.
105
+ const markedLogger = request.log.child({impersonatorId});
106
+ request.log = markedLogger;
107
+ if (reply) {
108
+ reply.log = markedLogger;
109
+ }
110
+ const requestSpan = request.opentelemetry?.().span;
111
+ if (requestSpan) {
112
+ enrichSpanWithMetadata({impersonatorId}, {span: requestSpan});
113
+ }
114
+ }
97
115
  },
98
116
  });
99
117
  }