@shipfox/api-auth 9.0.3 → 9.2.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 +28 -0
- package/README.md +28 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +22 -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/auth.d.ts +9 -0
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +71 -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 +10 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +19 -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 +15 -0
- package/dist/db/admin-grants.d.ts.map +1 -0
- package/dist/db/admin-grants.js +49 -0
- package/dist/db/admin-grants.js.map +1 -0
- package/dist/db/db.d.ts +218 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- 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 +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.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/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/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/meta/0001_snapshot.json +702 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/config.test.ts +39 -0
- package/src/config.ts +30 -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/auth.test.ts +110 -0
- package/src/core/auth.ts +78 -21
- package/src/core/entities/admin-grant.ts +10 -0
- package/src/core/errors.ts +24 -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 +83 -0
- package/src/db/db.ts +2 -0
- package/src/db/schema/admin-grants.ts +49 -0
- package/src/index.test.ts +53 -0
- package/src/index.ts +28 -5
- package/src/presentation/dto/user.ts +7 -2
- package/src/presentation/inter-module.test.ts +47 -0
- package/src/presentation/inter-module.ts +22 -1
- package/src/presentation/routes/index.ts +3 -1
- 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 +1 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "9.0
|
|
4
|
+
"version": "9.2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -26,17 +26,17 @@
|
|
|
26
26
|
"@node-rs/argon2": "^2.0.2",
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@shipfox/api-auth-context": "9.0
|
|
30
|
-
"@shipfox/api-auth-dto": "9.0
|
|
31
|
-
"@shipfox/api-email-challenges": "1.1.
|
|
32
|
-
"@shipfox/api-workspaces-dto": "9.0
|
|
29
|
+
"@shipfox/api-auth-context": "9.2.0",
|
|
30
|
+
"@shipfox/api-auth-dto": "9.2.0",
|
|
31
|
+
"@shipfox/api-email-challenges": "1.1.4",
|
|
32
|
+
"@shipfox/api-workspaces-dto": "9.2.0",
|
|
33
33
|
"@shipfox/inter-module": "0.2.2",
|
|
34
34
|
"@shipfox/config": "1.2.4",
|
|
35
35
|
"@shipfox/node-drizzle": "0.3.4",
|
|
36
36
|
"@shipfox/node-auth-root-key": "0.2.3",
|
|
37
37
|
"@shipfox/node-fastify": "0.3.3",
|
|
38
38
|
"@shipfox/node-jwt": "0.3.2",
|
|
39
|
-
"@shipfox/node-email": "0.3.
|
|
39
|
+
"@shipfox/node-email": "0.3.4",
|
|
40
40
|
"@shipfox/node-mailer": "0.2.3",
|
|
41
41
|
"@shipfox/node-module": "1.0.2",
|
|
42
42
|
"@shipfox/node-opentelemetry": "0.6.2",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
describe('signup gate configuration', () => {
|
|
2
|
+
afterEach(() => {
|
|
3
|
+
vi.unstubAllEnvs();
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
test('uses the Auth-prefixed defaults', async () => {
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
|
|
10
|
+
const {config} = await import('#config.js');
|
|
11
|
+
|
|
12
|
+
expect(config.AUTH_SIGNUP_GATE_ENABLED).toBe(false);
|
|
13
|
+
expect(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS).toBe('');
|
|
14
|
+
expect(config.AUTH_SIGNUP_ALLOWED_EMAILS).toBe('');
|
|
15
|
+
expect(config.AUTH_SIGNUP_NOT_ALLOWED_MESSAGE).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('fails startup when the enabled gate has no allowlist', async () => {
|
|
19
|
+
vi.stubEnv('AUTH_SIGNUP_GATE_ENABLED', 'true');
|
|
20
|
+
vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS', ', ');
|
|
21
|
+
vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAILS', ' , ');
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
|
|
24
|
+
await expect(import('#config.js')).rejects.toThrow(
|
|
25
|
+
'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('accepts an enabled gate with either allowlist', async () => {
|
|
30
|
+
vi.stubEnv('AUTH_SIGNUP_GATE_ENABLED', 'true');
|
|
31
|
+
vi.stubEnv('AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS', 'shipfox.io');
|
|
32
|
+
vi.resetModules();
|
|
33
|
+
|
|
34
|
+
const {config} = await import('#config.js');
|
|
35
|
+
|
|
36
|
+
expect(config.AUTH_SIGNUP_GATE_ENABLED).toBe(true);
|
|
37
|
+
expect(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS).toBe('shipfox.io');
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -29,8 +29,38 @@ export const config = createConfig({
|
|
|
29
29
|
desc: 'Whether password login is available. Use true or false. Defaults to true. When false, password and email-verification routes are not registered, and server startup requires another module to contribute a login method.',
|
|
30
30
|
default: true,
|
|
31
31
|
}),
|
|
32
|
+
AUTH_SIGNUP_GATE_ENABLED: bool({
|
|
33
|
+
desc: 'Whether new account creation is restricted to the configured signup email allowlist. Defaults to false, which allows every signup.',
|
|
34
|
+
default: false,
|
|
35
|
+
}),
|
|
36
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: str({
|
|
37
|
+
desc: 'Comma-separated email domains allowed to create accounts, such as shipfox.io,acme.com. Required when AUTH_SIGNUP_GATE_ENABLED is true unless AUTH_SIGNUP_ALLOWED_EMAILS is set.',
|
|
38
|
+
default: '',
|
|
39
|
+
}),
|
|
40
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: str({
|
|
41
|
+
desc: 'Comma-separated exact email addresses allowed to create accounts. Required when AUTH_SIGNUP_GATE_ENABLED is true unless AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS is set.',
|
|
42
|
+
default: '',
|
|
43
|
+
}),
|
|
44
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: str({
|
|
45
|
+
desc: 'Description returned when the signup gate blocks an account. Optional. The default is This Shipfox deployment does not accept new accounts right now.',
|
|
46
|
+
default: undefined,
|
|
47
|
+
}),
|
|
32
48
|
CLIENT_BASE_URL: str({
|
|
33
49
|
desc: 'Base URL of the client app. Used to build links in emails such as password resets.',
|
|
34
50
|
default: 'http://localhost:5173',
|
|
35
51
|
}),
|
|
36
52
|
});
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
config.AUTH_SIGNUP_GATE_ENABLED &&
|
|
56
|
+
!hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS) &&
|
|
57
|
+
!hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAILS)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS to be set.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasSignupAllowlistEntry(value: string): boolean {
|
|
65
|
+
return value.split(',').some((entry) => entry.trim().length > 0);
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
+
|
|
3
|
+
export const ADMIN_ROLES: readonly AdminRole[] = [
|
|
4
|
+
'admin-observer',
|
|
5
|
+
'admin-operator',
|
|
6
|
+
'admin-owner',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const ADMIN_ROLE_RANK: Record<AdminRole, number> = {
|
|
10
|
+
'admin-observer': 1,
|
|
11
|
+
'admin-operator': 2,
|
|
12
|
+
'admin-owner': 3,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function hasMinimumAdminRole(role: AdminRole, minimumRole: AdminRole): boolean {
|
|
16
|
+
return ADMIN_ROLE_RANK[role] >= ADMIN_ROLE_RANK[minimumRole];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function highestAdminRole(roles: readonly AdminRole[]): AdminRole | null {
|
|
20
|
+
return roles.reduce<AdminRole | null>(
|
|
21
|
+
(highest, role) =>
|
|
22
|
+
highest === null || ADMIN_ROLE_RANK[role] > ADMIN_ROLE_RANK[highest] ? role : highest,
|
|
23
|
+
null,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {createAdminGrant} from '#db/admin-grants.js';
|
|
2
|
+
import {userFactory} from '#test/index.js';
|
|
3
|
+
import {hasMinimumAdminRole, highestAdminRole, requireAdminRole} from './admin-role.js';
|
|
4
|
+
import type {AdminRoleRequiredError} from './errors.js';
|
|
5
|
+
|
|
6
|
+
describe('admin role policy', () => {
|
|
7
|
+
test.each([
|
|
8
|
+
['admin-observer', 'admin-observer', true],
|
|
9
|
+
['admin-observer', 'admin-operator', false],
|
|
10
|
+
['admin-operator', 'admin-observer', true],
|
|
11
|
+
['admin-operator', 'admin-owner', false],
|
|
12
|
+
['admin-owner', 'admin-observer', true],
|
|
13
|
+
['admin-owner', 'admin-operator', true],
|
|
14
|
+
['admin-owner', 'admin-owner', true],
|
|
15
|
+
] as const)('%s satisfies %s: %s', (role, minimumRole, expected) => {
|
|
16
|
+
expect(hasMinimumAdminRole(role, minimumRole)).toBe(expected);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('selects the highest role from fixed grants', () => {
|
|
20
|
+
expect(highestAdminRole(['admin-observer', 'admin-owner', 'admin-operator'])).toBe(
|
|
21
|
+
'admin-owner',
|
|
22
|
+
);
|
|
23
|
+
expect(highestAdminRole([])).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('evaluates the current role from Auth storage for every required minimum', async () => {
|
|
27
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
28
|
+
await createAdminGrant({userId: user.id, role: 'admin-operator'});
|
|
29
|
+
|
|
30
|
+
await expect(requireAdminRole({userId: user.id, minimumRole: 'admin-observer'})).resolves.toBe(
|
|
31
|
+
'admin-operator',
|
|
32
|
+
);
|
|
33
|
+
await expect(requireAdminRole({userId: user.id, minimumRole: 'admin-owner'})).rejects.toEqual(
|
|
34
|
+
expect.objectContaining<Partial<AdminRoleRequiredError>>({
|
|
35
|
+
minimumRole: 'admin-owner',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {findCurrentAdminRole, revokeAdminGrant as revokeAdminGrantInDb} from '#db/admin-grants.js';
|
|
3
|
+
import {hasMinimumAdminRole} from './admin-role-model.js';
|
|
4
|
+
import {AdminRoleRequiredError} from './errors.js';
|
|
5
|
+
|
|
6
|
+
export {ADMIN_ROLES, hasMinimumAdminRole, highestAdminRole} from './admin-role-model.js';
|
|
7
|
+
|
|
8
|
+
export async function getCurrentAdminRole(params: {userId: string}): Promise<AdminRole | null> {
|
|
9
|
+
return await findCurrentAdminRole(params);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function requireAdminRole(params: {
|
|
13
|
+
userId: string;
|
|
14
|
+
minimumRole: AdminRole;
|
|
15
|
+
}): Promise<AdminRole> {
|
|
16
|
+
const role = await getCurrentAdminRole({userId: params.userId});
|
|
17
|
+
if (!role || !hasMinimumAdminRole(role, params.minimumRole)) {
|
|
18
|
+
throw new AdminRoleRequiredError(params.minimumRole);
|
|
19
|
+
}
|
|
20
|
+
return role;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function revokeAdminGrant(params: {grantId: string}) {
|
|
24
|
+
return await revokeAdminGrantInDb(params);
|
|
25
|
+
}
|
package/src/core/auth.test.ts
CHANGED
|
@@ -160,6 +160,71 @@ describe('auth core', () => {
|
|
|
160
160
|
await expect(promise).rejects.toBeInstanceOf(EmailTakenError);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
test('signup normalizes the email before checking the policy', async () => {
|
|
164
|
+
const email = `signup-policy-${crypto.randomUUID()}@example.com`;
|
|
165
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
|
|
166
|
+
|
|
167
|
+
await signup({
|
|
168
|
+
email: ` ${email.toUpperCase()} `,
|
|
169
|
+
password: 'correct horse battery staple',
|
|
170
|
+
signupPolicy: {isSignupAllowed},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(isSignupAllowed).toHaveBeenCalledWith({
|
|
174
|
+
email,
|
|
175
|
+
emailVerified: false,
|
|
176
|
+
source: 'password',
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('signup denies a new user when the policy does not allow it', async () => {
|
|
181
|
+
const email = `signup-policy-denied-${crypto.randomUUID()}@example.com`;
|
|
182
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
signup({
|
|
186
|
+
email,
|
|
187
|
+
password: 'correct horse battery staple',
|
|
188
|
+
signupPolicy: {isSignupAllowed},
|
|
189
|
+
}),
|
|
190
|
+
).rejects.toEqual(
|
|
191
|
+
expect.objectContaining({
|
|
192
|
+
name: 'SignupNotAllowedError',
|
|
193
|
+
message: 'This Shipfox deployment does not accept new accounts right now.',
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('signup fails closed when the policy throws', async () => {
|
|
200
|
+
const email = `signup-policy-error-${crypto.randomUUID()}@example.com`;
|
|
201
|
+
const policyError = new Error('policy unavailable');
|
|
202
|
+
const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
signup({
|
|
206
|
+
email,
|
|
207
|
+
password: 'correct horse battery staple',
|
|
208
|
+
signupPolicy: {isSignupAllowed},
|
|
209
|
+
}),
|
|
210
|
+
).rejects.toBe(policyError);
|
|
211
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('signup does not check the policy for an existing user', async () => {
|
|
215
|
+
const existing = await userFactory.create({emailVerifiedAt: new Date()});
|
|
216
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
|
|
217
|
+
|
|
218
|
+
await expect(
|
|
219
|
+
signup({
|
|
220
|
+
email: ` ${existing.email.toUpperCase()} `,
|
|
221
|
+
password: 'correct horse battery staple',
|
|
222
|
+
signupPolicy: {isSignupAllowed},
|
|
223
|
+
}),
|
|
224
|
+
).rejects.toBeInstanceOf(EmailTakenError);
|
|
225
|
+
expect(isSignupAllowed).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
163
228
|
test('signup with an invitation writes the signed-up event with its user insert', async () => {
|
|
164
229
|
const email = `signup-invitation-${crypto.randomUUID()}@example.com`;
|
|
165
230
|
const userId = crypto.randomUUID();
|
|
@@ -175,6 +240,9 @@ describe('auth core', () => {
|
|
|
175
240
|
name: 'Invited User',
|
|
176
241
|
invitationToken: `invite-${crypto.randomUUID()}`,
|
|
177
242
|
workspaces,
|
|
243
|
+
signupPolicy: {
|
|
244
|
+
isSignupAllowed: vi.fn().mockRejectedValue(new Error('policy unavailable')),
|
|
245
|
+
},
|
|
178
246
|
});
|
|
179
247
|
|
|
180
248
|
const events = await outboxEventsTo(email, AUTH_USER_SIGNED_UP);
|
|
@@ -199,24 +267,66 @@ describe('auth core', () => {
|
|
|
199
267
|
expect(user.status).toBe('active');
|
|
200
268
|
});
|
|
201
269
|
|
|
270
|
+
test('provisionUser normalizes the email before checking the policy', async () => {
|
|
271
|
+
const email = `provision-policy-${crypto.randomUUID()}@example.com`;
|
|
272
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
|
|
273
|
+
|
|
274
|
+
await provisionUser({
|
|
275
|
+
email: ` ${email.toUpperCase()} `,
|
|
276
|
+
signupPolicy: {isSignupAllowed},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(isSignupAllowed).toHaveBeenCalledWith({
|
|
280
|
+
email,
|
|
281
|
+
emailVerified: true,
|
|
282
|
+
source: 'external-identity',
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('provisionUser denies a new user when the policy does not allow it', async () => {
|
|
287
|
+
const email = `provision-policy-denied-${crypto.randomUUID()}@example.com`;
|
|
288
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false, message: 'Closed beta'});
|
|
289
|
+
|
|
290
|
+
await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toEqual(
|
|
291
|
+
expect.objectContaining({
|
|
292
|
+
name: 'SignupNotAllowedError',
|
|
293
|
+
message: 'Closed beta',
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('provisionUser fails closed when the policy throws', async () => {
|
|
300
|
+
const email = `provision-policy-error-${crypto.randomUUID()}@example.com`;
|
|
301
|
+
const policyError = new Error('policy unavailable');
|
|
302
|
+
const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
|
|
303
|
+
|
|
304
|
+
await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toBe(policyError);
|
|
305
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
202
308
|
test('provisionUser returns existing unverified and suspended users unchanged', async () => {
|
|
203
309
|
const unverified = await userFactory.create();
|
|
204
310
|
const suspended = await userFactory.create({emailVerifiedAt: new Date()});
|
|
205
311
|
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
|
|
206
312
|
const storedUnverified = await findUserById({id: unverified.id});
|
|
207
313
|
const storedSuspended = await findUserById({id: suspended.id});
|
|
314
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
|
|
208
315
|
|
|
209
316
|
const existingUnverified = await provisionUser({
|
|
210
317
|
email: ` ${unverified.email.toUpperCase()} `,
|
|
211
318
|
name: 'Replacement Name',
|
|
319
|
+
signupPolicy: {isSignupAllowed},
|
|
212
320
|
});
|
|
213
321
|
const existingSuspended = await provisionUser({
|
|
214
322
|
email: ` ${suspended.email.toUpperCase()} `,
|
|
215
323
|
name: 'Replacement Name',
|
|
324
|
+
signupPolicy: {isSignupAllowed},
|
|
216
325
|
});
|
|
217
326
|
|
|
218
327
|
expect(existingUnverified).toEqual(storedUnverified);
|
|
219
328
|
expect(existingSuspended).toEqual(storedSuspended);
|
|
329
|
+
expect(isSignupAllowed).not.toHaveBeenCalled();
|
|
220
330
|
});
|
|
221
331
|
|
|
222
332
|
test('provisionUser returns one unchanged user for concurrent callbacks', async () => {
|
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,33 @@ 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';
|
|
49
52
|
|
|
50
53
|
const RESET_TTL_HOURS = 1;
|
|
51
54
|
const PASSWORD_VERIFICATION_PURPOSE = 'password-verification';
|
|
55
|
+
const DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE =
|
|
56
|
+
'This Shipfox deployment does not accept new accounts right now.';
|
|
57
|
+
|
|
58
|
+
const defaultSignupPolicy: SignupPolicy = {
|
|
59
|
+
isSignupAllowed: async () => ({allowed: true}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function assertSignupAllowed(params: {
|
|
63
|
+
signupPolicy?: SignupPolicy | undefined;
|
|
64
|
+
email: string;
|
|
65
|
+
emailVerified: boolean;
|
|
66
|
+
source: string;
|
|
67
|
+
}): Promise<void> {
|
|
68
|
+
const result = await (params.signupPolicy ?? defaultSignupPolicy).isSignupAllowed({
|
|
69
|
+
email: params.email,
|
|
70
|
+
emailVerified: params.emailVerified,
|
|
71
|
+
source: params.source,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!result.allowed) {
|
|
75
|
+
throw new SignupNotAllowedError(result.message ?? DEFAULT_SIGNUP_NOT_ALLOWED_MESSAGE);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
52
78
|
|
|
53
79
|
let dummyHashCache: string | undefined;
|
|
54
80
|
async function getDummyHash(): Promise<string> {
|
|
@@ -114,11 +140,12 @@ async function createRefreshSession(
|
|
|
114
140
|
async function createSessionTokens(
|
|
115
141
|
user: User,
|
|
116
142
|
workspaces: WorkspacesInterModuleClient,
|
|
117
|
-
): Promise<{token: string; refreshToken: string}> {
|
|
143
|
+
): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
|
|
118
144
|
const refreshSessionId = crypto.randomUUID();
|
|
119
145
|
const token = await signAccessToken(user, workspaces, refreshSessionId);
|
|
146
|
+
const adminRole = await getCurrentAdminRole({userId: user.id});
|
|
120
147
|
const {refreshToken} = await createRefreshSession(user, refreshSessionId);
|
|
121
|
-
return {token, refreshToken};
|
|
148
|
+
return {token, refreshToken, adminRole};
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
function passwordResetLink(rawToken: string): string {
|
|
@@ -156,11 +183,13 @@ export interface SignupParams {
|
|
|
156
183
|
email: string;
|
|
157
184
|
password: string;
|
|
158
185
|
name?: string | undefined;
|
|
186
|
+
signupPolicy?: SignupPolicy | undefined;
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
export interface ProvisionUserParams {
|
|
162
190
|
email: string;
|
|
163
191
|
name?: string | null | undefined;
|
|
192
|
+
signupPolicy?: SignupPolicy | undefined;
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
/**
|
|
@@ -168,8 +197,19 @@ export interface ProvisionUserParams {
|
|
|
168
197
|
* Existing users are returned unchanged, including their password and profile.
|
|
169
198
|
*/
|
|
170
199
|
export async function provisionUser(params: ProvisionUserParams): Promise<User> {
|
|
200
|
+
const email = emailSchema.parse(params.email);
|
|
201
|
+
const existing = await findUserByEmail({email});
|
|
202
|
+
if (existing) return existing;
|
|
203
|
+
|
|
204
|
+
await assertSignupAllowed({
|
|
205
|
+
signupPolicy: params.signupPolicy,
|
|
206
|
+
email,
|
|
207
|
+
emailVerified: true,
|
|
208
|
+
source: 'external-identity',
|
|
209
|
+
});
|
|
210
|
+
|
|
171
211
|
return await provisionDbUser({
|
|
172
|
-
email
|
|
212
|
+
email,
|
|
173
213
|
name: params.name ?? null,
|
|
174
214
|
});
|
|
175
215
|
}
|
|
@@ -179,7 +219,8 @@ export type SignupResult = User & {
|
|
|
179
219
|
};
|
|
180
220
|
|
|
181
221
|
export async function signup(params: SignupParams & {sourceIp?: string}): Promise<SignupResult> {
|
|
182
|
-
const
|
|
222
|
+
const email = emailSchema.parse(params.email);
|
|
223
|
+
const existing = await findUserByEmail({email});
|
|
183
224
|
if (existing) {
|
|
184
225
|
const canResumeVerification =
|
|
185
226
|
existing.status === 'active' &&
|
|
@@ -196,12 +237,19 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
|
|
|
196
237
|
});
|
|
197
238
|
return {...existing, emailChallenge};
|
|
198
239
|
}
|
|
199
|
-
throw new EmailTakenError(
|
|
240
|
+
throw new EmailTakenError(email);
|
|
200
241
|
}
|
|
201
242
|
|
|
243
|
+
await assertSignupAllowed({
|
|
244
|
+
signupPolicy: params.signupPolicy,
|
|
245
|
+
email,
|
|
246
|
+
emailVerified: false,
|
|
247
|
+
source: 'password',
|
|
248
|
+
});
|
|
249
|
+
|
|
202
250
|
const hashedPassword = await hashPassword({password: params.password});
|
|
203
251
|
const user = await createDbUser({
|
|
204
|
-
email
|
|
252
|
+
email,
|
|
205
253
|
hashedPassword,
|
|
206
254
|
name: params.name ?? null,
|
|
207
255
|
signedUp: {viaInvitation: false},
|
|
@@ -289,12 +337,12 @@ export async function signupWithInvitation(
|
|
|
289
337
|
|
|
290
338
|
// Step 4: Issue session. signAccessToken reads memberships through the
|
|
291
339
|
// workspaces module API, so a successful accept is reflected in the JWT.
|
|
292
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
340
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
293
341
|
|
|
294
342
|
if (acceptError) {
|
|
295
|
-
return {token, refreshToken, user, membership, acceptError};
|
|
343
|
+
return {token, refreshToken, user, membership, acceptError, adminRole};
|
|
296
344
|
}
|
|
297
|
-
return {token, refreshToken, user, membership};
|
|
345
|
+
return {token, refreshToken, user, membership, adminRole};
|
|
298
346
|
}
|
|
299
347
|
|
|
300
348
|
export interface CreateUserParams extends SignupParams {
|
|
@@ -328,6 +376,7 @@ export interface LoginResult {
|
|
|
328
376
|
token: string;
|
|
329
377
|
refreshToken: string;
|
|
330
378
|
user: User;
|
|
379
|
+
adminRole: AdminRole | null;
|
|
331
380
|
}
|
|
332
381
|
|
|
333
382
|
export async function login(params: LoginParams): Promise<LoginResult> {
|
|
@@ -351,9 +400,9 @@ export async function login(params: LoginParams): Promise<LoginResult> {
|
|
|
351
400
|
throw new EmailNotVerifiedError();
|
|
352
401
|
}
|
|
353
402
|
|
|
354
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
403
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
355
404
|
|
|
356
|
-
return {token, refreshToken, user};
|
|
405
|
+
return {token, refreshToken, user, adminRole};
|
|
357
406
|
}
|
|
358
407
|
|
|
359
408
|
export interface CreateSessionForUserParams {
|
|
@@ -366,6 +415,7 @@ export interface CreateSessionForUserResult {
|
|
|
366
415
|
token: string;
|
|
367
416
|
refreshToken: string;
|
|
368
417
|
user: User;
|
|
418
|
+
adminRole: AdminRole | null;
|
|
369
419
|
}
|
|
370
420
|
|
|
371
421
|
export type CreateSessionForUserError =
|
|
@@ -393,9 +443,9 @@ export async function createSessionForUser(
|
|
|
393
443
|
throw new InvalidCredentialsError();
|
|
394
444
|
}
|
|
395
445
|
|
|
396
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
446
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
397
447
|
|
|
398
|
-
return {token, refreshToken, user};
|
|
448
|
+
return {token, refreshToken, user, adminRole};
|
|
399
449
|
}
|
|
400
450
|
|
|
401
451
|
export interface RefreshAccessTokenResult {
|
|
@@ -403,6 +453,7 @@ export interface RefreshAccessTokenResult {
|
|
|
403
453
|
/** Undefined on a grace-window hit: keep the existing cookie instead of rotating it. */
|
|
404
454
|
refreshToken: string | undefined;
|
|
405
455
|
user: User;
|
|
456
|
+
adminRole: AdminRole | null;
|
|
406
457
|
}
|
|
407
458
|
|
|
408
459
|
export async function refreshAccessToken(params: {
|
|
@@ -422,6 +473,7 @@ export async function refreshAccessToken(params: {
|
|
|
422
473
|
recordRefreshOutcome('rejected');
|
|
423
474
|
throw new TokenInvalidError('Refresh token is invalid or expired');
|
|
424
475
|
}
|
|
476
|
+
const adminRole = await getCurrentAdminRole({userId: user.id});
|
|
425
477
|
|
|
426
478
|
// Within the grace window a rotated token means a concurrent refresh (e.g. a
|
|
427
479
|
// second tab); past it, reuse of a retired token means a compromised session.
|
|
@@ -429,7 +481,7 @@ export async function refreshAccessToken(params: {
|
|
|
429
481
|
if (isWithinRotationGrace(current)) {
|
|
430
482
|
const token = await signAccessToken(user, params.workspaces, current.sessionId);
|
|
431
483
|
recordRefreshOutcome('grace');
|
|
432
|
-
return {token, refreshToken: undefined, user};
|
|
484
|
+
return {token, refreshToken: undefined, user, adminRole};
|
|
433
485
|
}
|
|
434
486
|
await revokeRefreshTokensForUser({userId: user.id});
|
|
435
487
|
recordRefreshOutcome('rejected');
|
|
@@ -453,18 +505,19 @@ export async function refreshAccessToken(params: {
|
|
|
453
505
|
}
|
|
454
506
|
const token = await signAccessToken(user, params.workspaces, latest.sessionId);
|
|
455
507
|
recordRefreshOutcome('grace');
|
|
456
|
-
return {token, refreshToken: undefined, user};
|
|
508
|
+
return {token, refreshToken: undefined, user, adminRole};
|
|
457
509
|
}
|
|
458
510
|
|
|
459
511
|
const token = await signAccessToken(user, params.workspaces, current.sessionId);
|
|
460
512
|
recordRefreshOutcome('rotated');
|
|
461
|
-
return {token, refreshToken: nextRefreshToken, user};
|
|
513
|
+
return {token, refreshToken: nextRefreshToken, user, adminRole};
|
|
462
514
|
}
|
|
463
515
|
|
|
464
516
|
export interface ConfirmEmailVerificationResult {
|
|
465
517
|
token: string;
|
|
466
518
|
refreshToken: string;
|
|
467
519
|
user: User;
|
|
520
|
+
adminRole: AdminRole | null;
|
|
468
521
|
}
|
|
469
522
|
|
|
470
523
|
export interface ResendEmailVerificationResult {
|
|
@@ -491,9 +544,12 @@ export async function confirmEmailVerification(params: {
|
|
|
491
544
|
throw new TokenInvalidError('Verification code is invalid or expired');
|
|
492
545
|
}
|
|
493
546
|
|
|
494
|
-
const {token, refreshToken} = await createSessionTokens(
|
|
547
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(
|
|
548
|
+
verifiedUser,
|
|
549
|
+
params.workspaces,
|
|
550
|
+
);
|
|
495
551
|
|
|
496
|
-
return {token, refreshToken, user: verifiedUser};
|
|
552
|
+
return {token, refreshToken, user: verifiedUser, adminRole};
|
|
497
553
|
}
|
|
498
554
|
|
|
499
555
|
export async function resendEmailVerification(params: {
|
|
@@ -537,6 +593,7 @@ export interface ConfirmPasswordResetResult {
|
|
|
537
593
|
token: string;
|
|
538
594
|
refreshToken: string;
|
|
539
595
|
user: User;
|
|
596
|
+
adminRole: AdminRole | null;
|
|
540
597
|
}
|
|
541
598
|
|
|
542
599
|
export async function confirmPasswordReset(params: {
|
|
@@ -562,9 +619,9 @@ export async function confirmPasswordReset(params: {
|
|
|
562
619
|
|
|
563
620
|
await revokeRefreshTokensForUser({userId: consumed.userId});
|
|
564
621
|
|
|
565
|
-
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
622
|
+
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
566
623
|
|
|
567
|
-
return {token, refreshToken, user};
|
|
624
|
+
return {token, refreshToken, user, adminRole};
|
|
568
625
|
}
|
|
569
626
|
|
|
570
627
|
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,23 @@ 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
|
+
|
|
76
100
|
export class TokenInvalidError extends Error {
|
|
77
101
|
constructor(reason?: string) {
|
|
78
102
|
super(reason ? `Invalid token: ${reason}` : 'Invalid token');
|