@shipfox/api-auth 10.2.0 → 12.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 (58) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +48 -32
  4. package/dist/core/auth.d.ts.map +1 -1
  5. package/dist/core/auth.js +16 -8
  6. package/dist/core/auth.js.map +1 -1
  7. package/dist/core/job-lease-token.d.ts +1 -1
  8. package/dist/core/job-lease-token.d.ts.map +1 -1
  9. package/dist/core/job-lease-token.js +1 -1
  10. package/dist/core/job-lease-token.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/core/runner-session-token.d.ts +1 -1
  16. package/dist/core/runner-session-token.d.ts.map +1 -1
  17. package/dist/core/runner-session-token.js +1 -1
  18. package/dist/core/runner-session-token.js.map +1 -1
  19. package/dist/db/rate-limits.d.ts +6 -15
  20. package/dist/db/rate-limits.d.ts.map +1 -1
  21. package/dist/db/rate-limits.js +17 -18
  22. package/dist/db/rate-limits.js.map +1 -1
  23. package/dist/db/refresh-tokens.d.ts +3 -7
  24. package/dist/db/refresh-tokens.d.ts.map +1 -1
  25. package/dist/db/refresh-tokens.js +3 -9
  26. package/dist/db/refresh-tokens.js.map +1 -1
  27. package/dist/presentation/auth/jwt-auth.d.ts +1 -4
  28. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  29. package/dist/presentation/auth/jwt-auth.js +5 -29
  30. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  31. package/dist/presentation/auth/lease-token-auth.d.ts +1 -1
  32. package/dist/presentation/auth/lease-token-auth.js +1 -1
  33. package/dist/presentation/auth/lease-token-auth.js.map +1 -1
  34. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  35. package/dist/presentation/routes/rate-limit.js +32 -53
  36. package/dist/presentation/routes/rate-limit.js.map +1 -1
  37. package/dist/tsconfig.test.tsbuildinfo +1 -1
  38. package/package.json +13 -13
  39. package/src/core/auth.test.ts +29 -10
  40. package/src/core/auth.ts +27 -12
  41. package/src/core/job-lease-token.ts +1 -1
  42. package/src/core/jwt.test.ts +20 -4
  43. package/src/core/jwt.ts +2 -1
  44. package/src/core/runner-session-token.ts +1 -1
  45. package/src/db/rate-limits.ts +28 -42
  46. package/src/db/refresh-tokens.test.ts +0 -19
  47. package/src/db/refresh-tokens.ts +3 -26
  48. package/src/presentation/auth/jwt-auth.test.ts +33 -8
  49. package/src/presentation/auth/jwt-auth.ts +3 -26
  50. package/src/presentation/auth/lease-token-auth.ts +1 -1
  51. package/src/presentation/routes/administration.test.ts +9 -1
  52. package/src/presentation/routes/password/change-password.test.ts +6 -0
  53. package/src/presentation/routes/password/password-reset-confirm.test.ts +14 -0
  54. package/src/presentation/routes/rate-limit.ts +31 -64
  55. package/src/presentation/routes/registration/signup.test.ts +2 -2
  56. package/src/presentation/routes/session/logout.test.ts +6 -0
  57. package/src/presentation/routes/session/refresh.test.ts +11 -0
  58. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "10.2.0",
4
+ "version": "12.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,24 +26,24 @@
26
26
  "@node-rs/argon2": "^2.0.2",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-common-dto": "9.2.0",
30
- "@shipfox/api-auth-context": "10.2.0",
31
- "@shipfox/api-auth-dto": "10.2.0",
32
- "@shipfox/api-email-challenges": "1.1.6",
33
- "@shipfox/api-workspaces-dto": "10.2.0",
34
- "@shipfox/inter-module": "0.2.2",
29
+ "@shipfox/api-common-dto": "12.0.0",
30
+ "@shipfox/api-auth-context": "12.0.0",
31
+ "@shipfox/api-auth-dto": "12.0.0",
32
+ "@shipfox/api-email-challenges": "1.1.7",
33
+ "@shipfox/api-workspaces-dto": "12.0.0",
34
+ "@shipfox/inter-module": "0.2.3",
35
35
  "@shipfox/config": "1.2.4",
36
- "@shipfox/node-drizzle": "0.3.4",
36
+ "@shipfox/node-drizzle": "0.3.5",
37
37
  "@shipfox/node-auth-root-key": "0.2.3",
38
- "@shipfox/node-fastify": "0.4.0",
39
- "@shipfox/node-jwt": "0.3.2",
38
+ "@shipfox/node-fastify": "0.4.1",
39
+ "@shipfox/node-jwt": "0.4.0",
40
40
  "@shipfox/node-email": "0.3.4",
41
41
  "@shipfox/node-mailer": "0.2.4",
42
- "@shipfox/node-module": "1.0.4",
42
+ "@shipfox/node-module": "1.0.5",
43
43
  "@shipfox/node-opentelemetry": "0.6.3",
44
44
  "@shipfox/node-outbox": "0.2.6",
45
- "@shipfox/node-postgres": "0.4.4",
46
- "@shipfox/node-rate-limit": "0.3.2",
45
+ "@shipfox/node-postgres": "0.5.0",
46
+ "@shipfox/node-rate-limit": "0.4.0",
47
47
  "@shipfox/node-tokens": "0.3.2"
48
48
  },
49
49
  "imports": {
@@ -73,7 +73,13 @@ vi.mock('#config.js', () => ({
73
73
  vi.mock('@shipfox/node-mailer', () => ({mailer: testConfig.mailer}));
74
74
 
75
75
  const listMembershipsByUserMock = vi.fn(() =>
76
- Promise.resolve({memberships: [] as Array<{workspaceId: string; role: 'admin'}>}),
76
+ Promise.resolve({
77
+ memberships: [] as Array<{
78
+ workspaceId: string;
79
+ role: 'admin';
80
+ workspaceStatus: 'active' | 'suspended' | 'deleted';
81
+ }>,
82
+ }),
77
83
  );
78
84
  const workspaces = {
79
85
  listMembershipsForTokenClaims: listMembershipsByUserMock,
@@ -492,7 +498,6 @@ describe('auth core', () => {
492
498
  test('refreshAccessToken rejects a lost rotation claim when the token was revoked', async () => {
493
499
  const user = await userFactory.create({emailVerifiedAt: new Date()});
494
500
  const loginResult = await login({email: user.email, password: user.plainPassword});
495
- listMembershipsByUserMock.mockClear();
496
501
  vi.spyOn(refreshTokenDb, 'rotateRefreshToken').mockImplementationOnce(async () => {
497
502
  await logout({refreshToken: loginResult.refreshToken});
498
503
  return undefined;
@@ -501,7 +506,9 @@ describe('auth core', () => {
501
506
  const raced = refreshAccessToken({refreshToken: loginResult.refreshToken});
502
507
 
503
508
  await expect(raced).rejects.toBeInstanceOf(TokenInvalidError);
504
- expect(listMembershipsByUserMock).not.toHaveBeenCalled();
509
+ await expect(
510
+ verifyUserToken({token: loginResult.token, secret: userAccessTokenKey()}),
511
+ ).resolves.toMatchObject({sub: user.id});
505
512
  });
506
513
 
507
514
  test('refreshAccessToken rejects reuse past the grace window and revokes the session', async () => {
@@ -524,6 +531,9 @@ describe('auth core', () => {
524
531
  })
525
532
  : undefined;
526
533
  expect(successor).toBeUndefined();
534
+ await expect(
535
+ verifyUserToken({token: refreshed.token, secret: userAccessTokenKey()}),
536
+ ).resolves.toMatchObject({sub: user.id});
527
537
  });
528
538
 
529
539
  test('refreshAccessToken rejects refresh tokens for inactive users', async () => {
@@ -597,6 +607,9 @@ describe('auth core', () => {
597
607
 
598
608
  expect(currentRefresh.user.id).toBe(user.id);
599
609
  await expect(otherRefresh).rejects.toBeInstanceOf(TokenInvalidError);
610
+ await expect(
611
+ verifyUserToken({token: loginResult.token, secret: userAccessTokenKey()}),
612
+ ).resolves.toMatchObject({sub: user.id});
600
613
  });
601
614
 
602
615
  test('changePassword rejects unknown users and invalid current passwords', async () => {
@@ -696,8 +709,8 @@ describe('auth core', () => {
696
709
  const workspaceB = crypto.randomUUID();
697
710
  listMembershipsByUserMock.mockResolvedValueOnce({
698
711
  memberships: [
699
- {workspaceId: workspaceA, role: 'admin'},
700
- {workspaceId: workspaceB, role: 'admin'},
712
+ {workspaceId: workspaceA, role: 'admin', workspaceStatus: 'active'},
713
+ {workspaceId: workspaceB, role: 'admin', workspaceStatus: 'suspended'},
701
714
  ],
702
715
  });
703
716
 
@@ -706,8 +719,8 @@ describe('auth core', () => {
706
719
  const claims = await verifyUserToken({token: result.token, secret: userAccessTokenKey()});
707
720
  expect(claims.name).toBe(user.name);
708
721
  expect(claims.memberships).toEqual([
709
- {workspaceId: workspaceA, role: 'admin'},
710
- {workspaceId: workspaceB, role: 'admin'},
722
+ {workspaceId: workspaceA, role: 'admin', workspaceStatus: 'active'},
723
+ {workspaceId: workspaceB, role: 'admin', workspaceStatus: 'suspended'},
711
724
  ]);
712
725
  });
713
726
 
@@ -718,7 +731,7 @@ describe('auth core', () => {
718
731
 
719
732
  const newWorkspaceId = crypto.randomUUID();
720
733
  listMembershipsByUserMock.mockResolvedValueOnce({
721
- memberships: [{workspaceId: newWorkspaceId, role: 'admin'}],
734
+ memberships: [{workspaceId: newWorkspaceId, role: 'admin', workspaceStatus: 'active'}],
722
735
  });
723
736
  const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
724
737
 
@@ -726,7 +739,9 @@ describe('auth core', () => {
726
739
  token: refreshed.token,
727
740
  secret: userAccessTokenKey(),
728
741
  });
729
- expect(refreshedClaims.memberships).toEqual([{workspaceId: newWorkspaceId, role: 'admin'}]);
742
+ expect(refreshedClaims.memberships).toEqual([
743
+ {workspaceId: newWorkspaceId, role: 'admin', workspaceStatus: 'active'},
744
+ ]);
730
745
  });
731
746
 
732
747
  test('login fails closed when listMembershipsByUser throws', async () => {
@@ -738,7 +753,7 @@ describe('auth core', () => {
738
753
  await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
739
754
  });
740
755
 
741
- test('refresh fails closed when listMembershipsByUser throws', async () => {
756
+ test('does not rotate a refresh token when listMembershipsByUser throws', async () => {
742
757
  const user = await userFactory.create({emailVerifiedAt: new Date()});
743
758
  listMembershipsByUserMock.mockResolvedValueOnce({memberships: []});
744
759
  const loginResult = await login({email: user.email, password: user.plainPassword});
@@ -747,5 +762,9 @@ describe('auth core', () => {
747
762
  const promise = refreshAccessToken({refreshToken: loginResult.refreshToken});
748
763
 
749
764
  await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
765
+
766
+ const retry = await refreshAccessToken({refreshToken: loginResult.refreshToken});
767
+ expect(retry.token).toEqual(expect.any(String));
768
+ expect(retry.refreshToken).toEqual(expect.any(String));
750
769
  });
751
770
  });
package/src/core/auth.ts CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  TokenInvalidError,
47
47
  UserNotFoundError,
48
48
  } from './errors.js';
49
- import {signUserToken} from './jwt.js';
49
+ import {signUserToken, type TokenMembership} from './jwt.js';
50
50
  import {hashPassword, verifyPassword} from './password.js';
51
51
  import type {SignupPolicy} from './ports.js';
52
52
  import {createEnvironmentSignupPolicy} from './signup-policy.js';
@@ -95,21 +95,30 @@ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
95
95
  recordTokenRefreshed(outcome);
96
96
  }
97
97
 
98
- async function signAccessToken(
99
- user: User,
98
+ type TokenMemberships = TokenMembership[];
99
+
100
+ async function loadTokenMemberships(
101
+ userId: string,
100
102
  workspaces: WorkspacesInterModuleClient,
101
- refreshSessionId: string,
102
- ): Promise<string> {
103
+ ): Promise<TokenMemberships> {
103
104
  const memberships = await workspaces
104
- .listMembershipsForTokenClaims({userId: user.id})
105
+ .listMembershipsForTokenClaims({userId})
105
106
  .catch((error: unknown) => {
106
107
  throw new AuthDependencyUnavailableError('workspaces', error);
107
108
  });
109
+ return memberships.memberships;
110
+ }
111
+
112
+ async function signAccessToken(
113
+ user: User,
114
+ memberships: TokenMemberships,
115
+ refreshSessionId: string,
116
+ ): Promise<string> {
108
117
  return await signUserToken({
109
118
  userId: user.id,
110
119
  email: user.email,
111
120
  name: user.name,
112
- memberships: memberships.memberships,
121
+ memberships,
113
122
  refreshSessionId,
114
123
  secret: userAccessTokenKey(),
115
124
  expiresIn: config.AUTH_JWT_EXPIRES_IN,
@@ -142,7 +151,8 @@ async function createSessionTokens(
142
151
  workspaces: WorkspacesInterModuleClient,
143
152
  ): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
144
153
  const refreshSessionId = crypto.randomUUID();
145
- const token = await signAccessToken(user, workspaces, refreshSessionId);
154
+ const memberships = await loadTokenMemberships(user.id, workspaces);
155
+ const token = await signAccessToken(user, memberships, refreshSessionId);
146
156
  const adminRole = await getCurrentAdminRole({userId: user.id});
147
157
  const {refreshToken} = await createRefreshSession(user, refreshSessionId);
148
158
  return {token, refreshToken, adminRole};
@@ -335,7 +345,7 @@ export async function signupWithInvitation(
335
345
  }
336
346
  }
337
347
 
338
- // Step 4: Issue session. signAccessToken reads memberships through the
348
+ // Step 4: Issue session. createSessionTokens reads memberships through the
339
349
  // workspaces module API, so a successful accept is reflected in the JWT.
340
350
  const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
341
351
 
@@ -479,7 +489,8 @@ export async function refreshAccessToken(params: {
479
489
  // second tab); past it, reuse of a retired token means a compromised session.
480
490
  if (current.rotatedAt) {
481
491
  if (isWithinRotationGrace(current)) {
482
- const token = await signAccessToken(user, params.workspaces, current.sessionId);
492
+ const memberships = await loadTokenMemberships(user.id, params.workspaces);
493
+ const token = await signAccessToken(user, memberships, current.sessionId);
483
494
  recordRefreshOutcome('grace');
484
495
  return {token, refreshToken: undefined, user, adminRole};
485
496
  }
@@ -488,6 +499,10 @@ export async function refreshAccessToken(params: {
488
499
  throw new TokenInvalidError('Refresh token reused after rotation');
489
500
  }
490
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
+
491
506
  // Losing the CAS requires a state check: another request may have rotated,
492
507
  // revoked, or expired the token before this refresh could claim it.
493
508
  const nextRefreshToken = generateOpaqueToken('refreshToken');
@@ -509,12 +524,12 @@ export async function refreshAccessToken(params: {
509
524
  recordRefreshOutcome('rejected');
510
525
  throw new TokenInvalidError('Refresh token is invalid or expired');
511
526
  }
512
- const token = await signAccessToken(user, params.workspaces, latest.sessionId);
527
+ const token = await signAccessToken(user, memberships, latest.sessionId);
513
528
  recordRefreshOutcome('grace');
514
529
  return {token, refreshToken: undefined, user, adminRole};
515
530
  }
516
531
 
517
- const token = await signAccessToken(user, params.workspaces, current.sessionId);
532
+ const token = await signAccessToken(user, memberships, current.sessionId);
518
533
  recordRefreshOutcome('rotated');
519
534
  return {token, refreshToken: nextRefreshToken, user, adminRole};
520
535
  }
@@ -66,7 +66,7 @@ export async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Prom
66
66
  return token;
67
67
  }
68
68
 
69
- /** Returns the claims on success, or `null` for any invalid input never throws. */
69
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */
70
70
  export async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null> {
71
71
  try {
72
72
  const claims = await verifyHs256({
@@ -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>;
@@ -30,7 +30,7 @@ export async function issueRunnerSessionToken(
30
30
  return token;
31
31
  }
32
32
 
33
- /** Returns the claims on success, or `null` for any invalid input never throws. */
33
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */
34
34
  export async function verifyRunnerSessionToken(
35
35
  token: string,
36
36
  ): Promise<RunnerSessionTokenClaims | null> {
@@ -1,28 +1,23 @@
1
+ import {
2
+ type ConsumeRateLimitParams,
3
+ type ConsumeRateLimitResult,
4
+ createRateLimitPersistence,
5
+ } from '@shipfox/node-rate-limit';
1
6
  import {lt, sql} from 'drizzle-orm';
2
7
  import {db} from './db.js';
3
8
  import {authRateLimits} from './schema/rate-limits.js';
4
9
 
5
- export interface ConsumeAuthRateLimitParams {
6
- action: string;
7
- scope: string;
8
- identifierHmac: string;
9
- windowStart: Date;
10
- expiresAt: Date;
11
- timeoutMs: number;
12
- }
13
-
14
- export interface ConsumeAuthRateLimitResult {
15
- count: number;
16
- expiresAt: Date;
17
- }
18
-
19
- export async function consumeAuthRateLimit(
20
- params: ConsumeAuthRateLimitParams,
21
- ): Promise<ConsumeAuthRateLimitResult> {
22
- return await db().transaction(async (tx) => {
23
- await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
24
-
25
- const rows = await tx
10
+ type AuthRateLimitTransaction = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
11
+
12
+ const persistence = createRateLimitPersistence<AuthRateLimitTransaction>({
13
+ transaction: (callback) => db().transaction(callback),
14
+ setStatementTimeout: async (transaction, timeoutMs) => {
15
+ await transaction.execute(
16
+ sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,
17
+ );
18
+ },
19
+ consume: async (transaction, params) => {
20
+ const rows = await transaction
26
21
  .insert(authRateLimits)
27
22
  .values({
28
23
  action: params.action,
@@ -46,24 +41,15 @@ export async function consumeAuthRateLimit(
46
41
  })
47
42
  .returning({count: authRateLimits.count, expiresAt: authRateLimits.expiresAt});
48
43
 
49
- const row = rows[0];
50
- if (!row) throw new Error('Rate limit upsert returned no rows');
51
- return row;
52
- });
53
- }
54
-
55
- let nextPruneAt = 0;
56
-
57
- export async function pruneExpiredAuthRateLimits(
58
- params: {now?: Date | undefined; minIntervalMs?: number | undefined} = {},
59
- ): Promise<number | undefined> {
60
- const now = params.now ?? new Date();
61
- const minIntervalMs = params.minIntervalMs ?? 60_000;
62
- if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
63
-
64
- nextPruneAt = now.getTime() + minIntervalMs;
65
-
66
- const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
67
-
68
- return result.rowCount ?? 0;
69
- }
44
+ return rows[0];
45
+ },
46
+ prune: async (now) => {
47
+ const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
48
+ return result.rowCount ?? 0;
49
+ },
50
+ });
51
+
52
+ export type ConsumeAuthRateLimitParams = ConsumeRateLimitParams<string, string>;
53
+ export type ConsumeAuthRateLimitResult = ConsumeRateLimitResult;
54
+ export const consumeAuthRateLimit = persistence.consume;
55
+ export const pruneExpiredAuthRateLimits = persistence.prune;
@@ -1,7 +1,6 @@
1
1
  import {hashOpaqueToken} from '@shipfox/node-tokens';
2
2
  import {
3
3
  createRefreshToken,
4
- findActiveRefreshSession,
5
4
  findActiveRefreshTokenByHash,
6
5
  findRefreshTokenByHash,
7
6
  revokeRefreshSession,
@@ -45,24 +44,6 @@ describe('refresh-tokens db', () => {
45
44
  expect(found).toBeUndefined();
46
45
  });
47
46
 
48
- test('finds only the active session owned by the requested user', async () => {
49
- const user = await createUser({email: emailFor('rt-session'), hashedPassword: 'h'});
50
- const otherUser = await createUser({email: emailFor('rt-session-other'), hashedPassword: 'h'});
51
- const sessionId = crypto.randomUUID();
52
- await createRefreshToken({
53
- userId: user.id,
54
- sessionId,
55
- hashedToken: hashOpaqueToken(`active-${crypto.randomUUID()}`),
56
- expiresAt: new Date(Date.now() + 60_000),
57
- });
58
-
59
- const found = await findActiveRefreshSession({sessionId, userId: user.id});
60
- const otherUserFound = await findActiveRefreshSession({sessionId, userId: otherUser.id});
61
-
62
- expect(found?.sessionId).toBe(sessionId);
63
- expect(otherUserFound).toBeUndefined();
64
- });
65
-
66
47
  test('rotates a token once, creates the successor, and only the first caller wins', async () => {
67
48
  const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
68
49
  const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
@@ -61,7 +61,7 @@ export async function createRefreshTokenForActiveUser(
61
61
  }
62
62
 
63
63
  /**
64
- * Looks up the live session token by hash one that can still authenticate as
64
+ * Looks up the live session token by hash. It can still authenticate as
65
65
  * the current session. Returns `undefined` for revoked, expired, or already
66
66
  * rotated tokens; rotated rows survive in the table only for the grace window
67
67
  * and are not "active".
@@ -87,34 +87,11 @@ export async function findActiveRefreshTokenByHash(params: {
87
87
  return toRefreshToken(row);
88
88
  }
89
89
 
90
- export async function findActiveRefreshSession(params: {
91
- sessionId: string;
92
- userId: string;
93
- }): Promise<RefreshToken | undefined> {
94
- const rows = await db()
95
- .select()
96
- .from(refreshTokens)
97
- .where(
98
- and(
99
- eq(refreshTokens.sessionId, params.sessionId),
100
- eq(refreshTokens.userId, params.userId),
101
- isNull(refreshTokens.revokedAt),
102
- isNull(refreshTokens.rotatedAt),
103
- gt(refreshTokens.expiresAt, sql`now()`),
104
- ),
105
- )
106
- .limit(1);
107
-
108
- const row = rows[0];
109
- if (!row) return undefined;
110
- return toRefreshToken(row);
111
- }
112
-
113
90
  /**
114
91
  * Looks up a non-revoked, non-expired token by hash, including ones already
115
92
  * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
116
- * rotate, honour the grace window, or reject reuse, so unlike
117
- * {@link findActiveRefreshTokenByHash} it must still see rotated rows.
93
+ * rotate, honour the grace window, or reject reuse, so, unlike
94
+ * {@link findActiveRefreshTokenByHash}, it must still see rotated rows.
118
95
  */
119
96
  export async function findRefreshTokenByHash(params: {
120
97
  hashedToken: string;
@@ -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();
@@ -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,8 +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
- import {findUserById} from '#db/users.js';
17
15
  import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
18
16
 
19
17
  const AUTHENTICATED_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth/session');
@@ -61,10 +59,7 @@ function getAuthenticatedSessionTokenContext(request: FastifyRequest): {
61
59
  );
62
60
  }
63
61
 
64
- /**
65
- * Resolves the active refresh session backing an authenticated user request.
66
- * The session check intentionally happens only for consumers that need browser-session binding.
67
- */
62
+ /** Returns the refresh-session metadata carried by a verified access token. */
68
63
  export async function getAuthenticatedSessionContext(
69
64
  request: FastifyRequest,
70
65
  ): Promise<AuthenticatedSessionContext> {
@@ -73,13 +68,7 @@ export async function getAuthenticatedSessionContext(
73
68
  if (!client || !token || token.userId !== client.userId || !token.refreshSessionId)
74
69
  throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
75
70
 
76
- const session = await findActiveRefreshSession({
77
- sessionId: token.refreshSessionId,
78
- userId: client.userId,
79
- });
80
- if (!session) throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
81
-
82
- return {userId: client.userId, refreshSessionId: session.sessionId};
71
+ return await Promise.resolve({userId: client.userId, refreshSessionId: token.refreshSessionId});
83
72
  }
84
73
 
85
74
  export function createJwtAuthMethod(): AuthMethod {
@@ -92,19 +81,7 @@ export function createJwtAuthMethod(): AuthMethod {
92
81
  } catch {
93
82
  throw new InvalidJwtTokenError();
94
83
  }
95
- const user = await findUserById({id: claims.sub});
96
-
97
- // Legacy access tokens without a session claim remain verifiable for the
98
- // migration window; known suspended or deleted users are still rejected.
99
- if (user && user.status !== 'active') return null;
100
- if (!claims.refreshSessionId) return claims;
101
- if (!user) return null;
102
-
103
- const session = await findActiveRefreshSession({
104
- sessionId: claims.refreshSessionId,
105
- userId: claims.sub,
106
- });
107
- return session ? claims : null;
84
+ return claims;
108
85
  },
109
86
  isInvalidTokenError: (error) => error instanceof InvalidJwtTokenError,
110
87
  invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
@@ -4,7 +4,7 @@ import {verifyJobLeaseToken} from '#core/job-lease-token.js';
4
4
  import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
5
5
 
6
6
  /**
7
- * Trust boundary: the signed token is the sole authority `claims.jobId` scopes
7
+ * Trust boundary: the signed token is the sole authority. `claims.jobId` scopes
8
8
  * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`
9
9
  * are carried for consumers but are NOT verified against the database here.
10
10
  *