@shipfox/api-auth 9.2.0 → 10.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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +32 -8
  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 +43 -0
  9. package/dist/core/administration.d.ts.map +1 -0
  10. package/dist/core/administration.js +152 -0
  11. package/dist/core/administration.js.map +1 -0
  12. package/dist/core/auth.d.ts.map +1 -1
  13. package/dist/core/auth.js +2 -5
  14. package/dist/core/auth.js.map +1 -1
  15. package/dist/core/entities/administrator-read-model.d.ts +21 -0
  16. package/dist/core/entities/administrator-read-model.d.ts.map +1 -0
  17. package/dist/core/entities/administrator-read-model.js +3 -0
  18. package/dist/core/entities/administrator-read-model.js.map +1 -0
  19. package/dist/core/errors.d.ts +15 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +30 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/db/admin-grants.d.ts +34 -1
  24. package/dist/db/admin-grants.d.ts.map +1 -1
  25. package/dist/db/admin-grants.js +177 -5
  26. package/dist/db/admin-grants.js.map +1 -1
  27. package/dist/db/admin-users.d.ts +21 -0
  28. package/dist/db/admin-users.d.ts.map +1 -0
  29. package/dist/db/admin-users.js +32 -0
  30. package/dist/db/admin-users.js.map +1 -0
  31. package/dist/db/db.d.ts +256 -0
  32. package/dist/db/db.d.ts.map +1 -1
  33. package/dist/db/db.js +2 -0
  34. package/dist/db/db.js.map +1 -1
  35. package/dist/db/schema/admin-command-results.d.ts +142 -0
  36. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  37. package/dist/db/schema/admin-command-results.js +21 -0
  38. package/dist/db/schema/admin-command-results.js.map +1 -0
  39. package/dist/index.d.ts +2 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +12 -3
  42. package/dist/index.js.map +1 -1
  43. package/dist/metrics/instance.d.ts +1 -1
  44. package/dist/metrics/instance.d.ts.map +1 -1
  45. package/dist/metrics/instance.js.map +1 -1
  46. package/dist/presentation/routes/administration.d.ts +4 -0
  47. package/dist/presentation/routes/administration.d.ts.map +1 -0
  48. package/dist/presentation/routes/administration.js +267 -0
  49. package/dist/presentation/routes/administration.js.map +1 -0
  50. package/dist/presentation/routes/rate-limit.d.ts +1 -0
  51. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  52. package/dist/presentation/routes/rate-limit.js +26 -1
  53. package/dist/presentation/routes/rate-limit.js.map +1 -1
  54. package/dist/tsconfig.test.tsbuildinfo +1 -1
  55. package/drizzle/0002_burly_whizzer.sql +12 -0
  56. package/drizzle/meta/0002_snapshot.json +791 -0
  57. package/drizzle/meta/_journal.json +7 -0
  58. package/package.json +10 -9
  59. package/src/config.ts +4 -0
  60. package/src/core/administration.ts +213 -0
  61. package/src/core/auth-default-signup-policy.test.ts +65 -0
  62. package/src/core/auth.test.ts +5 -0
  63. package/src/core/auth.ts +2 -3
  64. package/src/core/entities/administrator-read-model.ts +23 -0
  65. package/src/core/errors.ts +35 -0
  66. package/src/db/admin-grants.test.ts +1 -7
  67. package/src/db/admin-grants.ts +310 -5
  68. package/src/db/admin-users.ts +58 -0
  69. package/src/db/db.ts +2 -0
  70. package/src/db/schema/admin-command-results.ts +41 -0
  71. package/src/index.test.ts +6 -2
  72. package/src/index.ts +23 -2
  73. package/src/metrics/instance.ts +1 -1
  74. package/src/presentation/auth/refresh-cookie.test.ts +4 -0
  75. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  76. package/src/presentation/routes/administration.test.ts +537 -0
  77. package/src/presentation/routes/administration.ts +276 -0
  78. package/src/presentation/routes/index.test.ts +1 -0
  79. package/src/presentation/routes/rate-limit.ts +26 -2
  80. package/test/globalSetup.ts +6 -2
  81. package/test/routes.ts +10 -1
  82. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,276 @@
1
+ import {AUTH_USER} from '@shipfox/api-auth-context';
2
+ import {
3
+ administratorUserLookupQuerySchema,
4
+ administratorUserSummarySchema,
5
+ bootstrapAdminOwnerBodySchema,
6
+ bootstrapAdminOwnerResponseSchema,
7
+ grantAdminRoleBodySchema,
8
+ grantAdminRoleResponseSchema,
9
+ listAdminGrantsQuerySchema,
10
+ listAdminGrantsResponseSchema,
11
+ revokeAdminGrantBodySchema,
12
+ revokeAdminGrantResponseSchema,
13
+ } from '@shipfox/api-auth-dto';
14
+ import {decodeTimestampIdCursor, encodeTimestampIdCursor} from '@shipfox/node-drizzle';
15
+ import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
16
+ import type {FastifyRequest} from 'fastify';
17
+ import {z} from 'zod';
18
+ import {requireAdminRole} from '#core/admin-role.js';
19
+ import {
20
+ bootstrapFirstAdminOwner,
21
+ findAdministratorUserSummary,
22
+ grantAdministratorRole,
23
+ listAdministratorGrantSummaries,
24
+ revokeAdministratorGrant,
25
+ } from '#core/administration.js';
26
+ import type {AdminGrant} from '#core/entities/admin-grant.js';
27
+ import type {
28
+ AdministratorGrantSummary,
29
+ AdministratorUserSummary,
30
+ } from '#core/entities/administrator-read-model.js';
31
+ import {
32
+ AdminBootstrapClosedError,
33
+ AdminGrantAlreadyExistsError,
34
+ AdminGrantNotFoundError,
35
+ AdminIdempotencyKeyReuseError,
36
+ AdminRoleRequiredError,
37
+ InvalidAdminBootstrapTokenError,
38
+ LastAdminOwnerError,
39
+ UserNotFoundError,
40
+ } from '#core/errors.js';
41
+ import {getClientContext} from '#presentation/auth/jwt-auth.js';
42
+ import {createAuthIpRateLimitPreHandler} from './rate-limit.js';
43
+
44
+ const idempotencyKeyMaxLength = 256;
45
+
46
+ function requireActorId(request: FastifyRequest): string {
47
+ const client = getClientContext(request);
48
+ if (!client) {
49
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
50
+ }
51
+ return client.userId;
52
+ }
53
+
54
+ function requireIdempotencyKey(request: FastifyRequest): string {
55
+ const value = request.headers['idempotency-key'];
56
+ const key = Array.isArray(value) ? value[0] : value;
57
+ if (!key || key.trim().length === 0 || key.length > idempotencyKeyMaxLength) {
58
+ throw new ClientError('Idempotency-Key header is required', 'idempotency-key-required', {
59
+ status: 400,
60
+ });
61
+ }
62
+ return key;
63
+ }
64
+
65
+ async function requireAdministratorObserver(request: FastifyRequest): Promise<void> {
66
+ await requireAdminRole({userId: requireActorId(request), minimumRole: 'admin-observer'});
67
+ }
68
+
69
+ function toAdminGrantDto(grant: AdminGrant) {
70
+ return {
71
+ id: grant.id,
72
+ user_id: grant.userId,
73
+ role: grant.role,
74
+ revoked_at: grant.revokedAt?.toISOString() ?? null,
75
+ created_at: grant.createdAt.toISOString(),
76
+ updated_at: grant.updatedAt.toISOString(),
77
+ };
78
+ }
79
+
80
+ function toAdministratorUserSummaryDto(user: AdministratorUserSummary) {
81
+ return {
82
+ id: user.id,
83
+ email: user.email,
84
+ name: user.name,
85
+ status: user.status,
86
+ email_verified_at: user.emailVerifiedAt?.toISOString() ?? null,
87
+ created_at: user.createdAt.toISOString(),
88
+ admin_role: user.adminRole,
89
+ };
90
+ }
91
+
92
+ function toAdministratorGrantSummaryDto(grant: AdministratorGrantSummary) {
93
+ return {
94
+ grant_id: grant.grantId,
95
+ role: grant.role,
96
+ created_at: grant.createdAt.toISOString(),
97
+ revoked_at: grant.revokedAt?.toISOString() ?? null,
98
+ user: grant.user,
99
+ };
100
+ }
101
+
102
+ function translateAdministrationError(error: unknown): never {
103
+ if (error instanceof AdminRoleRequiredError) {
104
+ throw new ClientError('Administrator role required', 'forbidden', {
105
+ status: 403,
106
+ details: {required_role: error.minimumRole},
107
+ });
108
+ }
109
+ if (error instanceof InvalidAdminBootstrapTokenError) {
110
+ throw new ClientError('Bootstrap token is invalid', 'bootstrap-token-invalid', {
111
+ status: 403,
112
+ });
113
+ }
114
+ if (error instanceof AdminBootstrapClosedError) {
115
+ throw new ClientError('First administrator owner already exists', 'bootstrap-closed', {
116
+ status: 409,
117
+ });
118
+ }
119
+ if (error instanceof AdminGrantAlreadyExistsError) {
120
+ throw new ClientError('Administrator grant already exists', 'grant-already-exists', {
121
+ status: 409,
122
+ });
123
+ }
124
+ if (error instanceof AdminGrantNotFoundError) {
125
+ throw new ClientError('Administrator grant not found', 'not-found', {status: 404});
126
+ }
127
+ if (error instanceof UserNotFoundError) {
128
+ throw new ClientError('User not found', 'not-found', {status: 404});
129
+ }
130
+ if (error instanceof LastAdminOwnerError) {
131
+ throw new ClientError('Cannot remove the final active administrator owner', 'last-owner', {
132
+ status: 409,
133
+ });
134
+ }
135
+ if (error instanceof AdminIdempotencyKeyReuseError) {
136
+ throw new ClientError(
137
+ 'Idempotency-Key was already used for a different command',
138
+ 'idempotency-key-reused',
139
+ {status: 409},
140
+ );
141
+ }
142
+ throw error;
143
+ }
144
+
145
+ const bootstrapRoute = defineRoute({
146
+ method: 'POST',
147
+ path: '/bootstrap',
148
+ description: 'Claim the first administrator owner role with the deployment bootstrap token.',
149
+ schema: {
150
+ body: bootstrapAdminOwnerBodySchema,
151
+ response: {201: bootstrapAdminOwnerResponseSchema},
152
+ },
153
+ preHandler: createAuthIpRateLimitPreHandler('bootstrap'),
154
+ errorHandler: translateAdministrationError,
155
+ handler: async (request, reply) => {
156
+ const actorId = requireActorId(request);
157
+ const grant = await bootstrapFirstAdminOwner({
158
+ actorId,
159
+ bootstrapToken: request.body.bootstrap_token,
160
+ idempotencyKey: requireIdempotencyKey(request),
161
+ correlationId: request.id,
162
+ });
163
+ reply.code(201);
164
+ return toAdminGrantDto(grant);
165
+ },
166
+ });
167
+
168
+ const listRoute = defineRoute({
169
+ method: 'GET',
170
+ path: '/',
171
+ description: 'List bounded local administrator grant summaries.',
172
+ schema: {
173
+ querystring: listAdminGrantsQuerySchema,
174
+ response: {200: listAdminGrantsResponseSchema},
175
+ },
176
+ errorHandler: translateAdministrationError,
177
+ handler: async (request) => {
178
+ const {limit, cursor} = request.query;
179
+ const decodedCursor = decodeTimestampIdCursor(cursor);
180
+ if (cursor && !decodedCursor) {
181
+ throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
182
+ }
183
+
184
+ const result = await listAdministratorGrantSummaries({
185
+ actorId: requireActorId(request),
186
+ limit,
187
+ ...(decodedCursor ? {cursor: decodedCursor} : {}),
188
+ });
189
+ return {
190
+ grants: result.grants.map(toAdministratorGrantSummaryDto),
191
+ next_cursor: result.nextCursor ? encodeTimestampIdCursor(result.nextCursor) : null,
192
+ };
193
+ },
194
+ });
195
+
196
+ const userLookupRoute = defineRoute({
197
+ method: 'GET',
198
+ path: '/',
199
+ description: 'Find one administrator-safe user summary by exact ID or email.',
200
+ schema: {
201
+ querystring: administratorUserLookupQuerySchema,
202
+ response: {200: administratorUserSummarySchema},
203
+ },
204
+ preHandler: [requireAdministratorObserver, createAuthIpRateLimitPreHandler('lookup')],
205
+ errorHandler: translateAdministrationError,
206
+ handler: async (request) => {
207
+ const {id, user_id: userId, email} = request.query;
208
+ const lookupId = id ?? userId;
209
+ const actorId = requireActorId(request);
210
+ const user = lookupId
211
+ ? await findAdministratorUserSummary({actorId, id: lookupId})
212
+ : email
213
+ ? await findAdministratorUserSummary({actorId, email})
214
+ : undefined;
215
+ if (!user) throw new UserNotFoundError(lookupId ?? email ?? 'unknown');
216
+ return toAdministratorUserSummaryDto(user);
217
+ },
218
+ });
219
+
220
+ const grantRoute = defineRoute({
221
+ method: 'POST',
222
+ path: '/',
223
+ description: 'Grant a local administrator role to an active user.',
224
+ schema: {
225
+ body: grantAdminRoleBodySchema,
226
+ response: {201: grantAdminRoleResponseSchema},
227
+ },
228
+ errorHandler: translateAdministrationError,
229
+ handler: async (request, reply) => {
230
+ const actorId = requireActorId(request);
231
+ const grant = await grantAdministratorRole({
232
+ actorId,
233
+ userId: request.body.user_id,
234
+ role: request.body.role,
235
+ reason: request.body.reason,
236
+ idempotencyKey: requireIdempotencyKey(request),
237
+ correlationId: request.id,
238
+ });
239
+ reply.code(201);
240
+ return toAdminGrantDto(grant);
241
+ },
242
+ });
243
+
244
+ const revokeRoute = defineRoute({
245
+ method: 'DELETE',
246
+ path: '/:grantId',
247
+ description: 'Revoke a local administrator grant.',
248
+ schema: {
249
+ params: z.object({grantId: z.string().uuid()}),
250
+ body: revokeAdminGrantBodySchema,
251
+ response: {200: revokeAdminGrantResponseSchema},
252
+ },
253
+ errorHandler: translateAdministrationError,
254
+ handler: async (request) => {
255
+ const grant = await revokeAdministratorGrant({
256
+ actorId: requireActorId(request),
257
+ grantId: request.params.grantId,
258
+ reason: request.body.reason,
259
+ idempotencyKey: requireIdempotencyKey(request),
260
+ correlationId: request.id,
261
+ });
262
+ return toAdminGrantDto(grant);
263
+ },
264
+ });
265
+
266
+ export const administrationRoutes: RouteGroup = {
267
+ prefix: '/admin/auth/admin-grants',
268
+ auth: AUTH_USER,
269
+ routes: [bootstrapRoute, listRoute, grantRoute, revokeRoute],
270
+ };
271
+
272
+ export const administrationUserRoutes: RouteGroup = {
273
+ prefix: '/admin/auth/users',
274
+ auth: AUTH_USER,
275
+ routes: [userLookupRoute],
276
+ };
@@ -8,6 +8,7 @@ const workspaces = {
8
8
  preflightInvitationAcceptance: vi.fn(),
9
9
  acceptInvitation: vi.fn(),
10
10
  requireActiveMembership: vi.fn(),
11
+ getWorkspaceOperatingState: vi.fn(),
11
12
  };
12
13
 
13
14
  const disabledPasswordPaths = [
@@ -8,7 +8,10 @@ import {
8
8
  checkAuthRateLimit,
9
9
  } from '#core/rate-limit.js';
10
10
 
11
- const policies: Record<AuthRateLimitAction, Record<AuthRateLimitScope, AuthRateLimitPolicy>> = {
11
+ const policies: Record<
12
+ AuthRateLimitAction,
13
+ Partial<Record<AuthRateLimitScope, AuthRateLimitPolicy>>
14
+ > = {
12
15
  login: {
13
16
  ip: {limit: 60, windowSeconds: 5 * 60},
14
17
  email: {limit: 10, windowSeconds: 15 * 60},
@@ -17,6 +20,12 @@ const policies: Record<AuthRateLimitAction, Record<AuthRateLimitScope, AuthRateL
17
20
  ip: {limit: 30, windowSeconds: 60 * 60},
18
21
  email: {limit: 3, windowSeconds: 60 * 60},
19
22
  },
23
+ bootstrap: {
24
+ ip: {limit: 5, windowSeconds: 15 * 60},
25
+ },
26
+ lookup: {
27
+ ip: {limit: 60, windowSeconds: 5 * 60},
28
+ },
20
29
  };
21
30
 
22
31
  interface EmailBody {
@@ -34,12 +43,15 @@ async function enforceRateLimit(params: {
34
43
  scope: AuthRateLimitScope;
35
44
  identifier: string;
36
45
  }): Promise<void> {
46
+ const policy = policies[params.action][params.scope];
47
+ if (!policy) return;
48
+
37
49
  try {
38
50
  await checkAuthRateLimit({
39
51
  action: params.action,
40
52
  scope: params.scope,
41
53
  identifier: params.identifier,
42
- ...policies[params.action][params.scope],
54
+ ...policy,
43
55
  });
44
56
  } catch (error) {
45
57
  if (error instanceof AuthRateLimitExceededError) {
@@ -117,3 +129,15 @@ export function createAuthRateLimitPreHandler(action: AuthRateLimitAction) {
117
129
  });
118
130
  };
119
131
  }
132
+
133
+ export function createAuthIpRateLimitPreHandler(action: AuthRateLimitAction) {
134
+ return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
135
+ await enforceRateLimit({
136
+ request,
137
+ reply,
138
+ action,
139
+ scope: 'ip',
140
+ identifier: request.ip,
141
+ });
142
+ };
143
+ }
@@ -1,5 +1,8 @@
1
1
  import './env.js';
2
- import {initializeEmailChallengesForTests} from '@shipfox/api-email-challenges/test';
2
+ import {
3
+ initializeEmailChallengesForTests,
4
+ resetEmailChallengesForTests,
5
+ } from '@shipfox/api-email-challenges/test';
3
6
  import {runMigrations} from '@shipfox/node-drizzle';
4
7
  import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
5
8
  import {sql} from 'drizzle-orm';
@@ -11,8 +14,9 @@ export async function setup() {
11
14
 
12
15
  await runMigrations(db(), migrationsPath, '__drizzle_migrations_auth');
13
16
  await initializeEmailChallengesForTests();
17
+ await resetEmailChallengesForTests();
14
18
  await db().execute(
15
- sql`TRUNCATE auth_admin_grants, auth_outbox, auth_password_resets, auth_rate_limits, auth_refresh_tokens, auth_users CASCADE`,
19
+ sql`TRUNCATE auth_admin_command_results, auth_admin_grants, auth_outbox, auth_password_resets, auth_rate_limits, auth_refresh_tokens, auth_users CASCADE`,
16
20
  );
17
21
 
18
22
  closeDb();
package/test/routes.ts CHANGED
@@ -8,6 +8,10 @@ import {and, desc, eq, sql} from 'drizzle-orm';
8
8
  import {db} from '#db/db.js';
9
9
  import {authOutbox} from '#db/schema/outbox.js';
10
10
  import {createJwtAuthMethod} from '#presentation/auth/jwt-auth.js';
11
+ import {
12
+ administrationRoutes,
13
+ administrationUserRoutes,
14
+ } from '#presentation/routes/administration.js';
11
15
  import {buildAuthRoutes} from '#presentation/routes/index.js';
12
16
 
13
17
  const testConfig = vi.hoisted(
@@ -48,11 +52,16 @@ const workspaces = workspaceTestDoubles as unknown as WorkspacesInterModuleClien
48
52
 
49
53
  vi.mock('#config.js', () => ({
50
54
  config: {
55
+ ADMIN_BOOTSTRAP_TOKEN: 'test-bootstrap-token',
51
56
  AUTH_JWT_EXPIRES_IN: '15m',
52
57
  AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
53
58
  AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
54
59
  AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
55
60
  AUTH_PASSWORD_ENABLED: true,
61
+ AUTH_SIGNUP_GATE_ENABLED: false,
62
+ AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: '',
63
+ AUTH_SIGNUP_ALLOWED_EMAILS: '',
64
+ AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
56
65
  CLIENT_BASE_URL: testConfig.clientBaseUrl,
57
66
  },
58
67
  mailer: testConfig.mailer,
@@ -147,7 +156,7 @@ export async function createAuthTestApp(params?: {
147
156
  }): Promise<FastifyInstance> {
148
157
  const appConfig: AppConfig = {
149
158
  auth: [createJwtAuthMethod()],
150
- routes: [buildAuthRoutes(true, workspaces)],
159
+ routes: [buildAuthRoutes(true, workspaces), administrationRoutes, administrationUserRoutes],
151
160
  swagger: false,
152
161
  };
153
162
  if (params?.fastifyOptions) appConfig.fastifyOptions = params.fastifyOptions;