@shipfox/api-auth 10.1.0 → 11.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 (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +30 -0
  3. package/README.md +42 -11
  4. package/dist/core/administration.d.ts +17 -1
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +107 -14
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +27 -10
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/jwt.d.ts +10 -0
  12. package/dist/core/jwt.d.ts.map +1 -1
  13. package/dist/core/jwt.js +3 -2
  14. package/dist/core/jwt.js.map +1 -1
  15. package/dist/db/admin-command.d.ts +18 -0
  16. package/dist/db/admin-command.d.ts.map +1 -0
  17. package/dist/db/admin-command.js +37 -0
  18. package/dist/db/admin-command.js.map +1 -0
  19. package/dist/db/admin-grants.d.ts +1 -1
  20. package/dist/db/admin-grants.d.ts.map +1 -1
  21. package/dist/db/admin-grants.js +12 -30
  22. package/dist/db/admin-grants.js.map +1 -1
  23. package/dist/db/admin-user-moderation.d.ts +29 -0
  24. package/dist/db/admin-user-moderation.d.ts.map +1 -0
  25. package/dist/db/admin-user-moderation.js +156 -0
  26. package/dist/db/admin-user-moderation.js.map +1 -0
  27. package/dist/db/admin-user-summary.d.ts +14 -0
  28. package/dist/db/admin-user-summary.d.ts.map +1 -0
  29. package/dist/db/admin-user-summary.js +31 -0
  30. package/dist/db/admin-user-summary.js.map +1 -0
  31. package/dist/db/admin-users.d.ts +2 -11
  32. package/dist/db/admin-users.d.ts.map +1 -1
  33. package/dist/db/admin-users.js +2 -27
  34. package/dist/db/admin-users.js.map +1 -1
  35. package/dist/db/refresh-tokens.d.ts +6 -4
  36. package/dist/db/refresh-tokens.d.ts.map +1 -1
  37. package/dist/db/refresh-tokens.js +31 -6
  38. package/dist/db/refresh-tokens.js.map +1 -1
  39. package/dist/db/schema/admin-command-results.d.ts +19 -2
  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 +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  47. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  48. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  49. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  50. package/dist/presentation/auth/jwt-auth.d.ts +1 -4
  51. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  52. package/dist/presentation/auth/jwt-auth.js +23 -19
  53. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  54. package/dist/presentation/routes/administration.d.ts.map +1 -1
  55. package/dist/presentation/routes/administration.js +94 -3
  56. package/dist/presentation/routes/administration.js.map +1 -1
  57. package/dist/tsconfig.test.tsbuildinfo +1 -1
  58. package/package.json +4 -4
  59. package/src/core/administration.ts +137 -15
  60. package/src/core/auth.test.ts +29 -10
  61. package/src/core/auth.ts +36 -14
  62. package/src/core/jwt.test.ts +20 -4
  63. package/src/core/jwt.ts +2 -1
  64. package/src/db/admin-command.ts +85 -0
  65. package/src/db/admin-grants.ts +19 -57
  66. package/src/db/admin-user-moderation.ts +230 -0
  67. package/src/db/admin-user-summary.ts +51 -0
  68. package/src/db/admin-users.ts +4 -49
  69. package/src/db/refresh-tokens.test.ts +0 -19
  70. package/src/db/refresh-tokens.ts +54 -23
  71. package/src/db/schema/admin-command-results.ts +23 -2
  72. package/src/index.ts +3 -0
  73. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  74. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  75. package/src/presentation/auth/jwt-auth.test.ts +34 -9
  76. package/src/presentation/auth/jwt-auth.ts +19 -13
  77. package/src/presentation/routes/administration.test.ts +253 -2
  78. package/src/presentation/routes/administration.ts +88 -1
  79. package/src/presentation/routes/password/change-password.test.ts +6 -0
  80. package/src/presentation/routes/password/password-reset-confirm.test.ts +14 -0
  81. package/src/presentation/routes/registration/signup.test.ts +2 -2
  82. package/src/presentation/routes/session/logout.test.ts +6 -0
  83. package/src/presentation/routes/session/refresh.test.ts +11 -0
  84. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "10.1.0",
4
+ "version": "11.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,10 +27,10 @@
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
29
  "@shipfox/api-common-dto": "9.2.0",
30
- "@shipfox/api-auth-context": "10.1.0",
31
- "@shipfox/api-auth-dto": "10.1.0",
30
+ "@shipfox/api-auth-context": "11.0.0",
31
+ "@shipfox/api-auth-dto": "10.2.0",
32
32
  "@shipfox/api-email-challenges": "1.1.6",
33
- "@shipfox/api-workspaces-dto": "10.0.0",
33
+ "@shipfox/api-workspaces-dto": "11.0.0",
34
34
  "@shipfox/inter-module": "0.2.2",
35
35
  "@shipfox/config": "1.2.4",
36
36
  "@shipfox/node-drizzle": "0.3.4",
@@ -1,7 +1,8 @@
1
- import {createHash, timingSafeEqual} from 'node:crypto';
1
+ import {timingSafeEqual} from 'node:crypto';
2
2
  import type {AdminRole} from '@shipfox/api-auth-dto';
3
3
  import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
4
4
  import type {TimestampIdCursor} from '@shipfox/node-drizzle';
5
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
5
6
  import {config} from '#config.js';
6
7
  import {
7
8
  bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
@@ -10,6 +11,12 @@ import {
10
11
  listAdminGrantSummaries,
11
12
  revokeAdminGrantWithAudit,
12
13
  } from '#db/admin-grants.js';
14
+ import {
15
+ reactivateUserWithAudit,
16
+ revokeUserSessionsWithAudit,
17
+ suspendUserWithAudit,
18
+ type UserModerationResult,
19
+ } from '#db/admin-user-moderation.js';
13
20
  import {findAdministratorUser as findAdministratorUserInDb} from '#db/admin-users.js';
14
21
  import {requireAdminRole} from './admin-role.js';
15
22
  import type {AdminGrant} from './entities/admin-grant.js';
@@ -29,22 +36,22 @@ import {
29
36
 
30
37
  const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
31
38
  const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
39
+ const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
32
40
  const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
33
41
  const GRANT_COMMAND = 'auth.admin_grant.grant';
34
42
  const REVOKE_COMMAND = 'auth.admin_grant.revoke';
35
-
36
- export function hashAdministrationValue(value: string): string {
37
- return createHash('sha256').update(value).digest('hex');
38
- }
43
+ const SUSPEND_USER_COMMAND = 'auth.user.suspend';
44
+ const REACTIVATE_USER_COMMAND = 'auth.user.reactivate';
45
+ const REVOKE_USER_SESSIONS_COMMAND = 'auth.user.revoke-sessions';
39
46
 
40
47
  export function administrationCommandFingerprint(command: string, input: unknown): string {
41
- return hashAdministrationValue(`${command}:${JSON.stringify(input)}`);
48
+ return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
42
49
  }
43
50
 
44
51
  function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
45
52
  if (!expected) return false;
46
- const candidateHash = Buffer.from(hashAdministrationValue(candidate), 'hex');
47
- const expectedHash = Buffer.from(hashAdministrationValue(expected), 'hex');
53
+ const candidateHash = Buffer.from(hashOpaqueToken(candidate), 'hex');
54
+ const expectedHash = Buffer.from(hashOpaqueToken(expected), 'hex');
48
55
  return timingSafeEqual(candidateHash, expectedHash);
49
56
  }
50
57
 
@@ -55,13 +62,14 @@ function administrationEvent(params: {
55
62
  targetType: string;
56
63
  targetId: string;
57
64
  reason: string;
65
+ requiredRole?: AdminRole;
58
66
  correlationId: string;
59
67
  idempotencyKeyFingerprint: string;
60
68
  }) {
61
69
  return createAdministrationActionEvent({
62
70
  actorId: params.actorId,
63
71
  actorRole: params.actorRole,
64
- requiredRole: ADMIN_OWNER_ROLE,
72
+ requiredRole: params.requiredRole ?? ADMIN_OWNER_ROLE,
65
73
  command: params.command,
66
74
  targetType: params.targetType,
67
75
  targetId: params.targetId,
@@ -79,6 +87,22 @@ export interface AdministrationMutationContext {
79
87
  correlationId: string;
80
88
  }
81
89
 
90
+ export interface AdministratorUserMutationResult {
91
+ user: AdministratorUserSummary;
92
+ correlationId: string;
93
+ sessionsRevoked: number;
94
+ }
95
+
96
+ function toAdministratorUserMutationResult(
97
+ result: UserModerationResult,
98
+ ): AdministratorUserMutationResult {
99
+ return {
100
+ user: result.user,
101
+ correlationId: result.correlationId,
102
+ sessionsRevoked: result.sessionsRevoked,
103
+ };
104
+ }
105
+
82
106
  export async function bootstrapFirstAdminOwner(
83
107
  params: AdministrationMutationContext & {bootstrapToken: string},
84
108
  ): Promise<AdminGrant> {
@@ -89,7 +113,7 @@ export async function bootstrapFirstAdminOwner(
89
113
  return await bootstrapFirstAdminOwnerInDb({
90
114
  userId: params.actorId,
91
115
  actorId: params.actorId,
92
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
116
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
93
117
  requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
94
118
  actorId: params.actorId,
95
119
  }),
@@ -101,7 +125,7 @@ export async function bootstrapFirstAdminOwner(
101
125
  targetId: params.actorId,
102
126
  reason: 'Initial administrator owner bootstrap',
103
127
  correlationId: params.correlationId,
104
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
128
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
105
129
  }),
106
130
  });
107
131
  }
@@ -160,7 +184,7 @@ export async function grantAdministratorRole(
160
184
  userId: params.userId,
161
185
  role: params.role,
162
186
  actorId: params.actorId,
163
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
187
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
164
188
  requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
165
189
  userId: params.userId,
166
190
  role: params.role,
@@ -174,7 +198,7 @@ export async function grantAdministratorRole(
174
198
  targetId: params.userId,
175
199
  reason: params.reason,
176
200
  correlationId: params.correlationId,
177
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
201
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
178
202
  }),
179
203
  });
180
204
  }
@@ -189,7 +213,7 @@ export async function revokeAdministratorGrant(
189
213
  return await revokeAdminGrantWithAudit({
190
214
  grantId: params.grantId,
191
215
  actorId: params.actorId,
192
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
216
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
193
217
  requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
194
218
  grantId: params.grantId,
195
219
  reason: params.reason,
@@ -202,11 +226,109 @@ export async function revokeAdministratorGrant(
202
226
  targetId: params.grantId,
203
227
  reason: params.reason,
204
228
  correlationId: params.correlationId,
205
- idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
229
+ idempotencyKeyFingerprint: hashOpaqueToken(params.idempotencyKey),
206
230
  }),
207
231
  });
208
232
  }
209
233
 
234
+ export async function suspendAdministratorUser(
235
+ params: AdministrationMutationContext & {userId: string; reason: string},
236
+ ): Promise<AdministratorUserMutationResult> {
237
+ const actorRole = await requireAdminRole({
238
+ userId: params.actorId,
239
+ minimumRole: ADMIN_OPERATOR_ROLE,
240
+ });
241
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
242
+ return toAdministratorUserMutationResult(
243
+ await suspendUserWithAudit({
244
+ actorId: params.actorId,
245
+ userId: params.userId,
246
+ idempotencyKeyFingerprint,
247
+ requestFingerprint: administrationCommandFingerprint(SUSPEND_USER_COMMAND, {
248
+ userId: params.userId,
249
+ reason: params.reason,
250
+ }),
251
+ event: administrationEvent({
252
+ actorId: params.actorId,
253
+ actorRole,
254
+ requiredRole: ADMIN_OPERATOR_ROLE,
255
+ command: SUSPEND_USER_COMMAND,
256
+ targetType: 'user',
257
+ targetId: params.userId,
258
+ reason: params.reason,
259
+ correlationId: params.correlationId,
260
+ idempotencyKeyFingerprint,
261
+ }),
262
+ }),
263
+ );
264
+ }
265
+
266
+ export async function reactivateAdministratorUser(
267
+ params: AdministrationMutationContext & {userId: string; reason?: string},
268
+ ): Promise<AdministratorUserMutationResult> {
269
+ const actorRole = await requireAdminRole({
270
+ userId: params.actorId,
271
+ minimumRole: ADMIN_OPERATOR_ROLE,
272
+ });
273
+ const reason = params.reason ?? 'User reactivation requested by an administrator';
274
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
275
+ return toAdministratorUserMutationResult(
276
+ await reactivateUserWithAudit({
277
+ actorId: params.actorId,
278
+ userId: params.userId,
279
+ idempotencyKeyFingerprint,
280
+ requestFingerprint: administrationCommandFingerprint(REACTIVATE_USER_COMMAND, {
281
+ userId: params.userId,
282
+ reason,
283
+ }),
284
+ event: administrationEvent({
285
+ actorId: params.actorId,
286
+ actorRole,
287
+ requiredRole: ADMIN_OPERATOR_ROLE,
288
+ command: REACTIVATE_USER_COMMAND,
289
+ targetType: 'user',
290
+ targetId: params.userId,
291
+ reason,
292
+ correlationId: params.correlationId,
293
+ idempotencyKeyFingerprint,
294
+ }),
295
+ }),
296
+ );
297
+ }
298
+
299
+ export async function revokeAdministratorUserSessions(
300
+ params: AdministrationMutationContext & {userId: string; reason?: string},
301
+ ): Promise<AdministratorUserMutationResult> {
302
+ const actorRole = await requireAdminRole({
303
+ userId: params.actorId,
304
+ minimumRole: ADMIN_OPERATOR_ROLE,
305
+ });
306
+ const reason = params.reason ?? 'All active user sessions revoked by an administrator';
307
+ const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
308
+ return toAdministratorUserMutationResult(
309
+ await revokeUserSessionsWithAudit({
310
+ actorId: params.actorId,
311
+ userId: params.userId,
312
+ idempotencyKeyFingerprint,
313
+ requestFingerprint: administrationCommandFingerprint(REVOKE_USER_SESSIONS_COMMAND, {
314
+ userId: params.userId,
315
+ reason,
316
+ }),
317
+ event: administrationEvent({
318
+ actorId: params.actorId,
319
+ actorRole,
320
+ requiredRole: ADMIN_OPERATOR_ROLE,
321
+ command: REVOKE_USER_SESSIONS_COMMAND,
322
+ targetType: 'user',
323
+ targetId: params.userId,
324
+ reason,
325
+ correlationId: params.correlationId,
326
+ idempotencyKeyFingerprint,
327
+ }),
328
+ }),
329
+ );
330
+ }
331
+
210
332
  export {
211
333
  AdminBootstrapClosedError,
212
334
  AdminGrantAlreadyExistsError,
@@ -73,7 +73,13 @@ vi.mock('#config.js', () => ({
73
73
  vi.mock('@shipfox/node-mailer', () => ({mailer: testConfig.mailer}));
74
74
 
75
75
  const listMembershipsByUserMock = vi.fn(() =>
76
- Promise.resolve({memberships: [] as Array<{workspaceId: string; role: 'admin'}>}),
76
+ Promise.resolve({
77
+ memberships: [] as Array<{
78
+ workspaceId: string;
79
+ role: 'admin';
80
+ workspaceStatus: 'active' | 'suspended' | 'deleted';
81
+ }>,
82
+ }),
77
83
  );
78
84
  const workspaces = {
79
85
  listMembershipsForTokenClaims: listMembershipsByUserMock,
@@ -492,7 +498,6 @@ describe('auth core', () => {
492
498
  test('refreshAccessToken rejects a lost rotation claim when the token was revoked', async () => {
493
499
  const user = await userFactory.create({emailVerifiedAt: new Date()});
494
500
  const loginResult = await login({email: user.email, password: user.plainPassword});
495
- listMembershipsByUserMock.mockClear();
496
501
  vi.spyOn(refreshTokenDb, 'rotateRefreshToken').mockImplementationOnce(async () => {
497
502
  await logout({refreshToken: loginResult.refreshToken});
498
503
  return undefined;
@@ -501,7 +506,9 @@ describe('auth core', () => {
501
506
  const raced = refreshAccessToken({refreshToken: loginResult.refreshToken});
502
507
 
503
508
  await expect(raced).rejects.toBeInstanceOf(TokenInvalidError);
504
- expect(listMembershipsByUserMock).not.toHaveBeenCalled();
509
+ await expect(
510
+ verifyUserToken({token: loginResult.token, secret: userAccessTokenKey()}),
511
+ ).resolves.toMatchObject({sub: user.id});
505
512
  });
506
513
 
507
514
  test('refreshAccessToken rejects reuse past the grace window and revokes the session', async () => {
@@ -524,6 +531,9 @@ describe('auth core', () => {
524
531
  })
525
532
  : undefined;
526
533
  expect(successor).toBeUndefined();
534
+ await expect(
535
+ verifyUserToken({token: refreshed.token, secret: userAccessTokenKey()}),
536
+ ).resolves.toMatchObject({sub: user.id});
527
537
  });
528
538
 
529
539
  test('refreshAccessToken rejects refresh tokens for inactive users', async () => {
@@ -597,6 +607,9 @@ describe('auth core', () => {
597
607
 
598
608
  expect(currentRefresh.user.id).toBe(user.id);
599
609
  await expect(otherRefresh).rejects.toBeInstanceOf(TokenInvalidError);
610
+ await expect(
611
+ verifyUserToken({token: loginResult.token, secret: userAccessTokenKey()}),
612
+ ).resolves.toMatchObject({sub: user.id});
600
613
  });
601
614
 
602
615
  test('changePassword rejects unknown users and invalid current passwords', async () => {
@@ -696,8 +709,8 @@ describe('auth core', () => {
696
709
  const workspaceB = crypto.randomUUID();
697
710
  listMembershipsByUserMock.mockResolvedValueOnce({
698
711
  memberships: [
699
- {workspaceId: workspaceA, role: 'admin'},
700
- {workspaceId: workspaceB, role: 'admin'},
712
+ {workspaceId: workspaceA, role: 'admin', workspaceStatus: 'active'},
713
+ {workspaceId: workspaceB, role: 'admin', workspaceStatus: 'suspended'},
701
714
  ],
702
715
  });
703
716
 
@@ -706,8 +719,8 @@ describe('auth core', () => {
706
719
  const claims = await verifyUserToken({token: result.token, secret: userAccessTokenKey()});
707
720
  expect(claims.name).toBe(user.name);
708
721
  expect(claims.memberships).toEqual([
709
- {workspaceId: workspaceA, role: 'admin'},
710
- {workspaceId: workspaceB, role: 'admin'},
722
+ {workspaceId: workspaceA, role: 'admin', workspaceStatus: 'active'},
723
+ {workspaceId: workspaceB, role: 'admin', workspaceStatus: 'suspended'},
711
724
  ]);
712
725
  });
713
726
 
@@ -718,7 +731,7 @@ describe('auth core', () => {
718
731
 
719
732
  const newWorkspaceId = crypto.randomUUID();
720
733
  listMembershipsByUserMock.mockResolvedValueOnce({
721
- memberships: [{workspaceId: newWorkspaceId, role: 'admin'}],
734
+ memberships: [{workspaceId: newWorkspaceId, role: 'admin', workspaceStatus: 'active'}],
722
735
  });
723
736
  const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
724
737
 
@@ -726,7 +739,9 @@ describe('auth core', () => {
726
739
  token: refreshed.token,
727
740
  secret: userAccessTokenKey(),
728
741
  });
729
- expect(refreshedClaims.memberships).toEqual([{workspaceId: newWorkspaceId, role: 'admin'}]);
742
+ expect(refreshedClaims.memberships).toEqual([
743
+ {workspaceId: newWorkspaceId, role: 'admin', workspaceStatus: 'active'},
744
+ ]);
730
745
  });
731
746
 
732
747
  test('login fails closed when listMembershipsByUser throws', async () => {
@@ -738,7 +753,7 @@ describe('auth core', () => {
738
753
  await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
739
754
  });
740
755
 
741
- test('refresh fails closed when listMembershipsByUser throws', async () => {
756
+ test('does not rotate a refresh token when listMembershipsByUser throws', async () => {
742
757
  const user = await userFactory.create({emailVerifiedAt: new Date()});
743
758
  listMembershipsByUserMock.mockResolvedValueOnce({memberships: []});
744
759
  const loginResult = await login({email: user.email, password: user.plainPassword});
@@ -747,5 +762,9 @@ describe('auth core', () => {
747
762
  const promise = refreshAccessToken({refreshToken: loginResult.refreshToken});
748
763
 
749
764
  await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
765
+
766
+ const retry = await refreshAccessToken({refreshToken: loginResult.refreshToken});
767
+ expect(retry.token).toEqual(expect.any(String));
768
+ expect(retry.refreshToken).toEqual(expect.any(String));
750
769
  });
751
770
  });
package/src/core/auth.ts CHANGED
@@ -15,7 +15,7 @@ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
15
15
  import {config} from '#config.js';
16
16
  import {consumePasswordReset, createPasswordReset} from '#db/password-resets.js';
17
17
  import {
18
- createRefreshToken,
18
+ createRefreshTokenForActiveUser,
19
19
  findActiveRefreshTokenByHash,
20
20
  findRefreshTokenByHash,
21
21
  revokeRefreshSession,
@@ -46,7 +46,7 @@ import {
46
46
  TokenInvalidError,
47
47
  UserNotFoundError,
48
48
  } from './errors.js';
49
- import {signUserToken} from './jwt.js';
49
+ import {signUserToken, type TokenMembership} from './jwt.js';
50
50
  import {hashPassword, verifyPassword} from './password.js';
51
51
  import type {SignupPolicy} from './ports.js';
52
52
  import {createEnvironmentSignupPolicy} from './signup-policy.js';
@@ -95,21 +95,30 @@ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
95
95
  recordTokenRefreshed(outcome);
96
96
  }
97
97
 
98
- async function signAccessToken(
99
- user: User,
98
+ type TokenMemberships = TokenMembership[];
99
+
100
+ async function loadTokenMemberships(
101
+ userId: string,
100
102
  workspaces: WorkspacesInterModuleClient,
101
- refreshSessionId: string,
102
- ): Promise<string> {
103
+ ): Promise<TokenMemberships> {
103
104
  const memberships = await workspaces
104
- .listMembershipsForTokenClaims({userId: user.id})
105
+ .listMembershipsForTokenClaims({userId})
105
106
  .catch((error: unknown) => {
106
107
  throw new AuthDependencyUnavailableError('workspaces', error);
107
108
  });
109
+ return memberships.memberships;
110
+ }
111
+
112
+ async function signAccessToken(
113
+ user: User,
114
+ memberships: TokenMemberships,
115
+ refreshSessionId: string,
116
+ ): Promise<string> {
108
117
  return await signUserToken({
109
118
  userId: user.id,
110
119
  email: user.email,
111
120
  name: user.name,
112
- memberships: memberships.memberships,
121
+ memberships,
113
122
  refreshSessionId,
114
123
  secret: userAccessTokenKey(),
115
124
  expiresIn: config.AUTH_JWT_EXPIRES_IN,
@@ -127,12 +136,13 @@ async function createRefreshSession(
127
136
  refreshSessionId: string,
128
137
  ): Promise<{refreshToken: string; refreshSessionId: string}> {
129
138
  const refreshToken = generateOpaqueToken('refreshToken');
130
- const session = await createRefreshToken({
139
+ const session = await createRefreshTokenForActiveUser({
131
140
  sessionId: refreshSessionId,
132
141
  userId: user.id,
133
142
  hashedToken: hashOpaqueToken(refreshToken),
134
143
  expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
135
144
  });
145
+ if (!session) throw new InvalidCredentialsError();
136
146
  return {refreshToken, refreshSessionId: session.sessionId};
137
147
  }
138
148
 
@@ -141,7 +151,8 @@ async function createSessionTokens(
141
151
  workspaces: WorkspacesInterModuleClient,
142
152
  ): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
143
153
  const refreshSessionId = crypto.randomUUID();
144
- const token = await signAccessToken(user, workspaces, refreshSessionId);
154
+ const memberships = await loadTokenMemberships(user.id, workspaces);
155
+ const token = await signAccessToken(user, memberships, refreshSessionId);
145
156
  const adminRole = await getCurrentAdminRole({userId: user.id});
146
157
  const {refreshToken} = await createRefreshSession(user, refreshSessionId);
147
158
  return {token, refreshToken, adminRole};
@@ -334,7 +345,7 @@ export async function signupWithInvitation(
334
345
  }
335
346
  }
336
347
 
337
- // Step 4: Issue session. signAccessToken reads memberships through the
348
+ // Step 4: Issue session. createSessionTokens reads memberships through the
338
349
  // workspaces module API, so a successful accept is reflected in the JWT.
339
350
  const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
340
351
 
@@ -478,7 +489,8 @@ export async function refreshAccessToken(params: {
478
489
  // second tab); past it, reuse of a retired token means a compromised session.
479
490
  if (current.rotatedAt) {
480
491
  if (isWithinRotationGrace(current)) {
481
- const token = await signAccessToken(user, params.workspaces, current.sessionId);
492
+ const memberships = await loadTokenMemberships(user.id, params.workspaces);
493
+ const token = await signAccessToken(user, memberships, current.sessionId);
482
494
  recordRefreshOutcome('grace');
483
495
  return {token, refreshToken: undefined, user, adminRole};
484
496
  }
@@ -487,27 +499,37 @@ export async function refreshAccessToken(params: {
487
499
  throw new TokenInvalidError('Refresh token reused after rotation');
488
500
  }
489
501
 
502
+ // Keep the external membership snapshot ahead of rotation so an outage
503
+ // cannot retire a refresh token that the caller can still retry.
504
+ const memberships = await loadTokenMemberships(user.id, params.workspaces);
505
+
490
506
  // Losing the CAS requires a state check: another request may have rotated,
491
507
  // revoked, or expired the token before this refresh could claim it.
492
508
  const nextRefreshToken = generateOpaqueToken('refreshToken');
493
509
  const rotated = await rotateRefreshToken({
510
+ userId: current.userId,
494
511
  id: current.id,
495
512
  currentHashedToken,
496
513
  nextHashedToken: hashOpaqueToken(nextRefreshToken),
497
514
  expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
498
515
  });
499
516
  if (!rotated) {
517
+ const latestUser = await findUserById({id: current.userId});
518
+ if (latestUser?.status !== 'active') {
519
+ recordRefreshOutcome('rejected');
520
+ throw new TokenInvalidError('Refresh token is invalid or expired');
521
+ }
500
522
  const latest = await findRefreshTokenByHash({hashedToken: currentHashedToken});
501
523
  if (!latest || !isWithinRotationGrace(latest)) {
502
524
  recordRefreshOutcome('rejected');
503
525
  throw new TokenInvalidError('Refresh token is invalid or expired');
504
526
  }
505
- const token = await signAccessToken(user, params.workspaces, latest.sessionId);
527
+ const token = await signAccessToken(user, memberships, latest.sessionId);
506
528
  recordRefreshOutcome('grace');
507
529
  return {token, refreshToken: undefined, user, adminRole};
508
530
  }
509
531
 
510
- const token = await signAccessToken(user, params.workspaces, current.sessionId);
532
+ const token = await signAccessToken(user, memberships, current.sessionId);
511
533
  recordRefreshOutcome('rotated');
512
534
  return {token, refreshToken: nextRefreshToken, user, adminRole};
513
535
  }
@@ -12,8 +12,8 @@ describe('jwt', () => {
12
12
  const userId = crypto.randomUUID();
13
13
  const email = `jwt-${crypto.randomUUID()}@example.com`;
14
14
  const memberships: TokenMembership[] = [
15
- {workspaceId: crypto.randomUUID(), role: 'admin'},
16
- {workspaceId: crypto.randomUUID(), role: 'admin'},
15
+ {workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'active'},
16
+ {workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'suspended'},
17
17
  ];
18
18
 
19
19
  const token = await signUserToken({
@@ -140,7 +140,7 @@ describe('jwt', () => {
140
140
  test('rejects a token with non-UUID workspaceId in memberships', async () => {
141
141
  const token = await new SignJWT({
142
142
  email: `jwt-${crypto.randomUUID()}@example.com`,
143
- memberships: [{workspaceId: 'not-a-uuid', role: 'admin'}],
143
+ memberships: [{workspaceId: 'not-a-uuid', role: 'admin', workspaceStatus: 'active'}],
144
144
  })
145
145
  .setProtectedHeader({alg: 'HS256'})
146
146
  .setSubject(crypto.randomUUID())
@@ -151,10 +151,26 @@ describe('jwt', () => {
151
151
  await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
152
152
  });
153
153
 
154
+ test('accepts legacy tokens with missing workspace status as active', async () => {
155
+ const token = await new SignJWT({
156
+ email: `jwt-${crypto.randomUUID()}@example.com`,
157
+ memberships: [{workspaceId: crypto.randomUUID(), role: 'admin'}],
158
+ })
159
+ .setProtectedHeader({alg: 'HS256'})
160
+ .setSubject(crypto.randomUUID())
161
+ .setIssuedAt()
162
+ .setExpirationTime('7d')
163
+ .sign(encodeSecret(SECRET));
164
+
165
+ await expect(verifyUserToken({token, secret: SECRET})).resolves.toMatchObject({
166
+ memberships: [{workspaceId: expect.any(String), role: 'admin', workspaceStatus: 'active'}],
167
+ });
168
+ });
169
+
154
170
  test('rejects a token with unknown role value', async () => {
155
171
  const token = await new SignJWT({
156
172
  email: `jwt-${crypto.randomUUID()}@example.com`,
157
- memberships: [{workspaceId: crypto.randomUUID(), role: 'owner'}],
173
+ memberships: [{workspaceId: crypto.randomUUID(), role: 'owner', workspaceStatus: 'active'}],
158
174
  })
159
175
  .setProtectedHeader({alg: 'HS256'})
160
176
  .setSubject(crypto.randomUUID())
package/src/core/jwt.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {workspaceRoleSchema} from '@shipfox/api-workspaces-dto';
1
+ import {workspaceRoleSchema, workspaceStatusSchema} from '@shipfox/api-workspaces-dto';
2
2
  import {signHs256, verifyHs256} from '@shipfox/node-jwt';
3
3
  import {z} from 'zod';
4
4
  import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
@@ -6,6 +6,7 @@ import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
6
6
  export const tokenMembershipSchema = z.object({
7
7
  workspaceId: z.string().uuid(),
8
8
  role: workspaceRoleSchema,
9
+ workspaceStatus: workspaceStatusSchema.default('active'),
9
10
  });
10
11
 
11
12
  export type TokenMembership = z.infer<typeof tokenMembershipSchema>;
@@ -0,0 +1,85 @@
1
+ import type {
2
+ AdministrationActionEvent,
3
+ AdministrationActionEventMap,
4
+ } from '@shipfox/api-common-dto';
5
+ import {writeOutboxEvent} from '@shipfox/node-outbox';
6
+ import {and, eq, sql} from 'drizzle-orm';
7
+ import {AdminIdempotencyKeyReuseError} from '#core/errors.js';
8
+ import type {db} from './db.js';
9
+ import {
10
+ type AdminCommandResultDb,
11
+ adminCommandResults,
12
+ type StoredAdminCommandResult,
13
+ } from './schema/admin-command-results.js';
14
+ import {authOutbox} from './schema/outbox.js';
15
+
16
+ export type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
17
+
18
+ export interface AdminCommandTransactionParams {
19
+ actorId: string;
20
+ idempotencyKeyFingerprint: string;
21
+ requestFingerprint: string;
22
+ event: AdministrationActionEvent;
23
+ }
24
+
25
+ export async function lockAdminCommand(
26
+ tx: Tx,
27
+ params: Pick<AdminCommandTransactionParams, 'actorId' | 'idempotencyKeyFingerprint'>,
28
+ ): Promise<void> {
29
+ await tx.execute(
30
+ sql`select pg_advisory_xact_lock(hashtext(${`auth_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
31
+ );
32
+ }
33
+
34
+ export async function lockAdminOwnerGrants(tx: Tx): Promise<void> {
35
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('auth_admin_owner_grants'))`);
36
+ }
37
+
38
+ export async function findAdminCommandResult(
39
+ tx: Tx,
40
+ params: Pick<
41
+ AdminCommandTransactionParams,
42
+ 'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
43
+ > & {command: string},
44
+ ): Promise<AdminCommandResultDb | undefined> {
45
+ const rows = await tx
46
+ .select()
47
+ .from(adminCommandResults)
48
+ .where(
49
+ and(
50
+ eq(adminCommandResults.actorId, params.actorId),
51
+ eq(adminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
52
+ ),
53
+ )
54
+ .limit(1);
55
+ const result = rows[0];
56
+ if (!result) return undefined;
57
+ if (
58
+ result.command !== params.command ||
59
+ result.requestFingerprint !== params.requestFingerprint
60
+ ) {
61
+ throw new AdminIdempotencyKeyReuseError();
62
+ }
63
+ return result;
64
+ }
65
+
66
+ export async function storeAdminCommandResult(
67
+ tx: Tx,
68
+ params: AdminCommandTransactionParams,
69
+ result: StoredAdminCommandResult,
70
+ ): Promise<void> {
71
+ await tx.insert(adminCommandResults).values({
72
+ actorId: params.actorId,
73
+ idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
74
+ command: params.event.command,
75
+ requestFingerprint: params.requestFingerprint,
76
+ result,
77
+ });
78
+ }
79
+
80
+ export async function writeAdminAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
81
+ await writeOutboxEvent<AdministrationActionEventMap>(tx, authOutbox, {
82
+ type: 'administration.action.performed',
83
+ payload: event,
84
+ });
85
+ }