@shipfox/api-auth 10.1.0 → 10.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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +15 -0
  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 +11 -2
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/db/admin-command.d.ts +18 -0
  12. package/dist/db/admin-command.d.ts.map +1 -0
  13. package/dist/db/admin-command.js +37 -0
  14. package/dist/db/admin-command.js.map +1 -0
  15. package/dist/db/admin-grants.d.ts +1 -1
  16. package/dist/db/admin-grants.d.ts.map +1 -1
  17. package/dist/db/admin-grants.js +12 -30
  18. package/dist/db/admin-grants.js.map +1 -1
  19. package/dist/db/admin-user-moderation.d.ts +29 -0
  20. package/dist/db/admin-user-moderation.d.ts.map +1 -0
  21. package/dist/db/admin-user-moderation.js +156 -0
  22. package/dist/db/admin-user-moderation.js.map +1 -0
  23. package/dist/db/admin-user-summary.d.ts +14 -0
  24. package/dist/db/admin-user-summary.d.ts.map +1 -0
  25. package/dist/db/admin-user-summary.js +31 -0
  26. package/dist/db/admin-user-summary.js.map +1 -0
  27. package/dist/db/admin-users.d.ts +2 -11
  28. package/dist/db/admin-users.d.ts.map +1 -1
  29. package/dist/db/admin-users.js +2 -27
  30. package/dist/db/admin-users.js.map +1 -1
  31. package/dist/db/refresh-tokens.d.ts +6 -0
  32. package/dist/db/refresh-tokens.d.ts.map +1 -1
  33. package/dist/db/refresh-tokens.js +31 -0
  34. package/dist/db/refresh-tokens.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +19 -2
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -1
  37. package/dist/db/schema/admin-command-results.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
  43. package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
  44. package/dist/presentation/auth/bearer-token-auth.js +4 -3
  45. package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
  46. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  47. package/dist/presentation/auth/jwt-auth.js +32 -4
  48. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  49. package/dist/presentation/routes/administration.d.ts.map +1 -1
  50. package/dist/presentation/routes/administration.js +94 -3
  51. package/dist/presentation/routes/administration.js.map +1 -1
  52. package/dist/tsconfig.test.tsbuildinfo +1 -1
  53. package/package.json +4 -4
  54. package/src/core/administration.ts +137 -15
  55. package/src/core/auth.ts +9 -2
  56. package/src/db/admin-command.ts +85 -0
  57. package/src/db/admin-grants.ts +19 -57
  58. package/src/db/admin-user-moderation.ts +230 -0
  59. package/src/db/admin-user-summary.ts +51 -0
  60. package/src/db/admin-users.ts +4 -49
  61. package/src/db/refresh-tokens.ts +54 -0
  62. package/src/db/schema/admin-command-results.ts +23 -2
  63. package/src/index.ts +3 -0
  64. package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
  65. package/src/presentation/auth/bearer-token-auth.ts +9 -2
  66. package/src/presentation/auth/jwt-auth.test.ts +1 -1
  67. package/src/presentation/auth/jwt-auth.ts +30 -1
  68. package/src/presentation/routes/administration.test.ts +245 -2
  69. package/src/presentation/routes/administration.ts +88 -1
  70. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,11 +1,20 @@
1
1
  import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
2
- import {sql} from 'drizzle-orm';
2
+ import {eq, sql} from 'drizzle-orm';
3
3
  import type {FastifyInstance} from 'fastify';
4
4
  import {type AuthRateLimitAction, hashAuthRateLimitIdentifier} from '#core/rate-limit.js';
5
5
  import {db} from '#db/db.js';
6
6
  import {authOutbox} from '#db/schema/outbox.js';
7
7
  import {authRateLimits} from '#db/schema/rate-limits.js';
8
- import {createAuthTestApp, createVerifiedSession, resetCapturedMail} from '#test/routes.js';
8
+ import {refreshTokens} from '#db/schema/refresh-tokens.js';
9
+ import {users} from '#db/schema/users.js';
10
+ import {
11
+ cookieHeader,
12
+ createAuthTestApp,
13
+ createVerifiedSession,
14
+ getSetCookie,
15
+ login,
16
+ resetCapturedMail,
17
+ } from '#test/routes.js';
9
18
 
10
19
  const BOOTSTRAP_TOKEN = 'test-bootstrap-token';
11
20
 
@@ -662,4 +671,238 @@ describe('Auth administration routes', () => {
662
671
  expect(finalOwnerRevoke.statusCode).toBe(409);
663
672
  expect(finalOwnerRevoke.json().code).toBe('last-owner');
664
673
  });
674
+
675
+ test('suspends users, invalidates sessions, and reactivates without restoring them', async () => {
676
+ const owner = await createVerifiedSession('admin-user-moderation-owner');
677
+ const target = await createVerifiedSession('admin-user-moderation-target');
678
+
679
+ await app.inject({
680
+ method: 'POST',
681
+ url: '/admin/auth/admin-grants/bootstrap',
682
+ headers: authHeaders(owner.token, 'moderation-bootstrap'),
683
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
684
+ });
685
+ await app.inject({
686
+ method: 'POST',
687
+ url: '/admin/auth/admin-grants',
688
+ headers: authHeaders(owner.token, 'moderation-grant'),
689
+ payload: {
690
+ user_id: owner.userId,
691
+ role: 'admin-operator',
692
+ reason: 'User moderation access',
693
+ },
694
+ });
695
+
696
+ const rotatedBeforeSuspension = await app.inject({
697
+ method: 'POST',
698
+ url: '/auth/refresh',
699
+ headers: {cookie: target.refreshCookie},
700
+ payload: {},
701
+ });
702
+ expect(rotatedBeforeSuspension.statusCode).toBe(200);
703
+ const rotatedRefreshCookie = cookieHeader(getSetCookie(rotatedBeforeSuspension));
704
+
705
+ const suspended = await app.inject({
706
+ method: 'POST',
707
+ url: `/admin/auth/users/${target.userId}/suspend`,
708
+ headers: authHeaders(owner.token, 'suspend-user'),
709
+ payload: {reason: 'Account security review'},
710
+ });
711
+
712
+ expect(suspended.statusCode).toBe(200);
713
+ expect(suspended.json()).toMatchObject({
714
+ id: target.userId,
715
+ status: 'suspended',
716
+ admin_role: null,
717
+ correlation_id: expect.any(String),
718
+ });
719
+
720
+ const suspendedMe = await app.inject({
721
+ method: 'GET',
722
+ url: '/auth/me',
723
+ headers: {authorization: `Bearer ${target.token}`},
724
+ });
725
+ const suspendedLogin = await login(app, {email: target.email, password: target.password});
726
+ expect(suspendedMe.statusCode).toBe(401);
727
+ expect(suspendedLogin.statusCode).toBe(401);
728
+
729
+ const suspendedDbUser = (
730
+ await db().select({status: users.status}).from(users).where(eq(users.id, target.userId))
731
+ )[0];
732
+ const targetSessions = await db()
733
+ .select({sessionId: refreshTokens.sessionId, revokedAt: refreshTokens.revokedAt})
734
+ .from(refreshTokens)
735
+ .where(eq(refreshTokens.userId, target.userId));
736
+ expect(suspendedDbUser?.status).toBe('suspended');
737
+ expect(targetSessions).toHaveLength(2);
738
+ expect(targetSessions.every(({revokedAt}) => revokedAt instanceof Date)).toBe(true);
739
+
740
+ const suspendedRefresh = await app.inject({
741
+ method: 'POST',
742
+ url: '/auth/refresh',
743
+ headers: {cookie: rotatedRefreshCookie},
744
+ payload: {},
745
+ });
746
+ expect(suspendedRefresh.statusCode).toBe(401);
747
+
748
+ const suspensionEvent = (await db().select().from(authOutbox)).find(
749
+ (event) =>
750
+ event.eventType === ADMINISTRATION_ACTION_PERFORMED &&
751
+ (event.payload as {command?: string}).command === 'auth.user.suspend',
752
+ );
753
+ expect(suspensionEvent?.payload).toMatchObject({
754
+ actorId: owner.userId,
755
+ actorRole: 'admin-owner',
756
+ requiredRole: 'admin-operator',
757
+ targetId: target.userId,
758
+ reason: 'Account security review',
759
+ result: 'succeeded',
760
+ });
761
+ expect(JSON.stringify(suspensionEvent?.payload)).not.toContain('suspend-user');
762
+ expect(JSON.stringify(suspensionEvent?.payload)).not.toContain(target.password);
763
+
764
+ const repeatedSuspension = await app.inject({
765
+ method: 'POST',
766
+ url: `/admin/auth/users/${target.userId}/suspend`,
767
+ headers: authHeaders(owner.token, 'suspend-user'),
768
+ payload: {reason: 'Account security review'},
769
+ });
770
+ expect(repeatedSuspension.statusCode).toBe(200);
771
+ expect(repeatedSuspension.json().correlation_id).toBe(suspended.json().correlation_id);
772
+
773
+ const reusedSuspensionKey = await app.inject({
774
+ method: 'POST',
775
+ url: `/admin/auth/users/${target.userId}/suspend`,
776
+ headers: authHeaders(owner.token, 'suspend-user'),
777
+ payload: {reason: 'A different reason'},
778
+ });
779
+ expect(reusedSuspensionKey.statusCode).toBe(409);
780
+ expect(reusedSuspensionKey.json().code).toBe('idempotency-key-reused');
781
+
782
+ const reactivated = await app.inject({
783
+ method: 'POST',
784
+ url: `/admin/auth/users/${target.userId}/reactivate`,
785
+ headers: authHeaders(owner.token, 'reactivate-user'),
786
+ payload: {},
787
+ });
788
+ expect(reactivated.statusCode).toBe(200);
789
+ expect(reactivated.json()).toMatchObject({
790
+ id: target.userId,
791
+ status: 'active',
792
+ correlation_id: expect.any(String),
793
+ });
794
+
795
+ const oldRefreshAfterReactivation = await app.inject({
796
+ method: 'POST',
797
+ url: '/auth/refresh',
798
+ headers: {cookie: target.refreshCookie},
799
+ payload: {},
800
+ });
801
+ expect(oldRefreshAfterReactivation.statusCode).toBe(401);
802
+ const rotatedRefreshAfterReactivation = await app.inject({
803
+ method: 'POST',
804
+ url: '/auth/refresh',
805
+ headers: {cookie: rotatedRefreshCookie},
806
+ payload: {},
807
+ });
808
+ expect(rotatedRefreshAfterReactivation.statusCode).toBe(401);
809
+
810
+ const newLogin = await login(app, {email: target.email, password: target.password});
811
+ expect(newLogin.statusCode).toBe(200);
812
+ const secondLogin = await login(app, {email: target.email, password: target.password});
813
+ expect(secondLogin.statusCode).toBe(200);
814
+
815
+ const revoked = await app.inject({
816
+ method: 'POST',
817
+ url: `/admin/auth/users/${target.userId}/revoke-sessions`,
818
+ headers: authHeaders(owner.token, 'revoke-user-sessions'),
819
+ payload: {reason: 'End all active sessions'},
820
+ });
821
+ expect(revoked.statusCode).toBe(200);
822
+ expect(revoked.json()).toMatchObject({
823
+ id: target.userId,
824
+ status: 'active',
825
+ sessions_revoked: 2,
826
+ correlation_id: expect.any(String),
827
+ });
828
+
829
+ const newRefresh = await app.inject({
830
+ method: 'POST',
831
+ url: '/auth/refresh',
832
+ headers: {cookie: cookieHeader(getSetCookie(newLogin))},
833
+ payload: {},
834
+ });
835
+ expect(newRefresh.statusCode).toBe(401);
836
+ const secondRefresh = await app.inject({
837
+ method: 'POST',
838
+ url: '/auth/refresh',
839
+ headers: {cookie: cookieHeader(getSetCookie(secondLogin))},
840
+ payload: {},
841
+ });
842
+ expect(secondRefresh.statusCode).toBe(401);
843
+
844
+ const repeatedRevoke = await app.inject({
845
+ method: 'POST',
846
+ url: `/admin/auth/users/${target.userId}/revoke-sessions`,
847
+ headers: authHeaders(owner.token, 'revoke-user-sessions'),
848
+ payload: {reason: 'End all active sessions'},
849
+ });
850
+ expect(repeatedRevoke.statusCode).toBe(200);
851
+ expect(repeatedRevoke.json().sessions_revoked).toBe(2);
852
+ expect(repeatedRevoke.json().correlation_id).toBe(revoked.json().correlation_id);
853
+ });
854
+
855
+ test('protects the final active owner from suspension', async () => {
856
+ const owner = await createVerifiedSession('admin-suspend-final-owner');
857
+
858
+ await app.inject({
859
+ method: 'POST',
860
+ url: '/admin/auth/admin-grants/bootstrap',
861
+ headers: authHeaders(owner.token, 'suspend-final-bootstrap'),
862
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
863
+ });
864
+
865
+ const suspended = await app.inject({
866
+ method: 'POST',
867
+ url: `/admin/auth/users/${owner.userId}/suspend`,
868
+ headers: authHeaders(owner.token, 'suspend-final-owner'),
869
+ payload: {reason: 'Attempt to suspend final owner'},
870
+ });
871
+
872
+ expect(suspended.statusCode).toBe(409);
873
+ expect(suspended.json().code).toBe('last-owner');
874
+ });
875
+
876
+ test('rejects user moderation by an observer', async () => {
877
+ const owner = await createVerifiedSession('admin-moderation-role-owner');
878
+ const observer = await createVerifiedSession('admin-moderation-role-observer');
879
+ const target = await createVerifiedSession('admin-moderation-role-target');
880
+
881
+ await app.inject({
882
+ method: 'POST',
883
+ url: '/admin/auth/admin-grants/bootstrap',
884
+ headers: authHeaders(owner.token, 'moderation-role-bootstrap'),
885
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
886
+ });
887
+ await app.inject({
888
+ method: 'POST',
889
+ url: '/admin/auth/admin-grants',
890
+ headers: authHeaders(owner.token, 'moderation-role-grant'),
891
+ payload: {
892
+ user_id: observer.userId,
893
+ role: 'admin-observer',
894
+ reason: 'Read-only administration access',
895
+ },
896
+ });
897
+
898
+ const response = await app.inject({
899
+ method: 'POST',
900
+ url: `/admin/auth/users/${target.userId}/suspend`,
901
+ headers: authHeaders(observer.token, 'moderation-role-suspend'),
902
+ payload: {reason: 'Observer should not mutate users'},
903
+ });
904
+
905
+ expect(response.statusCode).toBe(403);
906
+ expect(response.json().code).toBe('forbidden');
907
+ });
665
908
  });
@@ -2,6 +2,7 @@ import {AUTH_USER} from '@shipfox/api-auth-context';
2
2
  import {
3
3
  adminBootstrapStateSchema,
4
4
  administratorUserLookupQuerySchema,
5
+ administratorUserMutationResponseSchema,
5
6
  administratorUserSummarySchema,
6
7
  bootstrapAdminOwnerBodySchema,
7
8
  bootstrapAdminOwnerResponseSchema,
@@ -9,8 +10,12 @@ import {
9
10
  grantAdminRoleResponseSchema,
10
11
  listAdminGrantsQuerySchema,
11
12
  listAdminGrantsResponseSchema,
13
+ reactivateAdministratorUserBodySchema,
12
14
  revokeAdminGrantBodySchema,
13
15
  revokeAdminGrantResponseSchema,
16
+ revokeAdministratorUserSessionsBodySchema,
17
+ revokeAdministratorUserSessionsResponseSchema,
18
+ suspendAdministratorUserBodySchema,
14
19
  } from '@shipfox/api-auth-dto';
15
20
  import {decodeTimestampIdCursor, encodeTimestampIdCursor} from '@shipfox/node-drizzle';
16
21
  import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
@@ -23,7 +28,10 @@ import {
23
28
  getAdminBootstrapState,
24
29
  grantAdministratorRole,
25
30
  listAdministratorGrantSummaries,
31
+ reactivateAdministratorUser,
26
32
  revokeAdministratorGrant,
33
+ revokeAdministratorUserSessions,
34
+ suspendAdministratorUser,
27
35
  } from '#core/administration.js';
28
36
  import type {AdminGrant} from '#core/entities/admin-grant.js';
29
37
  import type {
@@ -101,6 +109,16 @@ function toAdministratorGrantSummaryDto(grant: AdministratorGrantSummary) {
101
109
  };
102
110
  }
103
111
 
112
+ function toAdministratorUserMutationDto(result: {
113
+ user: AdministratorUserSummary;
114
+ correlationId: string;
115
+ }) {
116
+ return {
117
+ ...toAdministratorUserSummaryDto(result.user),
118
+ correlation_id: result.correlationId,
119
+ };
120
+ }
121
+
104
122
  function translateAdministrationError(error: unknown): never {
105
123
  if (error instanceof AdminRoleRequiredError) {
106
124
  throw new ClientError('Administrator role required', 'forbidden', {
@@ -229,6 +247,75 @@ const userLookupRoute = defineRoute({
229
247
  },
230
248
  });
231
249
 
250
+ const suspendUserRoute = defineRoute({
251
+ method: 'POST',
252
+ path: '/:userId/suspend',
253
+ description: 'Suspend a user and revoke all of its active sessions.',
254
+ schema: {
255
+ params: z.object({userId: z.string().uuid()}),
256
+ body: suspendAdministratorUserBodySchema,
257
+ response: {200: administratorUserMutationResponseSchema},
258
+ },
259
+ errorHandler: translateAdministrationError,
260
+ handler: async (request) => {
261
+ const result = await suspendAdministratorUser({
262
+ actorId: requireActorId(request),
263
+ userId: request.params.userId,
264
+ reason: request.body.reason,
265
+ idempotencyKey: requireIdempotencyKey(request),
266
+ correlationId: request.id,
267
+ });
268
+ return toAdministratorUserMutationDto(result);
269
+ },
270
+ });
271
+
272
+ const reactivateUserRoute = defineRoute({
273
+ method: 'POST',
274
+ path: '/:userId/reactivate',
275
+ description: 'Reactivate a suspended user without restoring revoked sessions.',
276
+ schema: {
277
+ params: z.object({userId: z.string().uuid()}),
278
+ body: reactivateAdministratorUserBodySchema,
279
+ response: {200: administratorUserMutationResponseSchema},
280
+ },
281
+ errorHandler: translateAdministrationError,
282
+ handler: async (request) => {
283
+ const result = await reactivateAdministratorUser({
284
+ actorId: requireActorId(request),
285
+ userId: request.params.userId,
286
+ ...(request.body.reason ? {reason: request.body.reason} : {}),
287
+ idempotencyKey: requireIdempotencyKey(request),
288
+ correlationId: request.id,
289
+ });
290
+ return toAdministratorUserMutationDto(result);
291
+ },
292
+ });
293
+
294
+ const revokeUserSessionsRoute = defineRoute({
295
+ method: 'POST',
296
+ path: '/:userId/revoke-sessions',
297
+ description: 'Revoke all active sessions for a user without changing account status.',
298
+ schema: {
299
+ params: z.object({userId: z.string().uuid()}),
300
+ body: revokeAdministratorUserSessionsBodySchema,
301
+ response: {200: revokeAdministratorUserSessionsResponseSchema},
302
+ },
303
+ errorHandler: translateAdministrationError,
304
+ handler: async (request) => {
305
+ const result = await revokeAdministratorUserSessions({
306
+ actorId: requireActorId(request),
307
+ userId: request.params.userId,
308
+ ...(request.body.reason ? {reason: request.body.reason} : {}),
309
+ idempotencyKey: requireIdempotencyKey(request),
310
+ correlationId: request.id,
311
+ });
312
+ return {
313
+ ...toAdministratorUserMutationDto(result),
314
+ sessions_revoked: result.sessionsRevoked,
315
+ };
316
+ },
317
+ });
318
+
232
319
  const grantRoute = defineRoute({
233
320
  method: 'POST',
234
321
  path: '/',
@@ -290,5 +377,5 @@ export const administrationBootstrapRoutes: RouteGroup = {
290
377
  export const administrationUserRoutes: RouteGroup = {
291
378
  prefix: '/admin/auth/users',
292
379
  auth: AUTH_USER,
293
- routes: [userLookupRoute],
380
+ routes: [userLookupRoute, suspendUserRoute, reactivateUserRoute, revokeUserSessionsRoute],
294
381
  };