@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +42 -11
- package/dist/core/administration.d.ts +17 -1
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +107 -14
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +27 -10
- package/dist/core/auth.js.map +1 -1
- package/dist/core/jwt.d.ts +10 -0
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js +3 -2
- package/dist/core/jwt.js.map +1 -1
- package/dist/db/admin-command.d.ts +18 -0
- package/dist/db/admin-command.d.ts.map +1 -0
- package/dist/db/admin-command.js +37 -0
- package/dist/db/admin-command.js.map +1 -0
- package/dist/db/admin-grants.d.ts +1 -1
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +12 -30
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts +29 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -0
- package/dist/db/admin-user-moderation.js +156 -0
- package/dist/db/admin-user-moderation.js.map +1 -0
- package/dist/db/admin-user-summary.d.ts +14 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -0
- package/dist/db/admin-user-summary.js +31 -0
- package/dist/db/admin-user-summary.js.map +1 -0
- package/dist/db/admin-users.d.ts +2 -11
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +2 -27
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/refresh-tokens.d.ts +6 -4
- package/dist/db/refresh-tokens.d.ts.map +1 -1
- package/dist/db/refresh-tokens.js +31 -6
- package/dist/db/refresh-tokens.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +19 -2
- package/dist/db/schema/admin-command-results.d.ts.map +1 -1
- package/dist/db/schema/admin-command-results.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.js +4 -3
- package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts +1 -4
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +23 -19
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +94 -3
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/core/administration.ts +137 -15
- package/src/core/auth.test.ts +29 -10
- package/src/core/auth.ts +36 -14
- package/src/core/jwt.test.ts +20 -4
- package/src/core/jwt.ts +2 -1
- package/src/db/admin-command.ts +85 -0
- package/src/db/admin-grants.ts +19 -57
- package/src/db/admin-user-moderation.ts +230 -0
- package/src/db/admin-user-summary.ts +51 -0
- package/src/db/admin-users.ts +4 -49
- package/src/db/refresh-tokens.test.ts +0 -19
- package/src/db/refresh-tokens.ts +54 -23
- package/src/db/schema/admin-command-results.ts +23 -2
- package/src/index.ts +3 -0
- package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
- package/src/presentation/auth/bearer-token-auth.ts +9 -2
- package/src/presentation/auth/jwt-auth.test.ts +34 -9
- package/src/presentation/auth/jwt-auth.ts +19 -13
- package/src/presentation/routes/administration.test.ts +253 -2
- package/src/presentation/routes/administration.ts +88 -1
- package/src/presentation/routes/password/change-password.test.ts +6 -0
- package/src/presentation/routes/password/password-reset-confirm.test.ts +14 -0
- package/src/presentation/routes/registration/signup.test.ts +2 -2
- package/src/presentation/routes/session/logout.test.ts +6 -0
- package/src/presentation/routes/session/refresh.test.ts +11 -0
- 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": "
|
|
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": "
|
|
31
|
-
"@shipfox/api-auth-dto": "10.
|
|
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": "
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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(
|
|
47
|
-
const expectedHash = Buffer.from(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
package/src/core/auth.test.ts
CHANGED
|
@@ -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({
|
|
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(
|
|
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([
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
98
|
+
type TokenMemberships = TokenMembership[];
|
|
99
|
+
|
|
100
|
+
async function loadTokenMemberships(
|
|
101
|
+
userId: string,
|
|
100
102
|
workspaces: WorkspacesInterModuleClient,
|
|
101
|
-
|
|
102
|
-
): Promise<string> {
|
|
103
|
+
): Promise<TokenMemberships> {
|
|
103
104
|
const memberships = await workspaces
|
|
104
|
-
.listMembershipsForTokenClaims({userId
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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,
|
|
532
|
+
const token = await signAccessToken(user, memberships, current.sessionId);
|
|
511
533
|
recordRefreshOutcome('rotated');
|
|
512
534
|
return {token, refreshToken: nextRefreshToken, user, adminRole};
|
|
513
535
|
}
|
package/src/core/jwt.test.ts
CHANGED
|
@@ -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
|
+
}
|