@shipfox/api-auth 5.0.0 → 6.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 (154) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/README.md +71 -37
  4. package/dist/config.d.ts +0 -12
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +0 -62
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/auth.d.ts +21 -2
  9. package/dist/core/auth.d.ts.map +1 -1
  10. package/dist/core/auth.js +109 -77
  11. package/dist/core/auth.js.map +1 -1
  12. package/dist/core/email-owner.d.ts +12 -0
  13. package/dist/core/email-owner.d.ts.map +1 -0
  14. package/dist/core/email-owner.js +20 -0
  15. package/dist/core/email-owner.js.map +1 -0
  16. package/dist/core/job-lease-token.d.ts.map +1 -1
  17. package/dist/core/job-lease-token.js +3 -2
  18. package/dist/core/job-lease-token.js.map +1 -1
  19. package/dist/core/jwt.d.ts +2 -2
  20. package/dist/core/jwt.d.ts.map +1 -1
  21. package/dist/core/jwt.js.map +1 -1
  22. package/dist/core/rate-limit.d.ts.map +1 -1
  23. package/dist/core/rate-limit.js +2 -7
  24. package/dist/core/rate-limit.js.map +1 -1
  25. package/dist/core/runner-session-token.d.ts.map +1 -1
  26. package/dist/core/runner-session-token.js +3 -2
  27. package/dist/core/runner-session-token.js.map +1 -1
  28. package/dist/db/db.d.ts +0 -218
  29. package/dist/db/db.d.ts.map +1 -1
  30. package/dist/db/db.js +0 -2
  31. package/dist/db/db.js.map +1 -1
  32. package/dist/db/users.d.ts +3 -0
  33. package/dist/db/users.d.ts.map +1 -1
  34. package/dist/db/users.js +28 -10
  35. package/dist/db/users.js.map +1 -1
  36. package/dist/index.d.ts +5 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +40 -34
  39. package/dist/index.js.map +1 -1
  40. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  41. package/dist/presentation/auth/jwt-auth.js +2 -2
  42. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  43. package/dist/presentation/e2eRoutes/create-session.d.ts +2 -1
  44. package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -1
  45. package/dist/presentation/e2eRoutes/create-session.js +42 -39
  46. package/dist/presentation/e2eRoutes/create-session.js.map +1 -1
  47. package/dist/presentation/e2eRoutes/index.d.ts +2 -1
  48. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -1
  49. package/dist/presentation/e2eRoutes/index.js +12 -10
  50. package/dist/presentation/e2eRoutes/index.js.map +1 -1
  51. package/dist/presentation/inter-module.d.ts +4 -0
  52. package/dist/presentation/inter-module.d.ts.map +1 -0
  53. package/dist/presentation/inter-module.js +16 -0
  54. package/dist/presentation/inter-module.js.map +1 -0
  55. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -1
  56. package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -1
  57. package/dist/presentation/routes/email-verification/verify-email-confirm.js +36 -25
  58. package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -1
  59. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  60. package/dist/presentation/routes/email-verification/verify-email-resend.js +20 -4
  61. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  62. package/dist/presentation/routes/index.d.ts +2 -2
  63. package/dist/presentation/routes/index.d.ts.map +1 -1
  64. package/dist/presentation/routes/index.js +11 -13
  65. package/dist/presentation/routes/index.js.map +1 -1
  66. package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -1
  67. package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -1
  68. package/dist/presentation/routes/password/password-reset-confirm.js +29 -26
  69. package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -1
  70. package/dist/presentation/routes/registration/signup.d.ts +2 -1
  71. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  72. package/dist/presentation/routes/registration/signup.js +86 -68
  73. package/dist/presentation/routes/registration/signup.js.map +1 -1
  74. package/dist/presentation/routes/session/login.d.ts +2 -1
  75. package/dist/presentation/routes/session/login.d.ts.map +1 -1
  76. package/dist/presentation/routes/session/login.js +40 -37
  77. package/dist/presentation/routes/session/login.js.map +1 -1
  78. package/dist/presentation/routes/session/refresh.d.ts +2 -1
  79. package/dist/presentation/routes/session/refresh.d.ts.map +1 -1
  80. package/dist/presentation/routes/session/refresh.js +42 -39
  81. package/dist/presentation/routes/session/refresh.js.map +1 -1
  82. package/dist/presentation/subscribers/index.d.ts +0 -1
  83. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  84. package/dist/presentation/subscribers/index.js +0 -1
  85. package/dist/presentation/subscribers/index.js.map +1 -1
  86. package/dist/presentation/subscribers/on-password-reset-send-requested.js +1 -1
  87. package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -1
  88. package/dist/tsconfig.test.tsbuildinfo +1 -1
  89. package/drizzle/0000_initial.sql +1 -13
  90. package/drizzle/meta/0000_snapshot.json +0 -92
  91. package/package.json +17 -13
  92. package/src/config.ts +0 -56
  93. package/src/core/auth.test.ts +106 -187
  94. package/src/core/auth.ts +131 -87
  95. package/src/core/email-owner.test.ts +86 -0
  96. package/src/core/email-owner.ts +23 -0
  97. package/src/core/job-lease-token.test.ts +2 -1
  98. package/src/core/job-lease-token.ts +3 -2
  99. package/src/core/jwt.ts +2 -2
  100. package/src/core/rate-limit.test.ts +11 -28
  101. package/src/core/rate-limit.ts +3 -11
  102. package/src/core/runner-session-token.test.ts +2 -1
  103. package/src/core/runner-session-token.ts +3 -2
  104. package/src/db/db.ts +0 -2
  105. package/src/db/users.ts +32 -14
  106. package/src/index.test.ts +15 -11
  107. package/src/index.ts +23 -20
  108. package/src/presentation/auth/jwt-auth.test.ts +2 -1
  109. package/src/presentation/auth/jwt-auth.ts +2 -2
  110. package/src/presentation/e2eRoutes/create-session.ts +34 -30
  111. package/src/presentation/e2eRoutes/index.test.ts +9 -6
  112. package/src/presentation/e2eRoutes/index.ts +8 -5
  113. package/src/presentation/inter-module.ts +13 -0
  114. package/src/presentation/routes/email-verification/verify-email-confirm.ts +42 -24
  115. package/src/presentation/routes/email-verification/verify-email-resend.ts +18 -4
  116. package/src/presentation/routes/index.test.ts +10 -3
  117. package/src/presentation/routes/index.ts +22 -12
  118. package/src/presentation/routes/password/password-reset-confirm.ts +25 -22
  119. package/src/presentation/routes/password/password-reset-request.test.ts +1 -0
  120. package/src/presentation/routes/rate-limit.test.ts +2 -2
  121. package/src/presentation/routes/registration/signup.test.ts +39 -74
  122. package/src/presentation/routes/registration/signup.ts +89 -70
  123. package/src/presentation/routes/session/login.ts +38 -35
  124. package/src/presentation/routes/session/refresh.ts +43 -40
  125. package/src/presentation/subscribers/email-send-requested.test.ts +2 -30
  126. package/src/presentation/subscribers/index.ts +0 -1
  127. package/src/presentation/subscribers/on-password-reset-send-requested.ts +1 -1
  128. package/test/env.ts +1 -3
  129. package/test/globalSetup.ts +10 -1
  130. package/test/routes.ts +51 -84
  131. package/tsconfig.build.tsbuildinfo +1 -1
  132. package/dist/core/entities/email-verification.d.ts +0 -9
  133. package/dist/core/entities/email-verification.d.ts.map +0 -1
  134. package/dist/core/entities/email-verification.js +0 -3
  135. package/dist/core/entities/email-verification.js.map +0 -1
  136. package/dist/db/email-verifications.d.ts +0 -39
  137. package/dist/db/email-verifications.d.ts.map +0 -1
  138. package/dist/db/email-verifications.js +0 -95
  139. package/dist/db/email-verifications.js.map +0 -1
  140. package/dist/db/schema/email-verifications.d.ts +0 -114
  141. package/dist/db/schema/email-verifications.d.ts.map +0 -1
  142. package/dist/db/schema/email-verifications.js +0 -35
  143. package/dist/db/schema/email-verifications.js.map +0 -1
  144. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +0 -3
  145. package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +0 -1
  146. package/dist/presentation/subscribers/on-email-verification-send-requested.js +0 -13
  147. package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +0 -1
  148. package/src/core/entities/email-verification.ts +0 -8
  149. package/src/db/email-verifications.test.ts +0 -61
  150. package/src/db/email-verifications.ts +0 -160
  151. package/src/db/schema/email-verifications.ts +0 -37
  152. package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +0 -66
  153. package/src/presentation/routes/email-verification/verify-email-resend.test.ts +0 -100
  154. package/src/presentation/subscribers/on-email-verification-send-requested.ts +0 -10
package/src/core/auth.ts CHANGED
@@ -1,20 +1,18 @@
1
- import {EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS, emailSchema} from '@shipfox/api-auth-dto';
1
+ import {emailSchema} from '@shipfox/api-auth-dto';
2
2
  import {
3
- acceptWorkspaceInvitation,
4
- InvitationEmailMismatchError,
5
- listMembershipsByUser,
6
- peekInvitationByRawToken,
7
- TokenAlreadyUsedError,
8
- TokenExpiredError,
9
- TokenInvalidError as WorkspacesTokenInvalidError,
10
- } from '@shipfox/api-workspaces';
3
+ confirmEmailChallenge,
4
+ consumeEmailChallengeProof,
5
+ createEmailChallenge,
6
+ resendEmailChallenge,
7
+ } from '@shipfox/api-email-challenges';
8
+ import {
9
+ type WorkspacesInterModuleClient,
10
+ workspacesInterModuleContract,
11
+ } from '@shipfox/api-workspaces-dto/inter-module';
12
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
13
+ import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
11
14
  import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
12
15
  import {config} from '#config.js';
13
- import {
14
- consumeEmailVerification,
15
- createEmailVerification,
16
- createResendEmailVerification,
17
- } from '#db/email-verifications.js';
18
16
  import {consumePasswordReset, createPasswordReset} from '#db/password-resets.js';
19
17
  import {
20
18
  createRefreshToken,
@@ -40,14 +38,17 @@ import {
40
38
  EmailNotVerifiedError,
41
39
  EmailTakenError,
42
40
  InvalidCredentialsError,
41
+ InvitationEmailMismatchError,
42
+ TokenAlreadyUsedError,
43
+ TokenExpiredError,
43
44
  TokenInvalidError,
44
45
  UserNotFoundError,
45
46
  } from './errors.js';
46
47
  import {signUserToken} from './jwt.js';
47
48
  import {hashPassword, verifyPassword} from './password.js';
48
49
 
49
- const VERIFICATION_TTL_HOURS = 24;
50
50
  const RESET_TTL_HOURS = 1;
51
+ const PASSWORD_VERIFICATION_PURPOSE = 'password-verification';
51
52
 
52
53
  let dummyHashCache: string | undefined;
53
54
  async function getDummyHash(): Promise<string> {
@@ -69,16 +70,21 @@ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
69
70
  recordTokenRefreshed(outcome);
70
71
  }
71
72
 
72
- async function signAccessToken(user: User): Promise<string> {
73
- const memberships = await listMembershipsByUser({userId: user.id}).catch((error: unknown) => {
74
- throw new AuthDependencyUnavailableError('workspaces', error);
75
- });
73
+ async function signAccessToken(
74
+ user: User,
75
+ workspaces: WorkspacesInterModuleClient,
76
+ ): Promise<string> {
77
+ const memberships = await workspaces
78
+ .listMembershipsForTokenClaims({userId: user.id})
79
+ .catch((error: unknown) => {
80
+ throw new AuthDependencyUnavailableError('workspaces', error);
81
+ });
76
82
  return await signUserToken({
77
83
  userId: user.id,
78
84
  email: user.email,
79
85
  name: user.name,
80
- memberships: memberships.map((m) => ({workspaceId: m.workspaceId, role: 'admin' as const})),
81
- secret: config.AUTH_JWT_SECRET,
86
+ memberships: memberships.memberships,
87
+ secret: userAccessTokenKey(),
82
88
  expiresIn: config.AUTH_JWT_EXPIRES_IN,
83
89
  });
84
90
  }
@@ -99,25 +105,35 @@ async function createRefreshSession(user: User): Promise<string> {
99
105
  return refreshToken;
100
106
  }
101
107
 
102
- function verificationLink(rawToken: string): string {
103
- return `${config.CLIENT_BASE_URL}/auth/verify-email?token=${rawToken}`;
104
- }
105
-
106
108
  function passwordResetLink(rawToken: string): string {
107
109
  return `${config.CLIENT_BASE_URL}/auth/reset?token=${rawToken}`;
108
110
  }
109
111
 
110
- async function createAndQueueEmailVerification(user: User): Promise<void> {
111
- const rawToken = generateOpaqueToken('emailVerification');
112
- await createEmailVerification({
113
- userId: user.id,
114
- hashedToken: hashOpaqueToken(rawToken),
115
- expiresAt: hoursFromNow(VERIFICATION_TTL_HOURS),
116
- sendEmail: {
117
- email: user.email,
118
- verifyLink: verificationLink(rawToken),
119
- },
120
- });
112
+ function invitationErrorFromKnownError(
113
+ error: unknown,
114
+ method: 'preflightInvitationAcceptance' | 'acceptInvitation',
115
+ ): Error {
116
+ const contractMethod = workspacesInterModuleContract.methods[method];
117
+ if (!isInterModuleKnownError(contractMethod, error)) return error as Error;
118
+
119
+ if (error.code === 'invitation-token-invalid')
120
+ return new TokenInvalidError('Invitation token is invalid');
121
+ if (error.code === 'invitation-token-used') return new TokenAlreadyUsedError();
122
+ if (error.code === 'invitation-token-expired') return new TokenExpiredError();
123
+ return new InvitationEmailMismatchError();
124
+ }
125
+
126
+ function invitationErrorMessage(
127
+ code:
128
+ | 'invitation-token-invalid'
129
+ | 'invitation-token-used'
130
+ | 'invitation-token-expired'
131
+ | 'invitation-email-mismatch',
132
+ ): string {
133
+ if (code === 'invitation-token-invalid') return 'Invitation token is invalid';
134
+ if (code === 'invitation-token-used') return 'Invitation has already been accepted';
135
+ if (code === 'invitation-token-expired') return 'Invitation has expired';
136
+ return 'Signup email does not match the invitation';
121
137
  }
122
138
 
123
139
  export interface SignupParams {
@@ -142,9 +158,27 @@ export async function provisionUser(params: ProvisionUserParams): Promise<User>
142
158
  });
143
159
  }
144
160
 
145
- export async function signup(params: SignupParams): Promise<User> {
161
+ export type SignupResult = User & {
162
+ emailChallenge: {id: string; nextResendAvailableAt: Date};
163
+ };
164
+
165
+ export async function signup(params: SignupParams & {sourceIp?: string}): Promise<SignupResult> {
146
166
  const existing = await findUserByEmail({email: params.email});
147
167
  if (existing) {
168
+ const canResumeVerification =
169
+ existing.status === 'active' &&
170
+ existing.emailVerifiedAt === null &&
171
+ existing.hashedPassword !== null &&
172
+ (await verifyPassword({password: params.password, hash: existing.hashedPassword}));
173
+ if (canResumeVerification) {
174
+ const emailChallenge = await createEmailChallenge({
175
+ email: existing.email,
176
+ purpose: PASSWORD_VERIFICATION_PURPOSE,
177
+ continuation: existing.id,
178
+ sourceIp: params.sourceIp ?? '0.0.0.0',
179
+ });
180
+ return {...existing, emailChallenge};
181
+ }
148
182
  throw new EmailTakenError(params.email);
149
183
  }
150
184
 
@@ -153,15 +187,22 @@ export async function signup(params: SignupParams): Promise<User> {
153
187
  email: params.email,
154
188
  hashedPassword,
155
189
  name: params.name ?? null,
190
+ signedUp: {viaInvitation: false},
156
191
  });
157
192
 
158
- await createAndQueueEmailVerification(user);
193
+ const emailChallenge = await createEmailChallenge({
194
+ email: user.email,
195
+ purpose: PASSWORD_VERIFICATION_PURPOSE,
196
+ continuation: user.id,
197
+ sourceIp: params.sourceIp ?? '0.0.0.0',
198
+ });
159
199
 
160
- return user;
200
+ return {...user, emailChallenge};
161
201
  }
162
202
 
163
203
  export interface SignupWithInvitationParams extends SignupParams {
164
204
  invitationToken: string;
205
+ workspaces: WorkspacesInterModuleClient;
165
206
  }
166
207
 
167
208
  export interface SignupWithInvitationResult extends LoginResult {
@@ -175,18 +216,13 @@ export async function signupWithInvitation(
175
216
  // Step 1: Pre-validate the invitation BEFORE any user write so an invalid
176
217
  // token never produces an orphan user. Re-validation happens again inside
177
218
  // acceptWorkspaceInvitation (race-safe) after the user is created.
178
- const invitation = await peekInvitationByRawToken({token: params.invitationToken});
179
- if (!invitation) {
180
- throw new WorkspacesTokenInvalidError('Invitation token is invalid');
181
- }
182
- if (invitation.acceptedAt !== null) {
183
- throw new TokenAlreadyUsedError();
184
- }
185
- if (invitation.expiresAt.getTime() <= Date.now()) {
186
- throw new TokenExpiredError();
187
- }
188
- if (invitation.email !== params.email) {
189
- throw new InvitationEmailMismatchError();
219
+ try {
220
+ await params.workspaces.preflightInvitationAcceptance({
221
+ token: params.invitationToken,
222
+ email: params.email,
223
+ });
224
+ } catch (error) {
225
+ throw invitationErrorFromKnownError(error, 'preflightInvitationAcceptance');
190
226
  }
191
227
 
192
228
  // Step 2: Create the user as verified. The invitation email is the proof of
@@ -201,6 +237,7 @@ export async function signupWithInvitation(
201
237
  hashedPassword,
202
238
  name: params.name ?? null,
203
239
  emailVerifiedAt: new Date(),
240
+ signedUp: {viaInvitation: true},
204
241
  });
205
242
 
206
243
  // Step 3: Accept the invitation through the workspaces module boundary.
@@ -210,7 +247,7 @@ export async function signupWithInvitation(
210
247
  let membership: SignupWithInvitationResult['membership'] = null;
211
248
  let acceptError: SignupWithInvitationResult['acceptError'];
212
249
  try {
213
- const result = await acceptWorkspaceInvitation({
250
+ const result = await params.workspaces.acceptInvitation({
214
251
  token: params.invitationToken,
215
252
  userId: user.id,
216
253
  email: user.email,
@@ -222,13 +259,8 @@ export async function signupWithInvitation(
222
259
  workspaceId: result.membership.workspaceId,
223
260
  };
224
261
  } catch (error) {
225
- if (
226
- error instanceof WorkspacesTokenInvalidError ||
227
- error instanceof TokenAlreadyUsedError ||
228
- error instanceof TokenExpiredError ||
229
- error instanceof InvitationEmailMismatchError
230
- ) {
231
- acceptError = {code: error.name, message: error.message};
262
+ if (isInterModuleKnownError(workspacesInterModuleContract.methods.acceptInvitation, error)) {
263
+ acceptError = {code: error.code, message: invitationErrorMessage(error.code)};
232
264
  } else {
233
265
  acceptError = {
234
266
  code: 'AcceptFailed',
@@ -239,7 +271,7 @@ export async function signupWithInvitation(
239
271
 
240
272
  // Step 4: Issue session. signAccessToken reads memberships through the
241
273
  // workspaces module API, so a successful accept is reflected in the JWT.
242
- const token = await signAccessToken(user);
274
+ const token = await signAccessToken(user, params.workspaces);
243
275
  const refreshToken = await createRefreshSession(user);
244
276
 
245
277
  if (acceptError) {
@@ -272,6 +304,7 @@ export async function createUser(params: CreateUserParams): Promise<User> {
272
304
  export interface LoginParams {
273
305
  email: string;
274
306
  password: string;
307
+ workspaces: WorkspacesInterModuleClient;
275
308
  }
276
309
 
277
310
  export interface LoginResult {
@@ -301,7 +334,7 @@ export async function login(params: LoginParams): Promise<LoginResult> {
301
334
  throw new EmailNotVerifiedError();
302
335
  }
303
336
 
304
- const token = await signAccessToken(user);
337
+ const token = await signAccessToken(user, params.workspaces);
305
338
  const refreshToken = await createRefreshSession(user);
306
339
 
307
340
  return {token, refreshToken, user};
@@ -310,6 +343,7 @@ export async function login(params: LoginParams): Promise<LoginResult> {
310
343
  export interface CreateSessionForUserParams {
311
344
  userId?: string | undefined;
312
345
  email?: string | undefined;
346
+ workspaces: WorkspacesInterModuleClient;
313
347
  }
314
348
 
315
349
  export interface CreateSessionForUserResult {
@@ -330,7 +364,7 @@ export async function createSessionForUser(
330
364
  const user = params.userId
331
365
  ? await findUserById({id: params.userId})
332
366
  : params.email
333
- ? await findUserByEmail({email: params.email})
367
+ ? await findUserByEmail({email: emailSchema.parse(params.email)})
334
368
  : undefined;
335
369
 
336
370
  if (!user) {
@@ -343,7 +377,7 @@ export async function createSessionForUser(
343
377
  throw new InvalidCredentialsError();
344
378
  }
345
379
 
346
- const token = await signAccessToken(user);
380
+ const token = await signAccessToken(user, params.workspaces);
347
381
  const refreshToken = await createRefreshSession(user);
348
382
 
349
383
  return {token, refreshToken, user};
@@ -358,6 +392,7 @@ export interface RefreshAccessTokenResult {
358
392
 
359
393
  export async function refreshAccessToken(params: {
360
394
  refreshToken: string;
395
+ workspaces: WorkspacesInterModuleClient;
361
396
  }): Promise<RefreshAccessTokenResult> {
362
397
  const currentHashedToken = hashOpaqueToken(params.refreshToken);
363
398
  const current = await findRefreshTokenByHash({hashedToken: currentHashedToken});
@@ -377,7 +412,7 @@ export async function refreshAccessToken(params: {
377
412
  // second tab); past it, reuse of a retired token means a compromised session.
378
413
  if (current.rotatedAt) {
379
414
  if (isWithinRotationGrace(current)) {
380
- const token = await signAccessToken(user);
415
+ const token = await signAccessToken(user, params.workspaces);
381
416
  recordRefreshOutcome('grace');
382
417
  return {token, refreshToken: undefined, user};
383
418
  }
@@ -401,12 +436,12 @@ export async function refreshAccessToken(params: {
401
436
  recordRefreshOutcome('rejected');
402
437
  throw new TokenInvalidError('Refresh token is invalid or expired');
403
438
  }
404
- const token = await signAccessToken(user);
439
+ const token = await signAccessToken(user, params.workspaces);
405
440
  recordRefreshOutcome('grace');
406
441
  return {token, refreshToken: undefined, user};
407
442
  }
408
443
 
409
- const token = await signAccessToken(user);
444
+ const token = await signAccessToken(user, params.workspaces);
410
445
  recordRefreshOutcome('rotated');
411
446
  return {token, refreshToken: nextRefreshToken, user};
412
447
  }
@@ -422,36 +457,44 @@ export interface ResendEmailVerificationResult {
422
457
  }
423
458
 
424
459
  export async function confirmEmailVerification(params: {
425
- token: string;
460
+ email: string;
461
+ challengeId: string;
462
+ code: string;
463
+ workspaces: WorkspacesInterModuleClient;
426
464
  }): Promise<ConfirmEmailVerificationResult> {
427
- const consumed = await consumeEmailVerification({hashedToken: hashOpaqueToken(params.token)});
428
- if (!consumed) {
429
- throw new TokenInvalidError('Verification token is invalid or expired');
430
- }
465
+ const user = await findUserByEmail({email: emailSchema.parse(params.email)});
466
+ if (!user) throw new TokenInvalidError('Verification code is invalid or expired');
467
+ await confirmEmailChallenge({id: params.challengeId, code: params.code, continuation: user.id});
468
+ await consumeEmailChallengeProof({
469
+ id: params.challengeId,
470
+ purpose: PASSWORD_VERIFICATION_PURPOSE,
471
+ continuation: user.id,
472
+ });
431
473
 
432
- const user = await markEmailVerified({userId: consumed.userId});
433
- if (user?.status !== 'active') {
434
- throw new TokenInvalidError('Verification token is invalid or expired');
474
+ const verifiedUser = await markEmailVerified({userId: user.id});
475
+ if (verifiedUser?.status !== 'active') {
476
+ throw new TokenInvalidError('Verification code is invalid or expired');
435
477
  }
436
478
 
437
- const token = await signAccessToken(user);
438
- const refreshToken = await createRefreshSession(user);
479
+ const token = await signAccessToken(verifiedUser, params.workspaces);
480
+ const refreshToken = await createRefreshSession(verifiedUser);
439
481
 
440
- return {token, refreshToken, user};
482
+ return {token, refreshToken, user: verifiedUser};
441
483
  }
442
484
 
443
485
  export async function resendEmailVerification(params: {
444
486
  email: string;
487
+ challengeId: string;
488
+ sourceIp: string;
445
489
  }): Promise<ResendEmailVerificationResult> {
446
- const rawToken = generateOpaqueToken('emailVerification');
447
- const result = await createResendEmailVerification({
448
- email: params.email,
449
- hashedToken: hashOpaqueToken(rawToken),
450
- expiresAt: hoursFromNow(VERIFICATION_TTL_HOURS),
451
- cooldownSeconds: EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS,
452
- sendEmail: {
453
- verifyLink: verificationLink(rawToken),
454
- },
490
+ const user = await findUserByEmail({email: emailSchema.parse(params.email)});
491
+ if (!user || user.emailVerifiedAt || user.status !== 'active') {
492
+ return {nextResendAvailableAt: new Date()};
493
+ }
494
+ const result = await resendEmailChallenge({
495
+ id: params.challengeId,
496
+ continuation: user.id,
497
+ sourceIp: params.sourceIp,
455
498
  });
456
499
 
457
500
  return {nextResendAvailableAt: result.nextResendAvailableAt};
@@ -485,6 +528,7 @@ export interface ConfirmPasswordResetResult {
485
528
  export async function confirmPasswordReset(params: {
486
529
  token: string;
487
530
  newPassword: string;
531
+ workspaces: WorkspacesInterModuleClient;
488
532
  }): Promise<ConfirmPasswordResetResult> {
489
533
  const consumed = await consumePasswordReset({hashedToken: hashOpaqueToken(params.token)});
490
534
  if (!consumed) {
@@ -504,7 +548,7 @@ export async function confirmPasswordReset(params: {
504
548
 
505
549
  await revokeRefreshTokensForUser({userId: consumed.userId});
506
550
 
507
- const token = await signAccessToken(user);
551
+ const token = await signAccessToken(user, params.workspaces);
508
552
  const refreshToken = await createRefreshSession(user);
509
553
 
510
554
  return {token, refreshToken, user};
@@ -0,0 +1,86 @@
1
+ import {generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {eq} from 'drizzle-orm';
3
+ import {ZodError} from 'zod';
4
+ import {findUserByEmail} from '#core/email-owner.js';
5
+ import {db} from '#db/db.js';
6
+ import {refreshTokens} from '#db/schema/refresh-tokens.js';
7
+ import {users} from '#db/schema/users.js';
8
+ import * as usersDb from '#db/users.js';
9
+ import {userFactory} from '#test/index.js';
10
+
11
+ describe('findUserByEmail (email owner)', () => {
12
+ test('returns undefined for a missing owner', async () => {
13
+ const result = await findUserByEmail({
14
+ email: `missing-${crypto.randomUUID()}@example.com`,
15
+ });
16
+
17
+ expect(result).toBeUndefined();
18
+ });
19
+
20
+ test.each([
21
+ 'active',
22
+ 'suspended',
23
+ 'deleted',
24
+ ] as const)('returns the owner of a(n) %s account', async (status) => {
25
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
26
+ await db().update(users).set({status}).where(eq(users.id, user.id));
27
+
28
+ const result = await findUserByEmail({email: user.email});
29
+
30
+ expect(result).toEqual({id: user.id, email: user.email, status});
31
+ });
32
+
33
+ test('projects only id, email, and status', async () => {
34
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
35
+
36
+ const result = await findUserByEmail({email: user.email});
37
+
38
+ expect(Object.keys(result ?? {}).sort()).toEqual(['email', 'id', 'status']);
39
+ });
40
+
41
+ test('resolves surrounding whitespace and mixed case to the same owner', async () => {
42
+ const user = await userFactory.create();
43
+
44
+ const result = await findUserByEmail({email: ` ${user.email.toUpperCase()} `});
45
+
46
+ expect(result).toEqual({id: user.id, email: user.email, status: 'active'});
47
+ });
48
+
49
+ test('performs no user or refresh-session writes', async () => {
50
+ const user = await userFactory.create({emailVerifiedAt: new Date()});
51
+ const rawRefreshToken = generateOpaqueToken('refreshToken');
52
+ await db()
53
+ .insert(refreshTokens)
54
+ .values({
55
+ userId: user.id,
56
+ hashedToken: hashOpaqueToken(rawRefreshToken),
57
+ expiresAt: new Date(Date.now() + 60_000),
58
+ });
59
+
60
+ const beforeUser = await db().select().from(users).where(eq(users.id, user.id));
61
+ const beforeRefreshTokens = await db()
62
+ .select()
63
+ .from(refreshTokens)
64
+ .where(eq(refreshTokens.userId, user.id));
65
+
66
+ await findUserByEmail({email: user.email});
67
+
68
+ const afterUser = await db().select().from(users).where(eq(users.id, user.id));
69
+ const afterRefreshTokens = await db()
70
+ .select()
71
+ .from(refreshTokens)
72
+ .where(eq(refreshTokens.userId, user.id));
73
+
74
+ expect(afterUser).toEqual(beforeUser);
75
+ expect(afterRefreshTokens).toEqual(beforeRefreshTokens);
76
+ });
77
+
78
+ test('rejects invalid email syntax without querying the database', async () => {
79
+ const findUserByEmailInDbSpy = vi.spyOn(usersDb, 'findUserByEmail');
80
+
81
+ const promise = findUserByEmail({email: 'not-an-email'});
82
+
83
+ await expect(promise).rejects.toBeInstanceOf(ZodError);
84
+ expect(findUserByEmailInDbSpy).not.toHaveBeenCalled();
85
+ });
86
+ });
@@ -0,0 +1,23 @@
1
+ import {emailSchema} from '@shipfox/api-auth-dto';
2
+ import {findUserByEmail as findUserByEmailInDb} from '#db/users.js';
3
+ import type {User} from './entities/user.js';
4
+
5
+ export type EmailOwner = Pick<User, 'id' | 'email' | 'status'>;
6
+
7
+ export interface FindUserByEmailParams {
8
+ email: string;
9
+ }
10
+
11
+ /**
12
+ * Read-only lookup of the current owner of a normalized email. Performs no
13
+ * user, session, or verification writes; returns undefined when no user owns
14
+ * the address.
15
+ */
16
+ export async function findUserByEmail(
17
+ params: FindUserByEmailParams,
18
+ ): Promise<EmailOwner | undefined> {
19
+ const email = emailSchema.parse(params.email);
20
+ const user = await findUserByEmailInDb({email});
21
+ if (!user) return undefined;
22
+ return {id: user.id, email: user.email, status: user.status};
23
+ }
@@ -1,9 +1,10 @@
1
1
  import {JOB_LEASE_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
2
+ import {jobLeaseTokenKey} from '@shipfox/node-auth-root-key';
2
3
  import {signHs256} from '@shipfox/node-jwt';
3
4
  import {issueJobLeaseToken, verifyJobLeaseToken} from './job-lease-token.js';
4
5
 
5
6
  // Matches test/env.ts; the codec reads this same value from config.
6
- const SECRET = process.env.AUTH_JOB_LEASE_TOKEN_SECRET ?? 'test-lease-secret';
7
+ const SECRET = jobLeaseTokenKey();
7
8
 
8
9
  function claims() {
9
10
  return {
@@ -3,6 +3,7 @@ import {
3
3
  type JobLeaseTokenClaims,
4
4
  jobLeaseTokenClaimsSchema,
5
5
  } from '@shipfox/api-auth-dto';
6
+ import {jobLeaseTokenKey} from '@shipfox/node-auth-root-key';
6
7
  import {signHs256, verifyHs256} from '@shipfox/node-jwt';
7
8
  import {config} from '#config.js';
8
9
  import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
@@ -57,7 +58,7 @@ export async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Prom
57
58
  }
58
59
  : {}),
59
60
  },
60
- secret: config.AUTH_JOB_LEASE_TOKEN_SECRET,
61
+ secret: jobLeaseTokenKey(),
61
62
  expiresIn: config.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN,
62
63
  audience: JOB_LEASE_TOKEN_AUDIENCE,
63
64
  });
@@ -70,7 +71,7 @@ export async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenC
70
71
  try {
71
72
  const claims = await verifyHs256({
72
73
  token,
73
- secret: config.AUTH_JOB_LEASE_TOKEN_SECRET,
74
+ secret: jobLeaseTokenKey(),
74
75
  schema: jobLeaseTokenClaimsSchema,
75
76
  audience: JOB_LEASE_TOKEN_AUDIENCE,
76
77
  });
package/src/core/jwt.ts CHANGED
@@ -26,13 +26,13 @@ export interface SignUserTokenParams {
26
26
  email: string;
27
27
  name?: string | null | undefined;
28
28
  memberships: TokenMembership[];
29
- secret: string;
29
+ secret: string | Uint8Array;
30
30
  expiresIn: string;
31
31
  }
32
32
 
33
33
  export interface VerifyUserTokenParams {
34
34
  token: string;
35
- secret: string;
35
+ secret: string | Uint8Array;
36
36
  }
37
37
 
38
38
  export async function signUserToken(params: SignUserTokenParams): Promise<string> {
@@ -258,12 +258,6 @@ describe('checkAuthRateLimit', () => {
258
258
  });
259
259
  const pruneExpiredAuthRateLimits = vi.fn(() => pendingPrune);
260
260
  vi.resetModules();
261
- vi.doMock('#config.js', () => ({
262
- config: {
263
- AUTH_JWT_SECRET: 'jwt-secret',
264
- RATE_LIMIT_IDENTIFIER_SECRET: undefined,
265
- },
266
- }));
267
261
  vi.doMock('#db/rate-limits.js', () => ({
268
262
  consumeAuthRateLimit,
269
263
  pruneExpiredAuthRateLimits,
@@ -293,7 +287,6 @@ describe('checkAuthRateLimit', () => {
293
287
  expect(pruneExpiredAuthRateLimits).toHaveBeenCalledWith({now});
294
288
  } finally {
295
289
  finishPrune?.(0);
296
- vi.doUnmock('#config.js');
297
290
  vi.doUnmock('#db/rate-limits.js');
298
291
  vi.doUnmock('#metrics/index.js');
299
292
  vi.resetModules();
@@ -421,41 +414,31 @@ describe('checkAuthRateLimit', () => {
421
414
  });
422
415
  });
423
416
 
424
- it('uses the configured identifier secret when present', async () => {
417
+ it('uses the root-derived identifier key', async () => {
425
418
  vi.resetModules();
426
- vi.doMock('#config.js', () => ({
427
- config: {
428
- AUTH_JWT_SECRET: 'jwt-secret',
429
- RATE_LIMIT_IDENTIFIER_SECRET: 'configured-secret',
430
- },
431
- }));
419
+ vi.stubEnv('AUTH_ROOT_KEY', 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=');
432
420
 
433
421
  try {
434
- const configuredSecretModule = await import('./rate-limit.js');
435
- const configuredSecretHash = configuredSecretModule.hashAuthRateLimitIdentifier({
422
+ const firstModule = await import('./rate-limit.js');
423
+ const firstHash = firstModule.hashAuthRateLimitIdentifier({
436
424
  action: 'login',
437
425
  scope: 'email',
438
426
  identifier: 'person@example.com',
439
427
  });
440
- vi.doMock('#config.js', () => ({
441
- config: {
442
- AUTH_JWT_SECRET: 'jwt-secret',
443
- RATE_LIMIT_IDENTIFIER_SECRET: undefined,
444
- },
445
- }));
428
+ vi.stubEnv('AUTH_ROOT_KEY', 'ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=');
446
429
  vi.resetModules();
447
- const derivedSecretModule = await import('./rate-limit.js');
448
- const derivedSecretHash = derivedSecretModule.hashAuthRateLimitIdentifier({
430
+ const secondModule = await import('./rate-limit.js');
431
+ const secondHash = secondModule.hashAuthRateLimitIdentifier({
449
432
  action: 'login',
450
433
  scope: 'email',
451
434
  identifier: 'person@example.com',
452
435
  });
453
436
 
454
- expect(configuredSecretHash).toMatch(HMAC_HEX_PATTERN);
455
- expect(derivedSecretHash).toMatch(HMAC_HEX_PATTERN);
456
- expect(configuredSecretHash).not.toBe(derivedSecretHash);
437
+ expect(firstHash).toMatch(HMAC_HEX_PATTERN);
438
+ expect(secondHash).toMatch(HMAC_HEX_PATTERN);
439
+ expect(firstHash).not.toBe(secondHash);
457
440
  } finally {
458
- vi.doUnmock('#config.js');
441
+ vi.unstubAllEnvs();
459
442
  vi.resetModules();
460
443
  }
461
444
  });
@@ -1,4 +1,4 @@
1
- import {createHmac} from 'node:crypto';
1
+ import {rateLimitIdentifierKey} from '@shipfox/node-auth-root-key';
2
2
  import {
3
3
  checkRateLimit,
4
4
  hashRateLimitIdentifier,
@@ -6,7 +6,6 @@ import {
6
6
  type RateLimitPolicy,
7
7
  RateLimitUnavailableError,
8
8
  } from '@shipfox/node-rate-limit';
9
- import {config} from '#config.js';
10
9
  import {consumeAuthRateLimit, pruneExpiredAuthRateLimits} from '#db/rate-limits.js';
11
10
  import {
12
11
  type AuthRateLimitAction,
@@ -64,17 +63,10 @@ export class AuthRateLimitUnavailableError extends RateLimitUnavailableError<
64
63
  }
65
64
 
66
65
  const DEFAULT_TIMEOUT_MS = 250;
67
- const IDENTIFIER_SECRET_DERIVATION_DOMAIN = 'shipfox.auth.rate-limit.identifier-secret.v1';
68
66
  const IDENTIFIER_HASH_DOMAIN = 'shipfox.auth.rate-limit.identifier.v1';
69
67
 
70
- function effectiveIdentifierSecret(): Buffer | string {
71
- if (config.RATE_LIMIT_IDENTIFIER_SECRET) {
72
- return config.RATE_LIMIT_IDENTIFIER_SECRET;
73
- }
74
-
75
- return createHmac('sha256', config.AUTH_JWT_SECRET)
76
- .update(IDENTIFIER_SECRET_DERIVATION_DOMAIN)
77
- .digest();
68
+ function effectiveIdentifierSecret(): Uint8Array {
69
+ return rateLimitIdentifierKey();
78
70
  }
79
71
 
80
72
  export function hashAuthRateLimitIdentifier(params: {