@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +52 -0
- package/README.md +71 -37
- package/dist/config.d.ts +0 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -62
- package/dist/config.js.map +1 -1
- package/dist/core/auth.d.ts +21 -2
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +109 -77
- package/dist/core/auth.js.map +1 -1
- package/dist/core/email-owner.d.ts +12 -0
- package/dist/core/email-owner.d.ts.map +1 -0
- package/dist/core/email-owner.js +20 -0
- package/dist/core/email-owner.js.map +1 -0
- package/dist/core/job-lease-token.d.ts.map +1 -1
- package/dist/core/job-lease-token.js +3 -2
- package/dist/core/job-lease-token.js.map +1 -1
- package/dist/core/jwt.d.ts +2 -2
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js.map +1 -1
- package/dist/core/rate-limit.d.ts.map +1 -1
- package/dist/core/rate-limit.js +2 -7
- package/dist/core/rate-limit.js.map +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +3 -2
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/db.d.ts +0 -218
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +0 -2
- package/dist/db/db.js.map +1 -1
- package/dist/db/users.d.ts +3 -0
- package/dist/db/users.d.ts.map +1 -1
- package/dist/db/users.js +28 -10
- package/dist/db/users.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -34
- package/dist/index.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +2 -2
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/e2eRoutes/create-session.d.ts +2 -1
- package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -1
- package/dist/presentation/e2eRoutes/create-session.js +42 -39
- package/dist/presentation/e2eRoutes/create-session.js.map +1 -1
- package/dist/presentation/e2eRoutes/index.d.ts +2 -1
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -1
- package/dist/presentation/e2eRoutes/index.js +12 -10
- package/dist/presentation/e2eRoutes/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +4 -0
- package/dist/presentation/inter-module.d.ts.map +1 -0
- package/dist/presentation/inter-module.js +16 -0
- package/dist/presentation/inter-module.js.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -1
- package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-confirm.js +36 -25
- package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js +20 -4
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
- package/dist/presentation/routes/index.d.ts +2 -2
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +11 -13
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -1
- package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -1
- package/dist/presentation/routes/password/password-reset-confirm.js +29 -26
- package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -1
- package/dist/presentation/routes/registration/signup.d.ts +2 -1
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
- package/dist/presentation/routes/registration/signup.js +86 -68
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/presentation/routes/session/login.d.ts +2 -1
- package/dist/presentation/routes/session/login.d.ts.map +1 -1
- package/dist/presentation/routes/session/login.js +40 -37
- package/dist/presentation/routes/session/login.js.map +1 -1
- package/dist/presentation/routes/session/refresh.d.ts +2 -1
- package/dist/presentation/routes/session/refresh.d.ts.map +1 -1
- package/dist/presentation/routes/session/refresh.js +42 -39
- package/dist/presentation/routes/session/refresh.js.map +1 -1
- package/dist/presentation/subscribers/index.d.ts +0 -1
- package/dist/presentation/subscribers/index.d.ts.map +1 -1
- package/dist/presentation/subscribers/index.js +0 -1
- package/dist/presentation/subscribers/index.js.map +1 -1
- package/dist/presentation/subscribers/on-password-reset-send-requested.js +1 -1
- package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_initial.sql +1 -13
- package/drizzle/meta/0000_snapshot.json +0 -92
- package/package.json +17 -13
- package/src/config.ts +0 -56
- package/src/core/auth.test.ts +106 -187
- package/src/core/auth.ts +131 -87
- package/src/core/email-owner.test.ts +86 -0
- package/src/core/email-owner.ts +23 -0
- package/src/core/job-lease-token.test.ts +2 -1
- package/src/core/job-lease-token.ts +3 -2
- package/src/core/jwt.ts +2 -2
- package/src/core/rate-limit.test.ts +11 -28
- package/src/core/rate-limit.ts +3 -11
- package/src/core/runner-session-token.test.ts +2 -1
- package/src/core/runner-session-token.ts +3 -2
- package/src/db/db.ts +0 -2
- package/src/db/users.ts +32 -14
- package/src/index.test.ts +15 -11
- package/src/index.ts +23 -20
- package/src/presentation/auth/jwt-auth.test.ts +2 -1
- package/src/presentation/auth/jwt-auth.ts +2 -2
- package/src/presentation/e2eRoutes/create-session.ts +34 -30
- package/src/presentation/e2eRoutes/index.test.ts +9 -6
- package/src/presentation/e2eRoutes/index.ts +8 -5
- package/src/presentation/inter-module.ts +13 -0
- package/src/presentation/routes/email-verification/verify-email-confirm.ts +42 -24
- package/src/presentation/routes/email-verification/verify-email-resend.ts +18 -4
- package/src/presentation/routes/index.test.ts +10 -3
- package/src/presentation/routes/index.ts +22 -12
- package/src/presentation/routes/password/password-reset-confirm.ts +25 -22
- package/src/presentation/routes/password/password-reset-request.test.ts +1 -0
- package/src/presentation/routes/rate-limit.test.ts +2 -2
- package/src/presentation/routes/registration/signup.test.ts +39 -74
- package/src/presentation/routes/registration/signup.ts +89 -70
- package/src/presentation/routes/session/login.ts +38 -35
- package/src/presentation/routes/session/refresh.ts +43 -40
- package/src/presentation/subscribers/email-send-requested.test.ts +2 -30
- package/src/presentation/subscribers/index.ts +0 -1
- package/src/presentation/subscribers/on-password-reset-send-requested.ts +1 -1
- package/test/env.ts +1 -3
- package/test/globalSetup.ts +10 -1
- package/test/routes.ts +51 -84
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/core/entities/email-verification.d.ts +0 -9
- package/dist/core/entities/email-verification.d.ts.map +0 -1
- package/dist/core/entities/email-verification.js +0 -3
- package/dist/core/entities/email-verification.js.map +0 -1
- package/dist/db/email-verifications.d.ts +0 -39
- package/dist/db/email-verifications.d.ts.map +0 -1
- package/dist/db/email-verifications.js +0 -95
- package/dist/db/email-verifications.js.map +0 -1
- package/dist/db/schema/email-verifications.d.ts +0 -114
- package/dist/db/schema/email-verifications.d.ts.map +0 -1
- package/dist/db/schema/email-verifications.js +0 -35
- package/dist/db/schema/email-verifications.js.map +0 -1
- package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +0 -3
- package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +0 -1
- package/dist/presentation/subscribers/on-email-verification-send-requested.js +0 -13
- package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +0 -1
- package/src/core/entities/email-verification.ts +0 -8
- package/src/db/email-verifications.test.ts +0 -61
- package/src/db/email-verifications.ts +0 -160
- package/src/db/schema/email-verifications.ts +0 -37
- package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +0 -66
- package/src/presentation/routes/email-verification/verify-email-resend.test.ts +0 -100
- package/src/presentation/subscribers/on-email-verification-send-requested.ts +0 -10
package/src/core/auth.ts
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {emailSchema} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
81
|
-
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
460
|
+
email: string;
|
|
461
|
+
challengeId: string;
|
|
462
|
+
code: string;
|
|
463
|
+
workspaces: WorkspacesInterModuleClient;
|
|
426
464
|
}): Promise<ConfirmEmailVerificationResult> {
|
|
427
|
-
const
|
|
428
|
-
if (!
|
|
429
|
-
|
|
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
|
|
433
|
-
if (
|
|
434
|
-
throw new TokenInvalidError('Verification
|
|
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(
|
|
438
|
-
const refreshToken = await createRefreshSession(
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
417
|
+
it('uses the root-derived identifier key', async () => {
|
|
425
418
|
vi.resetModules();
|
|
426
|
-
vi.
|
|
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
|
|
435
|
-
const
|
|
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.
|
|
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
|
|
448
|
-
const
|
|
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(
|
|
455
|
-
expect(
|
|
456
|
-
expect(
|
|
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.
|
|
441
|
+
vi.unstubAllEnvs();
|
|
459
442
|
vi.resetModules();
|
|
460
443
|
}
|
|
461
444
|
});
|
package/src/core/rate-limit.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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():
|
|
71
|
-
|
|
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: {
|