@shipfox/api-auth 7.0.2 → 9.0.1

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 (51) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +73 -0
  3. package/README.md +29 -8
  4. package/dist/core/auth.d.ts.map +1 -1
  5. package/dist/core/auth.js +38 -21
  6. package/dist/core/auth.js.map +1 -1
  7. package/dist/core/entities/refresh-token.d.ts +1 -0
  8. package/dist/core/entities/refresh-token.d.ts.map +1 -1
  9. package/dist/core/entities/refresh-token.js.map +1 -1
  10. package/dist/core/jwt.d.ts +2 -0
  11. package/dist/core/jwt.d.ts.map +1 -1
  12. package/dist/core/jwt.js +3 -1
  13. package/dist/core/jwt.js.map +1 -1
  14. package/dist/db/db.d.ts +34 -0
  15. package/dist/db/db.d.ts.map +1 -1
  16. package/dist/db/refresh-tokens.d.ts +9 -0
  17. package/dist/db/refresh-tokens.d.ts.map +1 -1
  18. package/dist/db/refresh-tokens.js +14 -0
  19. package/dist/db/refresh-tokens.js.map +1 -1
  20. package/dist/db/schema/refresh-tokens.d.ts +17 -0
  21. package/dist/db/schema/refresh-tokens.d.ts.map +1 -1
  22. package/dist/db/schema/refresh-tokens.js +4 -0
  23. package/dist/db/schema/refresh-tokens.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/presentation/auth/jwt-auth.d.ts +14 -1
  29. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  30. package/dist/presentation/auth/jwt-auth.js +34 -0
  31. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  32. package/dist/tsconfig.test.tsbuildinfo +1 -1
  33. package/drizzle/0000_initial.sql +2 -0
  34. package/drizzle/meta/0000_snapshot.json +29 -0
  35. package/package.json +22 -23
  36. package/src/core/auth.test.ts +42 -0
  37. package/src/core/auth.ts +36 -19
  38. package/src/core/entities/refresh-token.ts +1 -0
  39. package/src/core/jwt.ts +3 -0
  40. package/src/db/refresh-tokens.test.ts +42 -0
  41. package/src/db/refresh-tokens.ts +42 -0
  42. package/src/db/schema/refresh-tokens.ts +6 -0
  43. package/src/index.test.ts +1 -0
  44. package/src/index.ts +8 -2
  45. package/src/presentation/auth/jwt-auth.test.ts +102 -1
  46. package/src/presentation/auth/jwt-auth.ts +55 -1
  47. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  48. package/src/presentation/routes/index.test.ts +1 -0
  49. package/test/globalSetup.ts +3 -10
  50. package/test/routes.ts +1 -0
  51. package/tsconfig.build.tsbuildinfo +1 -1
@@ -35,6 +35,7 @@ CREATE TABLE "auth_rate_limits" (
35
35
  --> statement-breakpoint
36
36
  CREATE TABLE "auth_refresh_tokens" (
37
37
  "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
38
+ "session_id" uuid DEFAULT uuidv7() NOT NULL,
38
39
  "user_id" uuid NOT NULL,
39
40
  "hashed_token" text NOT NULL,
40
41
  "expires_at" timestamp with time zone NOT NULL,
@@ -65,5 +66,6 @@ CREATE INDEX "auth_password_resets_user_id_idx" ON "auth_password_resets" USING
65
66
  CREATE UNIQUE INDEX "auth_rate_limits_window_unique" ON "auth_rate_limits" USING btree ("action","scope","identifier_hmac","window_start");--> statement-breakpoint
66
67
  CREATE INDEX "auth_rate_limits_expires_at_idx" ON "auth_rate_limits" USING btree ("expires_at");--> statement-breakpoint
67
68
  CREATE UNIQUE INDEX "auth_refresh_tokens_hashed_token_unique" ON "auth_refresh_tokens" USING btree ("hashed_token");--> statement-breakpoint
69
+ CREATE UNIQUE INDEX "auth_refresh_tokens_active_session_unique" ON "auth_refresh_tokens" USING btree ("user_id","session_id") WHERE "auth_refresh_tokens"."revoked_at" IS NULL AND "auth_refresh_tokens"."rotated_at" IS NULL;--> statement-breakpoint
68
70
  CREATE INDEX "auth_refresh_tokens_user_id_idx" ON "auth_refresh_tokens" USING btree ("user_id");--> statement-breakpoint
69
71
  CREATE UNIQUE INDEX "auth_users_email_unique" ON "auth_users" USING btree ("email");
@@ -143,6 +143,13 @@
143
143
  "notNull": true,
144
144
  "default": "uuidv7()"
145
145
  },
146
+ "session_id": {
147
+ "name": "session_id",
148
+ "type": "uuid",
149
+ "primaryKey": false,
150
+ "notNull": true,
151
+ "default": "uuidv7()"
152
+ },
146
153
  "user_id": {
147
154
  "name": "user_id",
148
155
  "type": "uuid",
@@ -415,6 +422,28 @@
415
422
  "method": "btree",
416
423
  "with": {}
417
424
  },
425
+ "auth_refresh_tokens_active_session_unique": {
426
+ "name": "auth_refresh_tokens_active_session_unique",
427
+ "columns": [
428
+ {
429
+ "expression": "user_id",
430
+ "isExpression": false,
431
+ "asc": true,
432
+ "nulls": "last"
433
+ },
434
+ {
435
+ "expression": "session_id",
436
+ "isExpression": false,
437
+ "asc": true,
438
+ "nulls": "last"
439
+ }
440
+ ],
441
+ "isUnique": true,
442
+ "where": "\"auth_refresh_tokens\".\"revoked_at\" IS NULL AND \"auth_refresh_tokens\".\"rotated_at\" IS NULL",
443
+ "concurrently": false,
444
+ "method": "btree",
445
+ "with": {}
446
+ },
418
447
  "auth_refresh_tokens_user_id_idx": {
419
448
  "name": "auth_refresh_tokens_user_id_idx",
420
449
  "columns": [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "7.0.2",
4
+ "version": "9.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -11,10 +11,6 @@
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
13
13
  "types": "dist/index.d.ts",
14
- "imports": {
15
- "#test/*": "./test/*",
16
- "#*": "./dist/*"
17
- },
18
14
  "exports": {
19
15
  ".": {
20
16
  "types": "./dist/index.d.ts",
@@ -30,24 +26,27 @@
30
26
  "@node-rs/argon2": "^2.0.2",
31
27
  "drizzle-orm": "^0.45.2",
32
28
  "zod": "^4.4.3",
33
- "@shipfox/api-auth-context": "6.0.0",
34
- "@shipfox/api-auth-dto": "6.0.0",
35
- "@shipfox/api-email-challenges": "0.2.3",
36
- "@shipfox/api-workspaces-dto": "6.0.0",
37
- "@shipfox/inter-module": "0.2.0",
38
- "@shipfox/config": "1.2.2",
39
- "@shipfox/node-drizzle": "0.3.2",
40
- "@shipfox/node-auth-root-key": "0.2.1",
41
- "@shipfox/node-fastify": "0.2.4",
42
- "@shipfox/node-jwt": "0.3.0",
43
- "@shipfox/node-email": "0.3.1",
44
- "@shipfox/node-mailer": "0.2.0",
45
- "@shipfox/node-module": "0.4.0",
46
- "@shipfox/node-opentelemetry": "0.5.2",
47
- "@shipfox/node-outbox": "0.2.4",
48
- "@shipfox/node-postgres": "0.4.2",
49
- "@shipfox/node-rate-limit": "0.3.0",
50
- "@shipfox/node-tokens": "0.3.0"
29
+ "@shipfox/api-auth-context": "9.0.1",
30
+ "@shipfox/api-auth-dto": "9.0.1",
31
+ "@shipfox/api-email-challenges": "1.1.0",
32
+ "@shipfox/api-workspaces-dto": "9.0.1",
33
+ "@shipfox/inter-module": "0.2.1",
34
+ "@shipfox/config": "1.2.3",
35
+ "@shipfox/node-drizzle": "0.3.3",
36
+ "@shipfox/node-auth-root-key": "0.2.2",
37
+ "@shipfox/node-jwt": "0.3.1",
38
+ "@shipfox/node-fastify": "0.3.1",
39
+ "@shipfox/node-email": "0.3.2",
40
+ "@shipfox/node-mailer": "0.2.2",
41
+ "@shipfox/node-module": "1.0.0",
42
+ "@shipfox/node-opentelemetry": "0.6.1",
43
+ "@shipfox/node-outbox": "0.2.5",
44
+ "@shipfox/node-postgres": "0.4.3",
45
+ "@shipfox/node-rate-limit": "0.3.1",
46
+ "@shipfox/node-tokens": "0.3.1"
47
+ },
48
+ "imports": {
49
+ "#*": "./dist/*"
51
50
  },
52
51
  "scripts": {
53
52
  "build": "shipfox-swc",
@@ -73,6 +73,7 @@ const listMembershipsByUserMock = vi.fn(() =>
73
73
  );
74
74
  const workspaces = {
75
75
  listMembershipsForTokenClaims: listMembershipsByUserMock,
76
+ getWorkspaceCreator: vi.fn(),
76
77
  preflightInvitationAcceptance: vi.fn(),
77
78
  acceptInvitation: vi.fn(),
78
79
  requireActiveMembership: vi.fn(),
@@ -334,6 +335,32 @@ describe('auth core', () => {
334
335
  expect(refreshed.user.id).toBe(user.id);
335
336
  });
336
337
 
338
+ test('keeps the refresh-session identity stable across a token refresh', async () => {
339
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
340
+ const initial = await login({email: user.email, password: user.plainPassword});
341
+ const refreshed = await refreshAccessToken({refreshToken: initial.refreshToken});
342
+ const initialClaims = await verifyUserToken({
343
+ token: initial.token,
344
+ secret: userAccessTokenKey(),
345
+ });
346
+ const refreshedClaims = await verifyUserToken({
347
+ token: refreshed.token,
348
+ secret: userAccessTokenKey(),
349
+ });
350
+
351
+ expect(refreshedClaims.refreshSessionId).toBe(initialClaims.refreshSessionId);
352
+ });
353
+
354
+ test('issues different refresh-session identities for separate sessions of one user', async () => {
355
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
356
+ const first = await login({email: user.email, password: user.plainPassword});
357
+ const second = await login({email: user.email, password: user.plainPassword});
358
+ const firstClaims = await verifyUserToken({token: first.token, secret: userAccessTokenKey()});
359
+ const secondClaims = await verifyUserToken({token: second.token, secret: userAccessTokenKey()});
360
+
361
+ expect(secondClaims.refreshSessionId).not.toBe(firstClaims.refreshSessionId);
362
+ });
363
+
337
364
  test('refreshAccessToken tolerates a concurrent reuse within the grace window', async () => {
338
365
  const user = await userFactory.create({emailVerifiedAt: new Date()});
339
366
  const loginResult = await login({email: user.email, password: user.plainPassword});
@@ -487,6 +514,21 @@ describe('auth core', () => {
487
514
  expect(active).toBeUndefined();
488
515
  });
489
516
 
517
+ test('logout revokes the active successor when presented a rotated refresh token', async () => {
518
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
519
+ const loginResult = await login({email: user.email, password: user.plainPassword});
520
+ const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
521
+
522
+ await logout({refreshToken: loginResult.refreshToken});
523
+
524
+ const successor = refreshed.refreshToken
525
+ ? await refreshTokenDb.findActiveRefreshTokenByHash({
526
+ hashedToken: hashOpaqueToken(refreshed.refreshToken),
527
+ })
528
+ : undefined;
529
+ expect(successor).toBeUndefined();
530
+ });
531
+
490
532
  test('getCurrentUser returns the user', async () => {
491
533
  const user = await userFactory.create({emailVerifiedAt: new Date()});
492
534
 
package/src/core/auth.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  createRefreshToken,
19
19
  findActiveRefreshTokenByHash,
20
20
  findRefreshTokenByHash,
21
- revokeRefreshTokenByHash,
21
+ revokeRefreshSession,
22
22
  revokeRefreshTokensForUser,
23
23
  rotateRefreshToken,
24
24
  } from '#db/refresh-tokens.js';
@@ -73,6 +73,7 @@ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
73
73
  async function signAccessToken(
74
74
  user: User,
75
75
  workspaces: WorkspacesInterModuleClient,
76
+ refreshSessionId: string,
76
77
  ): Promise<string> {
77
78
  const memberships = await workspaces
78
79
  .listMembershipsForTokenClaims({userId: user.id})
@@ -84,6 +85,7 @@ async function signAccessToken(
84
85
  email: user.email,
85
86
  name: user.name,
86
87
  memberships: memberships.memberships,
88
+ refreshSessionId,
87
89
  secret: userAccessTokenKey(),
88
90
  expiresIn: config.AUTH_JWT_EXPIRES_IN,
89
91
  });
@@ -95,14 +97,28 @@ function isWithinRotationGrace(refreshToken: RefreshToken): boolean {
95
97
  return Date.now() - refreshToken.rotatedAt.getTime() <= graceMs;
96
98
  }
97
99
 
98
- async function createRefreshSession(user: User): Promise<string> {
100
+ async function createRefreshSession(
101
+ user: User,
102
+ refreshSessionId: string,
103
+ ): Promise<{refreshToken: string; refreshSessionId: string}> {
99
104
  const refreshToken = generateOpaqueToken('refreshToken');
100
- await createRefreshToken({
105
+ const session = await createRefreshToken({
106
+ sessionId: refreshSessionId,
101
107
  userId: user.id,
102
108
  hashedToken: hashOpaqueToken(refreshToken),
103
109
  expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
104
110
  });
105
- return refreshToken;
111
+ return {refreshToken, refreshSessionId: session.sessionId};
112
+ }
113
+
114
+ async function createSessionTokens(
115
+ user: User,
116
+ workspaces: WorkspacesInterModuleClient,
117
+ ): Promise<{token: string; refreshToken: string}> {
118
+ const refreshSessionId = crypto.randomUUID();
119
+ const token = await signAccessToken(user, workspaces, refreshSessionId);
120
+ const {refreshToken} = await createRefreshSession(user, refreshSessionId);
121
+ return {token, refreshToken};
106
122
  }
107
123
 
108
124
  function passwordResetLink(rawToken: string): string {
@@ -175,6 +191,7 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
175
191
  email: existing.email,
176
192
  purpose: PASSWORD_VERIFICATION_PURPOSE,
177
193
  continuation: existing.id,
194
+ idempotencyKey: existing.id,
178
195
  sourceIp: params.sourceIp ?? '0.0.0.0',
179
196
  });
180
197
  return {...existing, emailChallenge};
@@ -194,6 +211,7 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
194
211
  email: user.email,
195
212
  purpose: PASSWORD_VERIFICATION_PURPOSE,
196
213
  continuation: user.id,
214
+ idempotencyKey: user.id,
197
215
  sourceIp: params.sourceIp ?? '0.0.0.0',
198
216
  });
199
217
 
@@ -271,8 +289,7 @@ export async function signupWithInvitation(
271
289
 
272
290
  // Step 4: Issue session. signAccessToken reads memberships through the
273
291
  // workspaces module API, so a successful accept is reflected in the JWT.
274
- const token = await signAccessToken(user, params.workspaces);
275
- const refreshToken = await createRefreshSession(user);
292
+ const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
276
293
 
277
294
  if (acceptError) {
278
295
  return {token, refreshToken, user, membership, acceptError};
@@ -334,8 +351,7 @@ export async function login(params: LoginParams): Promise<LoginResult> {
334
351
  throw new EmailNotVerifiedError();
335
352
  }
336
353
 
337
- const token = await signAccessToken(user, params.workspaces);
338
- const refreshToken = await createRefreshSession(user);
354
+ const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
339
355
 
340
356
  return {token, refreshToken, user};
341
357
  }
@@ -377,8 +393,7 @@ export async function createSessionForUser(
377
393
  throw new InvalidCredentialsError();
378
394
  }
379
395
 
380
- const token = await signAccessToken(user, params.workspaces);
381
- const refreshToken = await createRefreshSession(user);
396
+ const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
382
397
 
383
398
  return {token, refreshToken, user};
384
399
  }
@@ -403,7 +418,7 @@ export async function refreshAccessToken(params: {
403
418
 
404
419
  const user = await findUserById({id: current.userId});
405
420
  if (user?.status !== 'active') {
406
- await revokeRefreshTokenByHash({hashedToken: currentHashedToken});
421
+ await revokeRefreshSession({sessionId: current.sessionId, userId: current.userId});
407
422
  recordRefreshOutcome('rejected');
408
423
  throw new TokenInvalidError('Refresh token is invalid or expired');
409
424
  }
@@ -412,7 +427,7 @@ export async function refreshAccessToken(params: {
412
427
  // second tab); past it, reuse of a retired token means a compromised session.
413
428
  if (current.rotatedAt) {
414
429
  if (isWithinRotationGrace(current)) {
415
- const token = await signAccessToken(user, params.workspaces);
430
+ const token = await signAccessToken(user, params.workspaces, current.sessionId);
416
431
  recordRefreshOutcome('grace');
417
432
  return {token, refreshToken: undefined, user};
418
433
  }
@@ -436,12 +451,12 @@ export async function refreshAccessToken(params: {
436
451
  recordRefreshOutcome('rejected');
437
452
  throw new TokenInvalidError('Refresh token is invalid or expired');
438
453
  }
439
- const token = await signAccessToken(user, params.workspaces);
454
+ const token = await signAccessToken(user, params.workspaces, latest.sessionId);
440
455
  recordRefreshOutcome('grace');
441
456
  return {token, refreshToken: undefined, user};
442
457
  }
443
458
 
444
- const token = await signAccessToken(user, params.workspaces);
459
+ const token = await signAccessToken(user, params.workspaces, current.sessionId);
445
460
  recordRefreshOutcome('rotated');
446
461
  return {token, refreshToken: nextRefreshToken, user};
447
462
  }
@@ -476,8 +491,7 @@ export async function confirmEmailVerification(params: {
476
491
  throw new TokenInvalidError('Verification code is invalid or expired');
477
492
  }
478
493
 
479
- const token = await signAccessToken(verifiedUser, params.workspaces);
480
- const refreshToken = await createRefreshSession(verifiedUser);
494
+ const {token, refreshToken} = await createSessionTokens(verifiedUser, params.workspaces);
481
495
 
482
496
  return {token, refreshToken, user: verifiedUser};
483
497
  }
@@ -548,8 +562,7 @@ export async function confirmPasswordReset(params: {
548
562
 
549
563
  await revokeRefreshTokensForUser({userId: consumed.userId});
550
564
 
551
- const token = await signAccessToken(user, params.workspaces);
552
- const refreshToken = await createRefreshSession(user);
565
+ const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
553
566
 
554
567
  return {token, refreshToken, user};
555
568
  }
@@ -587,7 +600,11 @@ export async function changePassword(params: {
587
600
 
588
601
  export async function logout(params: {refreshToken?: string | undefined}): Promise<void> {
589
602
  if (!params.refreshToken) return;
590
- await revokeRefreshTokenByHash({hashedToken: hashOpaqueToken(params.refreshToken)});
603
+ const refreshToken = await findRefreshTokenByHash({
604
+ hashedToken: hashOpaqueToken(params.refreshToken),
605
+ });
606
+ if (!refreshToken) return;
607
+ await revokeRefreshSession({sessionId: refreshToken.sessionId, userId: refreshToken.userId});
591
608
  }
592
609
 
593
610
  export interface GetCurrentUserResult {
@@ -1,5 +1,6 @@
1
1
  export interface RefreshToken {
2
2
  id: string;
3
+ sessionId: string;
3
4
  userId: string;
4
5
  hashedToken: string;
5
6
  expiresAt: Date;
package/src/core/jwt.ts CHANGED
@@ -12,6 +12,7 @@ export type TokenMembership = z.infer<typeof tokenMembershipSchema>;
12
12
 
13
13
  export const userTokenClaimsSchema = z.object({
14
14
  sub: z.string().uuid(),
15
+ refreshSessionId: z.string().uuid().optional(),
15
16
  email: z.string().email(),
16
17
  name: z.string().nullable().optional(),
17
18
  memberships: z.array(tokenMembershipSchema),
@@ -22,6 +23,7 @@ export const userTokenClaimsSchema = z.object({
22
23
  export type UserTokenClaims = z.infer<typeof userTokenClaimsSchema>;
23
24
 
24
25
  export interface SignUserTokenParams {
26
+ refreshSessionId?: string | undefined;
25
27
  userId: string;
26
28
  email: string;
27
29
  name?: string | null | undefined;
@@ -41,6 +43,7 @@ export async function signUserToken(params: SignUserTokenParams): Promise<string
41
43
  email: params.email,
42
44
  name: params.name ?? null,
43
45
  memberships: params.memberships,
46
+ refreshSessionId: params.refreshSessionId,
44
47
  },
45
48
  secret: params.secret,
46
49
  expiresIn: params.expiresIn,
@@ -1,8 +1,10 @@
1
1
  import {hashOpaqueToken} from '@shipfox/node-tokens';
2
2
  import {
3
3
  createRefreshToken,
4
+ findActiveRefreshSession,
4
5
  findActiveRefreshTokenByHash,
5
6
  findRefreshTokenByHash,
7
+ revokeRefreshSession,
6
8
  revokeRefreshTokenByHash,
7
9
  revokeRefreshTokensForUser,
8
10
  rotateRefreshToken,
@@ -43,6 +45,24 @@ describe('refresh-tokens db', () => {
43
45
  expect(found).toBeUndefined();
44
46
  });
45
47
 
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
+
46
66
  test('rotates a token once, creates the successor, and only the first caller wins', async () => {
47
67
  const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
48
68
  const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
@@ -139,6 +159,28 @@ describe('refresh-tokens db', () => {
139
159
  expect(found).toBeUndefined();
140
160
  });
141
161
 
162
+ test('revokes every token in one refresh-session lineage', async () => {
163
+ const user = await createUser({email: emailFor('rt-revoke-session'), hashedPassword: 'h'});
164
+ const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
165
+ const nextHashedToken = hashOpaqueToken(`next-${crypto.randomUUID()}`);
166
+ const created = await createRefreshToken({
167
+ userId: user.id,
168
+ hashedToken: currentHashedToken,
169
+ expiresAt: new Date(Date.now() + 60_000),
170
+ });
171
+ await rotateRefreshToken({
172
+ id: created.id,
173
+ currentHashedToken,
174
+ nextHashedToken,
175
+ expiresAt: new Date(Date.now() + 60_000),
176
+ });
177
+
178
+ await revokeRefreshSession({sessionId: created.sessionId, userId: user.id});
179
+
180
+ expect(await findRefreshTokenByHash({hashedToken: currentHashedToken})).toBeUndefined();
181
+ expect(await findActiveRefreshTokenByHash({hashedToken: nextHashedToken})).toBeUndefined();
182
+ });
183
+
142
184
  test('revokes all user refresh tokens except the selected session', async () => {
143
185
  const user = await createUser({email: emailFor('rt-revoke-user'), hashedPassword: 'h'});
144
186
  const keepHash = hashOpaqueToken(`keep-${crypto.randomUUID()}`);
@@ -4,6 +4,7 @@ import {db} from './db.js';
4
4
  import {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';
5
5
 
6
6
  export interface CreateRefreshTokenParams {
7
+ sessionId?: string | undefined;
7
8
  userId: string;
8
9
  hashedToken: string;
9
10
  expiresAt: Date;
@@ -13,6 +14,7 @@ export async function createRefreshToken(params: CreateRefreshTokenParams): Prom
13
14
  const rows = await db()
14
15
  .insert(refreshTokens)
15
16
  .values({
17
+ sessionId: params.sessionId,
16
18
  userId: params.userId,
17
19
  hashedToken: params.hashedToken,
18
20
  expiresAt: params.expiresAt,
@@ -51,6 +53,29 @@ export async function findActiveRefreshTokenByHash(params: {
51
53
  return toRefreshToken(row);
52
54
  }
53
55
 
56
+ export async function findActiveRefreshSession(params: {
57
+ sessionId: string;
58
+ userId: string;
59
+ }): Promise<RefreshToken | undefined> {
60
+ const rows = await db()
61
+ .select()
62
+ .from(refreshTokens)
63
+ .where(
64
+ and(
65
+ eq(refreshTokens.sessionId, params.sessionId),
66
+ eq(refreshTokens.userId, params.userId),
67
+ isNull(refreshTokens.revokedAt),
68
+ isNull(refreshTokens.rotatedAt),
69
+ gt(refreshTokens.expiresAt, sql`now()`),
70
+ ),
71
+ )
72
+ .limit(1);
73
+
74
+ const row = rows[0];
75
+ if (!row) return undefined;
76
+ return toRefreshToken(row);
77
+ }
78
+
54
79
  /**
55
80
  * Looks up a non-revoked, non-expired token by hash, including ones already
56
81
  * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
@@ -113,6 +138,7 @@ export async function rotateRefreshToken(params: {
113
138
  const successorRows = await tx
114
139
  .insert(refreshTokens)
115
140
  .values({
141
+ sessionId: rotated.sessionId,
116
142
  userId: rotated.userId,
117
143
  hashedToken: params.nextHashedToken,
118
144
  expiresAt: params.expiresAt,
@@ -132,6 +158,22 @@ export async function revokeRefreshTokenByHash(params: {hashedToken: string}): P
132
158
  .where(and(eq(refreshTokens.hashedToken, params.hashedToken), isNull(refreshTokens.revokedAt)));
133
159
  }
134
160
 
161
+ export async function revokeRefreshSession(params: {
162
+ sessionId: string;
163
+ userId: string;
164
+ }): Promise<void> {
165
+ await db()
166
+ .update(refreshTokens)
167
+ .set({revokedAt: sql`now()`, updatedAt: sql`now()`})
168
+ .where(
169
+ and(
170
+ eq(refreshTokens.sessionId, params.sessionId),
171
+ eq(refreshTokens.userId, params.userId),
172
+ isNull(refreshTokens.revokedAt),
173
+ ),
174
+ );
175
+ }
176
+
135
177
  export async function revokeRefreshTokensForUser(params: {
136
178
  userId: string;
137
179
  exceptRefreshTokenId?: string | undefined;
@@ -1,4 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
2
3
  import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
4
  import type {RefreshToken} from '#core/entities/refresh-token.js';
4
5
  import {pgTable} from './common.js';
@@ -8,6 +9,7 @@ export const refreshTokens = pgTable(
8
9
  'refresh_tokens',
9
10
  {
10
11
  id: uuidv7PrimaryKey(),
12
+ sessionId: uuid('session_id').notNull().default(sql`uuidv7()`),
11
13
  userId: uuid('user_id')
12
14
  .notNull()
13
15
  .references(() => users.id, {onDelete: 'cascade'}),
@@ -21,6 +23,9 @@ export const refreshTokens = pgTable(
21
23
  },
22
24
  (table) => [
23
25
  uniqueIndex('auth_refresh_tokens_hashed_token_unique').on(table.hashedToken),
26
+ uniqueIndex('auth_refresh_tokens_active_session_unique')
27
+ .on(table.userId, table.sessionId)
28
+ .where(sql`${table.revokedAt} IS NULL AND ${table.rotatedAt} IS NULL`),
24
29
  index('auth_refresh_tokens_user_id_idx').on(table.userId),
25
30
  ],
26
31
  );
@@ -31,6 +36,7 @@ export type RefreshTokenCreateDb = typeof refreshTokens.$inferInsert;
31
36
  export function toRefreshToken(row: RefreshTokenDb): RefreshToken {
32
37
  return {
33
38
  id: row.id,
39
+ sessionId: row.sessionId,
34
40
  userId: row.userId,
35
41
  hashedToken: row.hashedToken,
36
42
  expiresAt: row.expiresAt,
package/src/index.test.ts CHANGED
@@ -26,6 +26,7 @@ describe('authModule', () => {
26
26
  const authModule = createAuthModule({
27
27
  workspaces: {
28
28
  listMembershipsForTokenClaims: vi.fn(),
29
+ getWorkspaceCreator: vi.fn(),
29
30
  preflightInvitationAcceptance: vi.fn(),
30
31
  acceptInvitation: vi.fn(),
31
32
  requireActiveMembership: vi.fn(),
package/src/index.ts CHANGED
@@ -44,7 +44,13 @@ export {
44
44
  issueRunnerSessionToken,
45
45
  verifyRunnerSessionToken,
46
46
  } from '#core/runner-session-token.js';
47
- export {createJwtAuthMethod} from '#presentation/auth/jwt-auth.js';
47
+ export {
48
+ type AuthenticatedSessionContext,
49
+ createJwtAuthMethod,
50
+ getAuthenticatedSessionContext,
51
+ type RefreshSessionId,
52
+ type UserId,
53
+ } from '#presentation/auth/jwt-auth.js';
48
54
  export {createLeaseTokenAuthMethod} from '#presentation/auth/lease-token-auth.js';
49
55
  export {
50
56
  authCookiePlugin,
@@ -63,7 +69,7 @@ export function createAuthModule({
63
69
  }): ShipfoxModule {
64
70
  return {
65
71
  name: 'auth',
66
- database: {db, migrationsPath},
72
+ database: {db, migrationsPath, databaseNamespace: 'auth'},
67
73
  auth: [createJwtAuthMethod(), createLeaseTokenAuthMethod(), createRunnerSessionAuthMethod()],
68
74
  loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
69
75
  routes: [buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces)],