@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
@@ -13,9 +13,11 @@ interface TestClaims {
13
13
  describe('bearer-token-auth', () => {
14
14
  let app: FastifyInstance;
15
15
  let verifyToken: ReturnType<typeof vi.fn<(token: string) => Promise<TestClaims | null>>>;
16
+ let verifierErrorIsInvalid = true;
16
17
 
17
18
  beforeEach(async () => {
18
19
  verifyToken = vi.fn<(token: string) => Promise<TestClaims | null>>();
20
+ verifierErrorIsInvalid = true;
19
21
  app = Fastify();
20
22
  app.setValidatorCompiler(validatorCompiler);
21
23
  app.setSerializerCompiler(serializerCompiler);
@@ -24,6 +26,7 @@ describe('bearer-token-auth', () => {
24
26
  const authMethod = createBearerTokenAuthMethod({
25
27
  name: 'test-bearer',
26
28
  verifyToken,
29
+ isInvalidTokenError: () => verifierErrorIsInvalid,
27
30
  invalidTokenError: {message: 'Invalid test bearer token', code: 'invalid-test-token'},
28
31
  setContext: (request, claims) => {
29
32
  (request as FastifyRequest & Record<typeof CONTEXT_KEY, typeof claims>)[CONTEXT_KEY] =
@@ -96,4 +99,18 @@ describe('bearer-token-auth', () => {
96
99
  expect(res.json()).toEqual({code: 'invalid-test-token'});
97
100
  expect(verifyToken).toHaveBeenCalledWith('invalid-token');
98
101
  });
102
+
103
+ test('propagates verifier errors classified as service failures', async () => {
104
+ verifierErrorIsInvalid = false;
105
+ verifyToken.mockRejectedValue(new Error('database unavailable'));
106
+
107
+ const res = await app.inject({
108
+ method: 'GET',
109
+ url: '/protected',
110
+ headers: {authorization: 'Bearer valid-token'},
111
+ });
112
+
113
+ expect(res.statusCode).toBe(500);
114
+ expect(res.json().code).not.toBe('invalid-test-token');
115
+ });
99
116
  });
@@ -9,6 +9,7 @@ interface UnauthorizedErrorParams {
9
9
  interface BearerTokenAuthMethodOptions<TClaims> {
10
10
  name: string;
11
11
  verifyToken: (token: string) => Promise<TClaims | null>;
12
+ isInvalidTokenError?: (error: unknown) => boolean;
12
13
  invalidTokenError: UnauthorizedErrorParams;
13
14
  setContext: (request: FastifyRequest, claims: TClaims) => void;
14
15
  }
@@ -27,7 +28,11 @@ export function createBearerTokenAuthMethod<TClaims>(
27
28
  const token = extractBearerToken(request.headers.authorization);
28
29
  if (!token) throwUnauthorized(missingBearerError);
29
30
 
30
- const claims = await verifyBearerToken(token, options.verifyToken);
31
+ const claims = await verifyBearerToken(
32
+ token,
33
+ options.verifyToken,
34
+ options.isInvalidTokenError,
35
+ );
31
36
  if (!claims) throwUnauthorized(options.invalidTokenError);
32
37
 
33
38
  options.setContext(request, claims);
@@ -38,10 +43,12 @@ export function createBearerTokenAuthMethod<TClaims>(
38
43
  async function verifyBearerToken<TClaims>(
39
44
  token: string,
40
45
  verifyToken: (token: string) => Promise<TClaims | null>,
46
+ isInvalidTokenError: ((error: unknown) => boolean) | undefined,
41
47
  ): Promise<TClaims | null> {
42
48
  try {
43
49
  return await verifyToken(token);
44
- } catch {
50
+ } catch (error) {
51
+ if (isInvalidTokenError && !isInvalidTokenError(error)) throw error;
45
52
  return null;
46
53
  }
47
54
  }
@@ -9,7 +9,7 @@ import {
9
9
  revokeRefreshTokenByHash,
10
10
  rotateRefreshToken,
11
11
  } from '#db/refresh-tokens.js';
12
- import {createUser, findUserById} from '#db/users.js';
12
+ import {createUser} from '#db/users.js';
13
13
  import {createJwtAuthMethod, getAuthenticatedSessionContext, getClientContext} from './jwt-auth.js';
14
14
 
15
15
  const SECRET = userAccessTokenKey();
@@ -116,7 +116,7 @@ describe('jwt-auth', () => {
116
116
  expect(res.statusCode).toBe(401);
117
117
  });
118
118
 
119
- test('does not read user state during JWT validation', async () => {
119
+ test('keeps a legacy token verifiable during the migration window', async () => {
120
120
  const userId = crypto.randomUUID();
121
121
  const email = emailFor('jwt-stateless');
122
122
  const token = await signUserToken({
@@ -138,7 +138,35 @@ describe('jwt-auth', () => {
138
138
  expect(res.json().client.email).toBe(email);
139
139
  });
140
140
 
141
- test('resolves the active refresh-session identity from an authenticated request', async () => {
141
+ test('verifies a token from claims when its user and refresh session are missing', async () => {
142
+ const userId = crypto.randomUUID();
143
+ const refreshSessionId = crypto.randomUUID();
144
+ const token = await signUserToken({
145
+ userId,
146
+ email: emailFor('jwt-missing-user'),
147
+ memberships: [],
148
+ refreshSessionId,
149
+ secret: SECRET,
150
+ expiresIn: '7d',
151
+ });
152
+
153
+ const protectedResponse = await app.inject({
154
+ method: 'GET',
155
+ url: '/protected',
156
+ headers: {authorization: `Bearer ${token}`},
157
+ });
158
+ const sessionResponse = await app.inject({
159
+ method: 'GET',
160
+ url: '/session',
161
+ headers: {authorization: `Bearer ${token}`},
162
+ });
163
+
164
+ expect(protectedResponse.statusCode).toBe(200);
165
+ expect(sessionResponse.statusCode).toBe(200);
166
+ expect(sessionResponse.json()).toEqual({userId, refreshSessionId});
167
+ });
168
+
169
+ test('returns the refresh-session identity from an authenticated request', async () => {
142
170
  const user = await createUser({email: emailFor('jwt-session'), hashedPassword: 'h'});
143
171
  const rawRefreshToken = `refresh-${crypto.randomUUID()}`;
144
172
  const refreshToken = await createRefreshToken({
@@ -186,7 +214,7 @@ describe('jwt-auth', () => {
186
214
  expect(refreshed.json()).toEqual(initial.json());
187
215
  });
188
216
 
189
- test('rejects an authenticated token after its refresh session is revoked', async () => {
217
+ test('keeps an authenticated token valid after its refresh session is revoked', async () => {
190
218
  const user = await createUser({email: emailFor('jwt-session-revoked'), hashedPassword: 'h'});
191
219
  const rawRefreshToken = `refresh-${crypto.randomUUID()}`;
192
220
  const refreshToken = await createRefreshToken({
@@ -210,7 +238,8 @@ describe('jwt-auth', () => {
210
238
  headers: {authorization: `Bearer ${token}`},
211
239
  });
212
240
 
213
- expect(res.statusCode).toBe(401);
241
+ expect(res.statusCode).toBe(200);
242
+ expect(res.json()).toEqual({userId: user.id, refreshSessionId: refreshToken.sessionId});
214
243
  });
215
244
 
216
245
  test('rejects an authenticated token without a refresh-session claim', async () => {
@@ -231,8 +260,4 @@ describe('jwt-auth', () => {
231
260
 
232
261
  expect(res.statusCode).toBe(401);
233
262
  });
234
-
235
- test('helpers remain available', () => {
236
- expect(typeof findUserById).toBe('function');
237
- });
238
263
  });
@@ -12,7 +12,6 @@ import type {RefreshToken} from '#core/entities/refresh-token.js';
12
12
  import type {User} from '#core/entities/user.js';
13
13
  import type {UserTokenClaims} from '#core/jwt.js';
14
14
  import {verifyUserToken} from '#core/jwt.js';
15
- import {findActiveRefreshSession} from '#db/refresh-tokens.js';
16
15
  import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
17
16
 
18
17
  const AUTHENTICATED_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth/session');
@@ -31,6 +30,13 @@ export interface CreateJwtAuthMethodOptions {
31
30
  secret: string;
32
31
  }
33
32
 
33
+ class InvalidJwtTokenError extends Error {
34
+ constructor() {
35
+ super('Invalid JWT');
36
+ this.name = 'InvalidJwtTokenError';
37
+ }
38
+ }
39
+
34
40
  export function getClientContext(request: FastifyRequest): ClientContext | null {
35
41
  return getUserContext(request);
36
42
  }
@@ -53,10 +59,7 @@ function getAuthenticatedSessionTokenContext(request: FastifyRequest): {
53
59
  );
54
60
  }
55
61
 
56
- /**
57
- * Resolves the active refresh session backing an authenticated user request.
58
- * The session check intentionally happens only for consumers that need browser-session binding.
59
- */
62
+ /** Returns the refresh-session metadata carried by a verified access token. */
60
63
  export async function getAuthenticatedSessionContext(
61
64
  request: FastifyRequest,
62
65
  ): Promise<AuthenticatedSessionContext> {
@@ -65,19 +68,22 @@ export async function getAuthenticatedSessionContext(
65
68
  if (!client || !token || token.userId !== client.userId || !token.refreshSessionId)
66
69
  throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
67
70
 
68
- const session = await findActiveRefreshSession({
69
- sessionId: token.refreshSessionId,
70
- userId: client.userId,
71
- });
72
- if (!session) throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
73
-
74
- return {userId: client.userId, refreshSessionId: session.sessionId};
71
+ return await Promise.resolve({userId: client.userId, refreshSessionId: token.refreshSessionId});
75
72
  }
76
73
 
77
74
  export function createJwtAuthMethod(): AuthMethod {
78
75
  return createBearerTokenAuthMethod({
79
76
  name: AUTH_USER,
80
- verifyToken: (token) => verifyUserToken({token, secret: userAccessTokenKey()}),
77
+ verifyToken: async (token) => {
78
+ let claims: UserTokenClaims;
79
+ try {
80
+ claims = await verifyUserToken({token, secret: userAccessTokenKey()});
81
+ } catch {
82
+ throw new InvalidJwtTokenError();
83
+ }
84
+ return claims;
85
+ },
86
+ isInvalidTokenError: (error) => error instanceof InvalidJwtTokenError,
81
87
  invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
82
88
  setContext: (request, claims) => {
83
89
  const clientContext: ClientContext = buildUserContext({
@@ -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,246 @@ 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(200);
727
+ expect(suspendedMe.json().user).toMatchObject({id: target.userId, status: 'suspended'});
728
+ expect(suspendedLogin.statusCode).toBe(401);
729
+
730
+ const suspendedDbUser = (
731
+ await db().select({status: users.status}).from(users).where(eq(users.id, target.userId))
732
+ )[0];
733
+ const targetSessions = await db()
734
+ .select({sessionId: refreshTokens.sessionId, revokedAt: refreshTokens.revokedAt})
735
+ .from(refreshTokens)
736
+ .where(eq(refreshTokens.userId, target.userId));
737
+ expect(suspendedDbUser?.status).toBe('suspended');
738
+ expect(targetSessions).toHaveLength(2);
739
+ expect(targetSessions.every(({revokedAt}) => revokedAt instanceof Date)).toBe(true);
740
+
741
+ const suspendedRefresh = await app.inject({
742
+ method: 'POST',
743
+ url: '/auth/refresh',
744
+ headers: {cookie: rotatedRefreshCookie},
745
+ payload: {},
746
+ });
747
+ expect(suspendedRefresh.statusCode).toBe(401);
748
+
749
+ const suspensionEvent = (await db().select().from(authOutbox)).find(
750
+ (event) =>
751
+ event.eventType === ADMINISTRATION_ACTION_PERFORMED &&
752
+ (event.payload as {command?: string}).command === 'auth.user.suspend',
753
+ );
754
+ expect(suspensionEvent?.payload).toMatchObject({
755
+ actorId: owner.userId,
756
+ actorRole: 'admin-owner',
757
+ requiredRole: 'admin-operator',
758
+ targetId: target.userId,
759
+ reason: 'Account security review',
760
+ result: 'succeeded',
761
+ });
762
+ expect(JSON.stringify(suspensionEvent?.payload)).not.toContain('suspend-user');
763
+ expect(JSON.stringify(suspensionEvent?.payload)).not.toContain(target.password);
764
+
765
+ const repeatedSuspension = await app.inject({
766
+ method: 'POST',
767
+ url: `/admin/auth/users/${target.userId}/suspend`,
768
+ headers: authHeaders(owner.token, 'suspend-user'),
769
+ payload: {reason: 'Account security review'},
770
+ });
771
+ expect(repeatedSuspension.statusCode).toBe(200);
772
+ expect(repeatedSuspension.json().correlation_id).toBe(suspended.json().correlation_id);
773
+
774
+ const reusedSuspensionKey = await app.inject({
775
+ method: 'POST',
776
+ url: `/admin/auth/users/${target.userId}/suspend`,
777
+ headers: authHeaders(owner.token, 'suspend-user'),
778
+ payload: {reason: 'A different reason'},
779
+ });
780
+ expect(reusedSuspensionKey.statusCode).toBe(409);
781
+ expect(reusedSuspensionKey.json().code).toBe('idempotency-key-reused');
782
+
783
+ const reactivated = await app.inject({
784
+ method: 'POST',
785
+ url: `/admin/auth/users/${target.userId}/reactivate`,
786
+ headers: authHeaders(owner.token, 'reactivate-user'),
787
+ payload: {},
788
+ });
789
+ expect(reactivated.statusCode).toBe(200);
790
+ expect(reactivated.json()).toMatchObject({
791
+ id: target.userId,
792
+ status: 'active',
793
+ correlation_id: expect.any(String),
794
+ });
795
+
796
+ const oldRefreshAfterReactivation = await app.inject({
797
+ method: 'POST',
798
+ url: '/auth/refresh',
799
+ headers: {cookie: target.refreshCookie},
800
+ payload: {},
801
+ });
802
+ expect(oldRefreshAfterReactivation.statusCode).toBe(401);
803
+ const rotatedRefreshAfterReactivation = await app.inject({
804
+ method: 'POST',
805
+ url: '/auth/refresh',
806
+ headers: {cookie: rotatedRefreshCookie},
807
+ payload: {},
808
+ });
809
+ expect(rotatedRefreshAfterReactivation.statusCode).toBe(401);
810
+
811
+ const newLogin = await login(app, {email: target.email, password: target.password});
812
+ expect(newLogin.statusCode).toBe(200);
813
+ const secondLogin = await login(app, {email: target.email, password: target.password});
814
+ expect(secondLogin.statusCode).toBe(200);
815
+
816
+ const revoked = await app.inject({
817
+ method: 'POST',
818
+ url: `/admin/auth/users/${target.userId}/revoke-sessions`,
819
+ headers: authHeaders(owner.token, 'revoke-user-sessions'),
820
+ payload: {reason: 'End all active sessions'},
821
+ });
822
+ expect(revoked.statusCode).toBe(200);
823
+ expect(revoked.json()).toMatchObject({
824
+ id: target.userId,
825
+ status: 'active',
826
+ sessions_revoked: 2,
827
+ correlation_id: expect.any(String),
828
+ });
829
+
830
+ const newRefresh = await app.inject({
831
+ method: 'POST',
832
+ url: '/auth/refresh',
833
+ headers: {cookie: cookieHeader(getSetCookie(newLogin))},
834
+ payload: {},
835
+ });
836
+ expect(newRefresh.statusCode).toBe(401);
837
+ const secondRefresh = await app.inject({
838
+ method: 'POST',
839
+ url: '/auth/refresh',
840
+ headers: {cookie: cookieHeader(getSetCookie(secondLogin))},
841
+ payload: {},
842
+ });
843
+ expect(secondRefresh.statusCode).toBe(401);
844
+
845
+ const revokedAccess = await app.inject({
846
+ method: 'GET',
847
+ url: '/auth/me',
848
+ headers: {authorization: `Bearer ${newLogin.json().token}`},
849
+ });
850
+ expect(revokedAccess.statusCode).toBe(200);
851
+
852
+ const repeatedRevoke = await app.inject({
853
+ method: 'POST',
854
+ url: `/admin/auth/users/${target.userId}/revoke-sessions`,
855
+ headers: authHeaders(owner.token, 'revoke-user-sessions'),
856
+ payload: {reason: 'End all active sessions'},
857
+ });
858
+ expect(repeatedRevoke.statusCode).toBe(200);
859
+ expect(repeatedRevoke.json().sessions_revoked).toBe(2);
860
+ expect(repeatedRevoke.json().correlation_id).toBe(revoked.json().correlation_id);
861
+ });
862
+
863
+ test('protects the final active owner from suspension', async () => {
864
+ const owner = await createVerifiedSession('admin-suspend-final-owner');
865
+
866
+ await app.inject({
867
+ method: 'POST',
868
+ url: '/admin/auth/admin-grants/bootstrap',
869
+ headers: authHeaders(owner.token, 'suspend-final-bootstrap'),
870
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
871
+ });
872
+
873
+ const suspended = await app.inject({
874
+ method: 'POST',
875
+ url: `/admin/auth/users/${owner.userId}/suspend`,
876
+ headers: authHeaders(owner.token, 'suspend-final-owner'),
877
+ payload: {reason: 'Attempt to suspend final owner'},
878
+ });
879
+
880
+ expect(suspended.statusCode).toBe(409);
881
+ expect(suspended.json().code).toBe('last-owner');
882
+ });
883
+
884
+ test('rejects user moderation by an observer', async () => {
885
+ const owner = await createVerifiedSession('admin-moderation-role-owner');
886
+ const observer = await createVerifiedSession('admin-moderation-role-observer');
887
+ const target = await createVerifiedSession('admin-moderation-role-target');
888
+
889
+ await app.inject({
890
+ method: 'POST',
891
+ url: '/admin/auth/admin-grants/bootstrap',
892
+ headers: authHeaders(owner.token, 'moderation-role-bootstrap'),
893
+ payload: {bootstrap_token: BOOTSTRAP_TOKEN},
894
+ });
895
+ await app.inject({
896
+ method: 'POST',
897
+ url: '/admin/auth/admin-grants',
898
+ headers: authHeaders(owner.token, 'moderation-role-grant'),
899
+ payload: {
900
+ user_id: observer.userId,
901
+ role: 'admin-observer',
902
+ reason: 'Read-only administration access',
903
+ },
904
+ });
905
+
906
+ const response = await app.inject({
907
+ method: 'POST',
908
+ url: `/admin/auth/users/${target.userId}/suspend`,
909
+ headers: authHeaders(observer.token, 'moderation-role-suspend'),
910
+ payload: {reason: 'Observer should not mutate users'},
911
+ });
912
+
913
+ expect(response.statusCode).toBe(403);
914
+ expect(response.json().code).toBe('forbidden');
915
+ });
665
916
  });
@@ -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
  };
@@ -48,6 +48,11 @@ describe('POST /auth/change-password', () => {
48
48
  headers: {cookie: cookieHeader(account.refreshCookie)},
49
49
  payload: {},
50
50
  });
51
+ const access = await app.inject({
52
+ method: 'GET',
53
+ url: '/auth/me',
54
+ headers: {authorization: `Bearer ${account.token}`},
55
+ });
51
56
  const oldPasswordValid = await verifyPassword({
52
57
  password: account.password,
53
58
  hash: updatedUser?.hashedPassword ?? '',
@@ -62,6 +67,7 @@ describe('POST /auth/change-password', () => {
62
67
  expect(oldPasswordValid).toBe(false);
63
68
  expect(newPasswordValid).toBe(true);
64
69
  expect(refresh.statusCode).toBe(200);
70
+ expect(access.statusCode).toBe(200);
65
71
  });
66
72
 
67
73
  test('transforms invalid current password into 401', async () => {
@@ -1,6 +1,7 @@
1
1
  import {AUTH_PASSWORD_RESET_SEND_REQUESTED} from '@shipfox/api-auth-dto';
2
2
  import type {FastifyInstance} from 'fastify';
3
3
  import {
4
+ cookieHeader,
4
5
  createAuthTestApp,
5
6
  extractToken,
6
7
  getSetCookie,
@@ -42,12 +43,25 @@ describe('POST /auth/password-reset/confirm', () => {
42
43
  payload: {token: resetToken, new_password: 'reset password is long'},
43
44
  });
44
45
  const loginRes = await login(app, {email: account.email, password: 'reset password is long'});
46
+ const oldRefresh = await app.inject({
47
+ method: 'POST',
48
+ url: '/auth/refresh',
49
+ headers: {cookie: cookieHeader(account.refreshCookie)},
50
+ payload: {},
51
+ });
52
+ const oldAccess = await app.inject({
53
+ method: 'GET',
54
+ url: '/auth/me',
55
+ headers: {authorization: `Bearer ${account.token}`},
56
+ });
45
57
 
46
58
  expect(confirm.statusCode).toBe(200);
47
59
  expect(confirm.json().token).toBeDefined();
48
60
  expect(confirm.json().user.email).toBe(account.email);
49
61
  expect(getSetCookie(confirm)).toContain('shipfox_refresh_token=');
50
62
  expect(loginRes.statusCode).toBe(200);
63
+ expect(oldRefresh.statusCode).toBe(401);
64
+ expect(oldAccess.statusCode).toBe(200);
51
65
  });
52
66
 
53
67
  test('transforms invalid token into 410', async () => {