@shipfox/api-auth 9.1.0 → 9.3.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 +41 -0
- package/README.md +44 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -1
- package/dist/core/admin-role-model.d.ts +5 -0
- package/dist/core/admin-role-model.d.ts.map +1 -0
- package/dist/core/admin-role-model.js +18 -0
- package/dist/core/admin-role-model.js.map +1 -0
- package/dist/core/admin-role.d.ts +13 -0
- package/dist/core/admin-role.d.ts.map +1 -0
- package/dist/core/admin-role.js +21 -0
- package/dist/core/admin-role.js.map +1 -0
- package/dist/core/administration.d.ts +27 -0
- package/dist/core/administration.d.ts.map +1 -0
- package/dist/core/administration.js +122 -0
- package/dist/core/administration.js.map +1 -0
- package/dist/core/auth.d.ts +9 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +68 -20
- package/dist/core/auth.js.map +1 -1
- package/dist/core/entities/admin-grant.d.ts +10 -0
- package/dist/core/entities/admin-grant.d.ts.map +1 -0
- package/dist/core/entities/admin-grant.js +3 -0
- package/dist/core/entities/admin-grant.js.map +1 -0
- package/dist/core/errors.d.ts +25 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +49 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/ports.d.ts +13 -0
- package/dist/core/ports.d.ts.map +1 -0
- package/dist/core/ports.js +3 -0
- package/dist/core/ports.js.map +1 -0
- package/dist/core/signup-policy.d.ts +10 -0
- package/dist/core/signup-policy.d.ts.map +1 -0
- package/dist/core/signup-policy.js +39 -0
- package/dist/core/signup-policy.js.map +1 -0
- package/dist/db/admin-grants.d.ts +33 -0
- package/dist/db/admin-grants.d.ts.map +1 -0
- package/dist/db/admin-grants.js +193 -0
- package/dist/db/admin-grants.js.map +1 -0
- package/dist/db/db.d.ts +474 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +4 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +142 -0
- package/dist/db/schema/admin-command-results.d.ts.map +1 -0
- package/dist/db/schema/admin-command-results.js +21 -0
- package/dist/db/schema/admin-command-results.js.map +1 -0
- package/dist/db/schema/admin-grants.d.ts +115 -0
- package/dist/db/schema/admin-grants.d.ts.map +1 -0
- package/dist/db/schema/admin-grants.js +42 -0
- package/dist/db/schema/admin-grants.js.map +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -4
- package/dist/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +1 -1
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/dto/user.d.ts +2 -1
- package/dist/presentation/dto/user.d.ts.map +1 -1
- package/dist/presentation/dto/user.js +2 -1
- package/dist/presentation/dto/user.js.map +1 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +26 -2
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +3 -0
- package/dist/presentation/routes/administration.d.ts.map +1 -0
- package/dist/presentation/routes/administration.js +186 -0
- package/dist/presentation/routes/administration.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +2 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +2 -2
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts +1 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +20 -1
- package/dist/presentation/routes/rate-limit.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 +14 -3
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/presentation/routes/session/me.d.ts.map +1 -1
- package/dist/presentation/routes/session/me.js +5 -1
- package/dist/presentation/routes/session/me.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_brainy_shriek.sql +14 -0
- package/drizzle/0002_burly_whizzer.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +702 -0
- package/drizzle/meta/0002_snapshot.json +791 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/config.test.ts +39 -0
- package/src/config.ts +34 -0
- package/src/core/admin-role-model.ts +25 -0
- package/src/core/admin-role.test.ts +39 -0
- package/src/core/admin-role.ts +25 -0
- package/src/core/administration.ts +172 -0
- package/src/core/auth-default-signup-policy.test.ts +65 -0
- package/src/core/auth.test.ts +115 -0
- package/src/core/auth.ts +77 -21
- package/src/core/entities/admin-grant.ts +10 -0
- package/src/core/errors.ts +59 -0
- package/src/core/jwt.test.ts +1 -0
- package/src/core/ports.ts +7 -0
- package/src/core/signup-policy.test.ts +78 -0
- package/src/core/signup-policy.ts +42 -0
- package/src/db/admin-grants.test.ts +57 -0
- package/src/db/admin-grants.ts +340 -0
- package/src/db/db.ts +4 -0
- package/src/db/schema/admin-command-results.ts +41 -0
- package/src/db/schema/admin-grants.ts +49 -0
- package/src/index.test.ts +58 -1
- package/src/index.ts +47 -6
- package/src/metrics/instance.ts +1 -1
- package/src/presentation/auth/refresh-cookie.test.ts +4 -0
- package/src/presentation/dto/user.ts +7 -2
- package/src/presentation/e2eRoutes/index.test.ts +1 -0
- package/src/presentation/inter-module.test.ts +47 -0
- package/src/presentation/inter-module.ts +22 -1
- package/src/presentation/routes/administration.test.ts +253 -0
- package/src/presentation/routes/administration.ts +195 -0
- package/src/presentation/routes/index.test.ts +1 -0
- package/src/presentation/routes/index.ts +3 -1
- package/src/presentation/routes/rate-limit.ts +23 -2
- package/src/presentation/routes/registration/signup.test.ts +26 -0
- package/src/presentation/routes/registration/signup.ts +22 -2
- package/src/presentation/routes/session/login.test.ts +1 -0
- package/src/presentation/routes/session/me.test.ts +18 -2
- package/src/presentation/routes/session/me.ts +5 -1
- package/test/globalSetup.ts +6 -2
- package/test/routes.ts +7 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/core/auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {emailSchema} from '@shipfox/api-auth-dto';
|
|
1
|
+
import {type AdminRole, emailSchema} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {
|
|
3
3
|
confirmEmailChallenge,
|
|
4
4
|
consumeEmailChallengeProof,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
updateUserPassword,
|
|
32
32
|
} from '#db/users.js';
|
|
33
33
|
import {type AuthTokenRefreshOutcome, recordTokenRefreshed} from '#metrics/index.js';
|
|
34
|
+
import {getCurrentAdminRole} from './admin-role.js';
|
|
34
35
|
import type {RefreshToken} from './entities/refresh-token.js';
|
|
35
36
|
import type {User} from './entities/user.js';
|
|
36
37
|
import {
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
EmailTakenError,
|
|
40
41
|
InvalidCredentialsError,
|
|
41
42
|
InvitationEmailMismatchError,
|
|
43
|
+
SignupNotAllowedError,
|
|
42
44
|
TokenAlreadyUsedError,
|
|
43
45
|
TokenExpiredError,
|
|
44
46
|
TokenInvalidError,
|
|
@@ -46,9 +48,32 @@ import {
|
|
|
46
48
|
} from './errors.js';
|
|
47
49
|
import {signUserToken} from './jwt.js';
|
|
48
50
|
import {hashPassword, verifyPassword} from './password.js';
|
|
51
|
+
import type {SignupPolicy} from './ports.js';
|
|
52
|
+
import {createEnvironmentSignupPolicy} from './signup-policy.js';
|
|
49
53
|
|
|
50
54
|
const RESET_TTL_HOURS = 1;
|
|
51
55
|
const PASSWORD_VERIFICATION_PURPOSE = 'password-verification';
|
|
56
|
+
const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
|
|
57
|
+
'This Shipfox deployment does not accept new accounts right now.';
|
|
58
|
+
|
|
59
|
+
const defaultSignupPolicy: SignupPolicy = createEnvironmentSignupPolicy();
|
|
60
|
+
|
|
61
|
+
async function assertSignupAllowed(params: {
|
|
62
|
+
signupPolicy?: SignupPolicy | undefined;
|
|
63
|
+
email: string;
|
|
64
|
+
emailVerified: boolean;
|
|
65
|
+
source: string;
|
|
66
|
+
}): Promise<void> {
|
|
67
|
+
const result = await (params.signupPolicy ?? defaultSignupPolicy).isSignupAllowed({
|
|
68
|
+
email: params.email,
|
|
69
|
+
emailVerified: params.emailVerified,
|
|
70
|
+
source: params.source,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result.allowed) {
|
|
74
|
+
throw new SignupNotAllowedError(result.message ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
52
77
|
|
|
53
78
|
let dummyHashCache: string | undefined;
|
|
54
79
|
async function getDummyHash(): Promise<string> {
|
|
@@ -114,11 +139,12 @@ async function createRefreshSession(
|
|
|
114
139
|
async function createSessionTokens(
|
|
115
140
|
user: User,
|
|
116
141
|
workspaces: WorkspacesInterModuleClient,
|
|
117
|
-
): Promise<{token: string; refreshToken: string}> {
|
|
142
|
+
): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
|
|
118
143
|
const refreshSessionId = crypto.randomUUID();
|
|
119
144
|
const token = await signAccessToken(user, workspaces, refreshSessionId);
|
|
145
|
+
const adminRole = await getCurrentAdminRole({userId: user.id});
|
|
120
146
|
const {refreshToken} = await createRefreshSession(user, refreshSessionId);
|
|
121
|
-
return {token, refreshToken};
|
|
147
|
+
return {token, refreshToken, adminRole};
|
|
122
148
|
}
|
|
123
149
|
|
|
124
150
|
function passwordResetLink(rawToken: string): string {
|
|
@@ -156,11 +182,13 @@ export interface SignupParams {
|
|
|
156
182
|
email: string;
|
|
157
183
|
password: string;
|
|
158
184
|
name?: string | undefined;
|
|
185
|
+
signupPolicy?: SignupPolicy | undefined;
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
export interface ProvisionUserParams {
|
|
162
189
|
email: string;
|
|
163
190
|
name?: string | null | undefined;
|
|
191
|
+
signupPolicy?: SignupPolicy | undefined;
|
|
164
192
|
}
|
|
165
193
|
|
|
166
194
|
/**
|
|
@@ -168,8 +196,19 @@ export interface ProvisionUserParams {
|
|
|
168
196
|
* Existing users are returned unchanged, including their password and profile.
|
|
169
197
|
*/
|
|
170
198
|
export async function provisionUser(params: ProvisionUserParams): Promise<User> {
|
|
199
|
+
const email = emailSchema.parse(params.email);
|
|
200
|
+
const existing = await findUserByEmail({email});
|
|
201
|
+
if (existing) return existing;
|
|
202
|
+
|
|
203
|
+
await assertSignupAllowed({
|
|
204
|
+
signupPolicy: params.signupPolicy,
|
|
205
|
+
email,
|
|
206
|
+
emailVerified: true,
|
|
207
|
+
source: 'external-identity',
|
|
208
|
+
});
|
|
209
|
+
|
|
171
210
|
return await provisionDbUser({
|
|
172
|
-
email
|
|
211
|
+
email,
|
|
173
212
|
name: params.name ?? null,
|
|
174
213
|
});
|
|
175
214
|
}
|
|
@@ -179,7 +218,8 @@ export type SignupResult = User & {
|
|
|
179
218
|
};
|
|
180
219
|
|
|
181
220
|
export async function signup(params: SignupParams & {sourceIp?: string}): Promise<SignupResult> {
|
|
182
|
-
const
|
|
221
|
+
const email = emailSchema.parse(params.email);
|
|
222
|
+
const existing = await findUserByEmail({email});
|
|
183
223
|
if (existing) {
|
|
184
224
|
const canResumeVerification =
|
|
185
225
|
existing.status === 'active' &&
|
|
@@ -196,12 +236,19 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
|
|
|
196
236
|
});
|
|
197
237
|
return {...existing, emailChallenge};
|
|
198
238
|
}
|
|
199
|
-
throw new EmailTakenError(
|
|
239
|
+
throw new EmailTakenError(email);
|
|
200
240
|
}
|
|
201
241
|
|
|
242
|
+
await assertSignupAllowed({
|
|
243
|
+
signupPolicy: params.signupPolicy,
|
|
244
|
+
email,
|
|
245
|
+
emailVerified: false,
|
|
246
|
+
source: 'password',
|
|
247
|
+
});
|
|
248
|
+
|
|
202
249
|
const hashedPassword = await hashPassword({password: params.password});
|
|
203
250
|
const user = await createDbUser({
|
|
204
|
-
email
|
|
251
|
+
email,
|
|
205
252
|
hashedPassword,
|
|
206
253
|
name: params.name ?? null,
|
|
207
254
|
signedUp: {viaInvitation: false},
|
|
@@ -289,12 +336,12 @@ export async function signupWithInvitation(
|
|
|
289
336
|
|
|
290
337
|
// Step 4: Issue session. signAccessToken reads memberships through the
|
|
291
338
|
// workspaces module API, so a successful accept is reflected in the JWT.
|
|
292
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
339
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
293
340
|
|
|
294
341
|
if (acceptError) {
|
|
295
|
-
return {token, refreshToken, user, membership, acceptError};
|
|
342
|
+
return {token, refreshToken, user, membership, acceptError, adminRole};
|
|
296
343
|
}
|
|
297
|
-
return {token, refreshToken, user, membership};
|
|
344
|
+
return {token, refreshToken, user, membership, adminRole};
|
|
298
345
|
}
|
|
299
346
|
|
|
300
347
|
export interface CreateUserParams extends SignupParams {
|
|
@@ -328,6 +375,7 @@ export interface LoginResult {
|
|
|
328
375
|
token: string;
|
|
329
376
|
refreshToken: string;
|
|
330
377
|
user: User;
|
|
378
|
+
adminRole: AdminRole | null;
|
|
331
379
|
}
|
|
332
380
|
|
|
333
381
|
export async function login(params: LoginParams): Promise<LoginResult> {
|
|
@@ -351,9 +399,9 @@ export async function login(params: LoginParams): Promise<LoginResult> {
|
|
|
351
399
|
throw new EmailNotVerifiedError();
|
|
352
400
|
}
|
|
353
401
|
|
|
354
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
402
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
355
403
|
|
|
356
|
-
return {token, refreshToken, user};
|
|
404
|
+
return {token, refreshToken, user, adminRole};
|
|
357
405
|
}
|
|
358
406
|
|
|
359
407
|
export interface CreateSessionForUserParams {
|
|
@@ -366,6 +414,7 @@ export interface CreateSessionForUserResult {
|
|
|
366
414
|
token: string;
|
|
367
415
|
refreshToken: string;
|
|
368
416
|
user: User;
|
|
417
|
+
adminRole: AdminRole | null;
|
|
369
418
|
}
|
|
370
419
|
|
|
371
420
|
export type CreateSessionForUserError =
|
|
@@ -393,9 +442,9 @@ export async function createSessionForUser(
|
|
|
393
442
|
throw new InvalidCredentialsError();
|
|
394
443
|
}
|
|
395
444
|
|
|
396
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
445
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
397
446
|
|
|
398
|
-
return {token, refreshToken, user};
|
|
447
|
+
return {token, refreshToken, user, adminRole};
|
|
399
448
|
}
|
|
400
449
|
|
|
401
450
|
export interface RefreshAccessTokenResult {
|
|
@@ -403,6 +452,7 @@ export interface RefreshAccessTokenResult {
|
|
|
403
452
|
/** Undefined on a grace-window hit: keep the existing cookie instead of rotating it. */
|
|
404
453
|
refreshToken: string | undefined;
|
|
405
454
|
user: User;
|
|
455
|
+
adminRole: AdminRole | null;
|
|
406
456
|
}
|
|
407
457
|
|
|
408
458
|
export async function refreshAccessToken(params: {
|
|
@@ -422,6 +472,7 @@ export async function refreshAccessToken(params: {
|
|
|
422
472
|
recordRefreshOutcome('rejected');
|
|
423
473
|
throw new TokenInvalidError('Refresh token is invalid or expired');
|
|
424
474
|
}
|
|
475
|
+
const adminRole = await getCurrentAdminRole({userId: user.id});
|
|
425
476
|
|
|
426
477
|
// Within the grace window a rotated token means a concurrent refresh (e.g. a
|
|
427
478
|
// second tab); past it, reuse of a retired token means a compromised session.
|
|
@@ -429,7 +480,7 @@ export async function refreshAccessToken(params: {
|
|
|
429
480
|
if (isWithinRotationGrace(current)) {
|
|
430
481
|
const token = await signAccessToken(user, params.workspaces, current.sessionId);
|
|
431
482
|
recordRefreshOutcome('grace');
|
|
432
|
-
return {token, refreshToken: undefined, user};
|
|
483
|
+
return {token, refreshToken: undefined, user, adminRole};
|
|
433
484
|
}
|
|
434
485
|
await revokeRefreshTokensForUser({userId: user.id});
|
|
435
486
|
recordRefreshOutcome('rejected');
|
|
@@ -453,18 +504,19 @@ export async function refreshAccessToken(params: {
|
|
|
453
504
|
}
|
|
454
505
|
const token = await signAccessToken(user, params.workspaces, latest.sessionId);
|
|
455
506
|
recordRefreshOutcome('grace');
|
|
456
|
-
return {token, refreshToken: undefined, user};
|
|
507
|
+
return {token, refreshToken: undefined, user, adminRole};
|
|
457
508
|
}
|
|
458
509
|
|
|
459
510
|
const token = await signAccessToken(user, params.workspaces, current.sessionId);
|
|
460
511
|
recordRefreshOutcome('rotated');
|
|
461
|
-
return {token, refreshToken: nextRefreshToken, user};
|
|
512
|
+
return {token, refreshToken: nextRefreshToken, user, adminRole};
|
|
462
513
|
}
|
|
463
514
|
|
|
464
515
|
export interface ConfirmEmailVerificationResult {
|
|
465
516
|
token: string;
|
|
466
517
|
refreshToken: string;
|
|
467
518
|
user: User;
|
|
519
|
+
adminRole: AdminRole | null;
|
|
468
520
|
}
|
|
469
521
|
|
|
470
522
|
export interface ResendEmailVerificationResult {
|
|
@@ -491,9 +543,12 @@ export async function confirmEmailVerification(params: {
|
|
|
491
543
|
throw new TokenInvalidError('Verification code is invalid or expired');
|
|
492
544
|
}
|
|
493
545
|
|
|
494
|
-
const {token, refreshToken} = await createSessionTokens(
|
|
546
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(
|
|
547
|
+
verifiedUser,
|
|
548
|
+
params.workspaces,
|
|
549
|
+
);
|
|
495
550
|
|
|
496
|
-
return {token, refreshToken, user: verifiedUser};
|
|
551
|
+
return {token, refreshToken, user: verifiedUser, adminRole};
|
|
497
552
|
}
|
|
498
553
|
|
|
499
554
|
export async function resendEmailVerification(params: {
|
|
@@ -537,6 +592,7 @@ export interface ConfirmPasswordResetResult {
|
|
|
537
592
|
token: string;
|
|
538
593
|
refreshToken: string;
|
|
539
594
|
user: User;
|
|
595
|
+
adminRole: AdminRole | null;
|
|
540
596
|
}
|
|
541
597
|
|
|
542
598
|
export async function confirmPasswordReset(params: {
|
|
@@ -562,9 +618,9 @@ export async function confirmPasswordReset(params: {
|
|
|
562
618
|
|
|
563
619
|
await revokeRefreshTokensForUser({userId: consumed.userId});
|
|
564
620
|
|
|
565
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
621
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
566
622
|
|
|
567
|
-
return {token, refreshToken, user};
|
|
623
|
+
return {token, refreshToken, user, adminRole};
|
|
568
624
|
}
|
|
569
625
|
|
|
570
626
|
export async function changePassword(params: {
|
package/src/core/errors.ts
CHANGED
|
@@ -47,6 +47,13 @@ export class EmailTakenError extends Error {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export class SignupNotAllowedError extends Error {
|
|
51
|
+
constructor(message: string) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'SignupNotAllowedError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export class InvalidCredentialsError extends Error {
|
|
51
58
|
constructor() {
|
|
52
59
|
super('Invalid credentials');
|
|
@@ -73,6 +80,58 @@ export class AuthDependencyUnavailableError extends Error {
|
|
|
73
80
|
}
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
export class AdminRoleRequiredError extends Error {
|
|
84
|
+
readonly minimumRole: import('@shipfox/api-auth-dto').AdminRole;
|
|
85
|
+
|
|
86
|
+
constructor(minimumRole: import('@shipfox/api-auth-dto').AdminRole) {
|
|
87
|
+
super(`Administrator role required: ${minimumRole}`);
|
|
88
|
+
this.name = 'AdminRoleRequiredError';
|
|
89
|
+
this.minimumRole = minimumRole;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class LastAdminOwnerError extends Error {
|
|
94
|
+
constructor() {
|
|
95
|
+
super('Cannot remove the final active administrator owner');
|
|
96
|
+
this.name = 'LastAdminOwnerError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class AdminGrantNotFoundError extends Error {
|
|
101
|
+
constructor() {
|
|
102
|
+
super('Administrator grant not found');
|
|
103
|
+
this.name = 'AdminGrantNotFoundError';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class AdminGrantAlreadyExistsError extends Error {
|
|
108
|
+
constructor() {
|
|
109
|
+
super('Administrator grant already exists');
|
|
110
|
+
this.name = 'AdminGrantAlreadyExistsError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class AdminBootstrapClosedError extends Error {
|
|
115
|
+
constructor() {
|
|
116
|
+
super('First administrator owner has already been created');
|
|
117
|
+
this.name = 'AdminBootstrapClosedError';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class InvalidAdminBootstrapTokenError extends Error {
|
|
122
|
+
constructor() {
|
|
123
|
+
super('Bootstrap token is invalid');
|
|
124
|
+
this.name = 'InvalidAdminBootstrapTokenError';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class AdminIdempotencyKeyReuseError extends Error {
|
|
129
|
+
constructor() {
|
|
130
|
+
super('Idempotency key was already used for a different command');
|
|
131
|
+
this.name = 'AdminIdempotencyKeyReuseError';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
76
135
|
export class TokenInvalidError extends Error {
|
|
77
136
|
constructor(reason?: string) {
|
|
78
137
|
super(reason ? `Invalid token: ${reason}` : 'Invalid token');
|
package/src/core/jwt.test.ts
CHANGED
|
@@ -30,6 +30,7 @@ describe('jwt', () => {
|
|
|
30
30
|
expect(claims.email).toBe(email);
|
|
31
31
|
expect(claims.name).toBe('Token User');
|
|
32
32
|
expect(claims.memberships).toEqual(memberships);
|
|
33
|
+
expect(claims).not.toHaveProperty('adminRole');
|
|
33
34
|
expect(claims.iat).toBeTypeOf('number');
|
|
34
35
|
expect(claims.exp).toBeGreaterThan(claims.iat);
|
|
35
36
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {config} from '#config.js';
|
|
2
|
+
import {
|
|
3
|
+
createEnvironmentSignupPolicy,
|
|
4
|
+
DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
|
|
5
|
+
} from './signup-policy.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('#config.js', () => ({
|
|
8
|
+
config: {
|
|
9
|
+
AUTH_SIGNUP_GATE_ENABLED: true,
|
|
10
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: ' Shipfox.io, acme.com ',
|
|
11
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: ' Alice@Example.com, ',
|
|
12
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe('createEnvironmentSignupPolicy', () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.mocked(config).AUTH_SIGNUP_GATE_ENABLED = true;
|
|
19
|
+
vi.mocked(config).AUTH_SIGNUP_NOT_ALLOWED_MESSAGE = undefined;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('allows exact addresses and domains after normalization', async () => {
|
|
23
|
+
const policy = createEnvironmentSignupPolicy();
|
|
24
|
+
|
|
25
|
+
await expect(
|
|
26
|
+
policy.isSignupAllowed({
|
|
27
|
+
email: ' alice@example.com ',
|
|
28
|
+
emailVerified: false,
|
|
29
|
+
source: 'password',
|
|
30
|
+
}),
|
|
31
|
+
).resolves.toEqual({allowed: true});
|
|
32
|
+
await expect(
|
|
33
|
+
policy.isSignupAllowed({
|
|
34
|
+
email: 'member@SHIPFOX.IO',
|
|
35
|
+
emailVerified: true,
|
|
36
|
+
source: 'oauth-google',
|
|
37
|
+
}),
|
|
38
|
+
).resolves.toEqual({allowed: true});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('does not match subdomains', async () => {
|
|
42
|
+
const policy = createEnvironmentSignupPolicy();
|
|
43
|
+
|
|
44
|
+
await expect(
|
|
45
|
+
policy.isSignupAllowed({
|
|
46
|
+
email: 'member@eu.shipfox.io',
|
|
47
|
+
emailVerified: true,
|
|
48
|
+
source: 'oauth-google',
|
|
49
|
+
}),
|
|
50
|
+
).resolves.toEqual({allowed: false, message: DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('returns the configured denial message for an unmatched address', async () => {
|
|
54
|
+
vi.mocked(config).AUTH_SIGNUP_NOT_ALLOWED_MESSAGE = 'Ask your administrator for access.';
|
|
55
|
+
const policy = createEnvironmentSignupPolicy();
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
policy.isSignupAllowed({
|
|
59
|
+
email: 'member@other.example',
|
|
60
|
+
emailVerified: true,
|
|
61
|
+
source: 'oauth-google',
|
|
62
|
+
}),
|
|
63
|
+
).resolves.toEqual({allowed: false, message: 'Ask your administrator for access.'});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('allows every address when the gate is disabled', async () => {
|
|
67
|
+
vi.mocked(config).AUTH_SIGNUP_GATE_ENABLED = false;
|
|
68
|
+
const policy = createEnvironmentSignupPolicy();
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
policy.isSignupAllowed({
|
|
72
|
+
email: 'member@other.example',
|
|
73
|
+
emailVerified: false,
|
|
74
|
+
source: 'password',
|
|
75
|
+
}),
|
|
76
|
+
).resolves.toEqual({allowed: true});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {config} from '#config.js';
|
|
2
|
+
import type {SignupPolicy} from './ports.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
|
|
5
|
+
'This Shipfox deployment does not accept new accounts right now.';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds the default signup policy from the Auth module's environment settings.
|
|
9
|
+
*
|
|
10
|
+
* The allowlist is resolved once at startup. Environment values are normalized
|
|
11
|
+
* before matching, but the configured denial message is returned unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function createEnvironmentSignupPolicy(): SignupPolicy {
|
|
14
|
+
const allowedEmails = parseAllowlist(config.AUTH_SIGNUP_ALLOWED_EMAILS);
|
|
15
|
+
const allowedDomains = parseAllowlist(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
isSignupAllowed({email}) {
|
|
19
|
+
if (!config.AUTH_SIGNUP_GATE_ENABLED) return Promise.resolve({allowed: true});
|
|
20
|
+
|
|
21
|
+
const normalizedEmail = normalize(email);
|
|
22
|
+
if (allowedEmails.has(normalizedEmail)) return Promise.resolve({allowed: true});
|
|
23
|
+
|
|
24
|
+
const atIndex = normalizedEmail.lastIndexOf('@');
|
|
25
|
+
const domain = atIndex === -1 ? '' : normalizedEmail.slice(atIndex + 1);
|
|
26
|
+
if (allowedDomains.has(domain)) return Promise.resolve({allowed: true});
|
|
27
|
+
|
|
28
|
+
return Promise.resolve({
|
|
29
|
+
allowed: false,
|
|
30
|
+
message: config.AUTH_SIGNUP_NOT_ALLOWED_MESSAGE ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE,
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseAllowlist(value: string): ReadonlySet<string> {
|
|
37
|
+
return new Set(value.split(',').map(normalize).filter(Boolean));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalize(value: string): string {
|
|
41
|
+
return value.trim().toLowerCase();
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {eq} from 'drizzle-orm';
|
|
2
|
+
import {LastAdminOwnerError} from '#core/errors.js';
|
|
3
|
+
import {userFactory} from '#test/index.js';
|
|
4
|
+
import {
|
|
5
|
+
createAdminGrant,
|
|
6
|
+
findCurrentAdminRole,
|
|
7
|
+
listAdminGrants,
|
|
8
|
+
revokeAdminGrant,
|
|
9
|
+
} from './admin-grants.js';
|
|
10
|
+
import {db} from './db.js';
|
|
11
|
+
import {users} from './schema/users.js';
|
|
12
|
+
|
|
13
|
+
describe('admin grants db', () => {
|
|
14
|
+
test('evaluates the highest active grant independently of workspace roles', async () => {
|
|
15
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
16
|
+
const observer = await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
17
|
+
await createAdminGrant({userId: user.id, role: 'admin-operator'});
|
|
18
|
+
|
|
19
|
+
expect(observer.userId).toBe(user.id);
|
|
20
|
+
expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-operator');
|
|
21
|
+
expect((await listAdminGrants()).filter((grant) => grant.userId === user.id)).toHaveLength(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('does not evaluate revoked grants or grants for suspended users', async () => {
|
|
25
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
26
|
+
const grant = await createAdminGrant({userId: user.id, role: 'admin-owner'});
|
|
27
|
+
|
|
28
|
+
await expect(revokeAdminGrant({grantId: grant.id})).rejects.toBeInstanceOf(LastAdminOwnerError);
|
|
29
|
+
expect(await findCurrentAdminRole({userId: user.id})).toBe('admin-owner');
|
|
30
|
+
|
|
31
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
|
|
32
|
+
expect(await findCurrentAdminRole({userId: user.id})).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('prevents revoking the final active owner but permits replacement first', async () => {
|
|
36
|
+
const firstOwner = await userFactory.create({emailVerifiedAt: new Date()});
|
|
37
|
+
const secondOwner = await userFactory.create({emailVerifiedAt: new Date()});
|
|
38
|
+
const firstGrant = await createAdminGrant({userId: firstOwner.id, role: 'admin-owner'});
|
|
39
|
+
const secondGrant = await createAdminGrant({userId: secondOwner.id, role: 'admin-owner'});
|
|
40
|
+
|
|
41
|
+
await expect(revokeAdminGrant({grantId: firstGrant.id})).resolves.toMatchObject({
|
|
42
|
+
id: firstGrant.id,
|
|
43
|
+
revokedAt: expect.any(Date),
|
|
44
|
+
});
|
|
45
|
+
await expect(revokeAdminGrant({grantId: secondGrant.id})).rejects.toBeInstanceOf(
|
|
46
|
+
LastAdminOwnerError,
|
|
47
|
+
);
|
|
48
|
+
expect(await findCurrentAdminRole({userId: secondOwner.id})).toBe('admin-owner');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('enforces one active grant of each fixed role per user', async () => {
|
|
52
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
53
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
54
|
+
|
|
55
|
+
await expect(createAdminGrant({userId: user.id, role: 'admin-observer'})).rejects.toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|