@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
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
"when": 1784102113104,
|
|
9
9
|
"tag": "0000_initial",
|
|
10
10
|
"breakpoints": true
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"idx": 1,
|
|
14
|
+
"version": "7",
|
|
15
|
+
"when": 1785065259979,
|
|
16
|
+
"tag": "0001_brainy_shriek",
|
|
17
|
+
"breakpoints": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"idx": 2,
|
|
21
|
+
"version": "7",
|
|
22
|
+
"when": 1785075183023,
|
|
23
|
+
"tag": "0002_burly_whizzer",
|
|
24
|
+
"breakpoints": true
|
|
11
25
|
}
|
|
12
26
|
]
|
|
13
27
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.3.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -26,20 +26,21 @@
|
|
|
26
26
|
"@node-rs/argon2": "^2.0.2",
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@shipfox/api-
|
|
30
|
-
"@shipfox/api-auth-
|
|
31
|
-
"@shipfox/api-
|
|
32
|
-
"@shipfox/api-
|
|
29
|
+
"@shipfox/api-common-dto": "9.2.0",
|
|
30
|
+
"@shipfox/api-auth-context": "9.3.0",
|
|
31
|
+
"@shipfox/api-auth-dto": "9.3.0",
|
|
32
|
+
"@shipfox/api-email-challenges": "1.1.5",
|
|
33
|
+
"@shipfox/api-workspaces-dto": "9.3.0",
|
|
33
34
|
"@shipfox/inter-module": "0.2.2",
|
|
34
35
|
"@shipfox/config": "1.2.4",
|
|
35
36
|
"@shipfox/node-drizzle": "0.3.4",
|
|
36
37
|
"@shipfox/node-auth-root-key": "0.2.3",
|
|
37
|
-
"@shipfox/node-fastify": "0.3.
|
|
38
|
+
"@shipfox/node-fastify": "0.3.4",
|
|
38
39
|
"@shipfox/node-jwt": "0.3.2",
|
|
39
40
|
"@shipfox/node-email": "0.3.4",
|
|
40
|
-
"@shipfox/node-mailer": "0.2.
|
|
41
|
-
"@shipfox/node-module": "1.0.
|
|
42
|
-
"@shipfox/node-opentelemetry": "0.6.
|
|
41
|
+
"@shipfox/node-mailer": "0.2.4",
|
|
42
|
+
"@shipfox/node-module": "1.0.3",
|
|
43
|
+
"@shipfox/node-opentelemetry": "0.6.3",
|
|
43
44
|
"@shipfox/node-outbox": "0.2.6",
|
|
44
45
|
"@shipfox/node-postgres": "0.4.4",
|
|
45
46
|
"@shipfox/node-rate-limit": "0.3.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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import {bool, createConfig, num, str} from '@shipfox/config';
|
|
2
2
|
|
|
3
3
|
export const config = createConfig({
|
|
4
|
+
ADMIN_BOOTSTRAP_TOKEN: str({
|
|
5
|
+
desc: 'Deployment secret accepted once to create the first administrator owner. Set it before bootstrap and remove or rotate it after successful bootstrap.',
|
|
6
|
+
default: undefined,
|
|
7
|
+
}),
|
|
4
8
|
AUTH_JWT_EXPIRES_IN: str({
|
|
5
9
|
desc: 'How long an access token stays valid. Accepts a duration string such as 15m, 1h, or 7d.',
|
|
6
10
|
default: '15m',
|
|
@@ -29,8 +33,38 @@ export const config = createConfig({
|
|
|
29
33
|
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
34
|
default: true,
|
|
31
35
|
}),
|
|
36
|
+
AUTH_SIGNUP_GATE_ENABLED: bool({
|
|
37
|
+
desc: 'Whether new account creation is restricted to the configured signup email allowlist. Defaults to false, which allows every signup.',
|
|
38
|
+
default: false,
|
|
39
|
+
}),
|
|
40
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: str({
|
|
41
|
+
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.',
|
|
42
|
+
default: '',
|
|
43
|
+
}),
|
|
44
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: str({
|
|
45
|
+
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.',
|
|
46
|
+
default: '',
|
|
47
|
+
}),
|
|
48
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: str({
|
|
49
|
+
desc: 'Description returned when the signup gate blocks an account. Optional. The default is This Shipfox deployment does not accept new accounts right now.',
|
|
50
|
+
default: undefined,
|
|
51
|
+
}),
|
|
32
52
|
CLIENT_BASE_URL: str({
|
|
33
53
|
desc: 'Base URL of the client app. Used to build links in emails such as password resets.',
|
|
34
54
|
default: 'http://localhost:5173',
|
|
35
55
|
}),
|
|
36
56
|
});
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
config.AUTH_SIGNUP_GATE_ENABLED &&
|
|
60
|
+
!hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS) &&
|
|
61
|
+
!hasSignupAllowlistEntry(config.AUTH_SIGNUP_ALLOWED_EMAILS)
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'AUTH_SIGNUP_GATE_ENABLED requires AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS or AUTH_SIGNUP_ALLOWED_EMAILS to be set.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasSignupAllowlistEntry(value: string): boolean {
|
|
69
|
+
return value.split(',').some((entry) => entry.trim().length > 0);
|
|
70
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {createHash, timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
3
|
+
import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
4
|
+
import {config} from '#config.js';
|
|
5
|
+
import {
|
|
6
|
+
bootstrapFirstAdminOwner as bootstrapFirstAdminOwnerInDb,
|
|
7
|
+
grantAdminRoleWithAudit,
|
|
8
|
+
listAdminGrants,
|
|
9
|
+
revokeAdminGrantWithAudit,
|
|
10
|
+
} from '#db/admin-grants.js';
|
|
11
|
+
import {requireAdminRole} from './admin-role.js';
|
|
12
|
+
import type {AdminGrant} from './entities/admin-grant.js';
|
|
13
|
+
import {
|
|
14
|
+
AdminBootstrapClosedError,
|
|
15
|
+
AdminGrantAlreadyExistsError,
|
|
16
|
+
AdminGrantNotFoundError,
|
|
17
|
+
AdminIdempotencyKeyReuseError,
|
|
18
|
+
InvalidAdminBootstrapTokenError,
|
|
19
|
+
LastAdminOwnerError,
|
|
20
|
+
UserNotFoundError,
|
|
21
|
+
} from './errors.js';
|
|
22
|
+
|
|
23
|
+
const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
|
|
24
|
+
const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
|
|
25
|
+
const GRANT_COMMAND = 'auth.admin_grant.grant';
|
|
26
|
+
const REVOKE_COMMAND = 'auth.admin_grant.revoke';
|
|
27
|
+
|
|
28
|
+
export function hashAdministrationValue(value: string): string {
|
|
29
|
+
return createHash('sha256').update(value).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function administrationCommandFingerprint(command: string, input: unknown): string {
|
|
33
|
+
return hashAdministrationValue(`${command}:${JSON.stringify(input)}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bootstrapTokenMatches(candidate: string, expected: string | undefined): boolean {
|
|
37
|
+
if (!expected) return false;
|
|
38
|
+
const candidateHash = Buffer.from(hashAdministrationValue(candidate), 'hex');
|
|
39
|
+
const expectedHash = Buffer.from(hashAdministrationValue(expected), 'hex');
|
|
40
|
+
return timingSafeEqual(candidateHash, expectedHash);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function administrationEvent(params: {
|
|
44
|
+
actorId: string;
|
|
45
|
+
actorRole: AdminRole;
|
|
46
|
+
command: string;
|
|
47
|
+
targetType: string;
|
|
48
|
+
targetId: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
correlationId: string;
|
|
51
|
+
idempotencyKeyFingerprint: string;
|
|
52
|
+
}) {
|
|
53
|
+
return createAdministrationActionEvent({
|
|
54
|
+
actorId: params.actorId,
|
|
55
|
+
actorRole: params.actorRole,
|
|
56
|
+
requiredRole: ADMIN_OWNER_ROLE,
|
|
57
|
+
command: params.command,
|
|
58
|
+
targetType: params.targetType,
|
|
59
|
+
targetId: params.targetId,
|
|
60
|
+
reason: params.reason,
|
|
61
|
+
result: 'succeeded',
|
|
62
|
+
correlationId: params.correlationId,
|
|
63
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
64
|
+
occurredAt: new Date().toISOString(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AdministrationMutationContext {
|
|
69
|
+
actorId: string;
|
|
70
|
+
idempotencyKey: string;
|
|
71
|
+
correlationId: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function bootstrapFirstAdminOwner(
|
|
75
|
+
params: AdministrationMutationContext & {bootstrapToken: string},
|
|
76
|
+
): Promise<AdminGrant> {
|
|
77
|
+
if (!bootstrapTokenMatches(params.bootstrapToken, config.ADMIN_BOOTSTRAP_TOKEN)) {
|
|
78
|
+
throw new InvalidAdminBootstrapTokenError();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return await bootstrapFirstAdminOwnerInDb({
|
|
82
|
+
userId: params.actorId,
|
|
83
|
+
actorId: params.actorId,
|
|
84
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
85
|
+
requestFingerprint: administrationCommandFingerprint(BOOTSTRAP_COMMAND, {
|
|
86
|
+
actorId: params.actorId,
|
|
87
|
+
}),
|
|
88
|
+
event: administrationEvent({
|
|
89
|
+
actorId: params.actorId,
|
|
90
|
+
actorRole: ADMIN_OWNER_ROLE,
|
|
91
|
+
command: BOOTSTRAP_COMMAND,
|
|
92
|
+
targetType: 'user',
|
|
93
|
+
targetId: params.actorId,
|
|
94
|
+
reason: 'Initial administrator owner bootstrap',
|
|
95
|
+
correlationId: params.correlationId,
|
|
96
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function listAdministratorGrants(params: {actorId: string}): Promise<AdminGrant[]> {
|
|
102
|
+
await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OWNER_ROLE});
|
|
103
|
+
return await listAdminGrants();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function grantAdministratorRole(
|
|
107
|
+
params: AdministrationMutationContext & {userId: string; role: AdminRole; reason: string},
|
|
108
|
+
): Promise<AdminGrant> {
|
|
109
|
+
const actorRole = await requireAdminRole({
|
|
110
|
+
userId: params.actorId,
|
|
111
|
+
minimumRole: ADMIN_OWNER_ROLE,
|
|
112
|
+
});
|
|
113
|
+
return await grantAdminRoleWithAudit({
|
|
114
|
+
userId: params.userId,
|
|
115
|
+
role: params.role,
|
|
116
|
+
actorId: params.actorId,
|
|
117
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
118
|
+
requestFingerprint: administrationCommandFingerprint(GRANT_COMMAND, {
|
|
119
|
+
userId: params.userId,
|
|
120
|
+
role: params.role,
|
|
121
|
+
reason: params.reason,
|
|
122
|
+
}),
|
|
123
|
+
event: administrationEvent({
|
|
124
|
+
actorId: params.actorId,
|
|
125
|
+
actorRole,
|
|
126
|
+
command: GRANT_COMMAND,
|
|
127
|
+
targetType: 'user',
|
|
128
|
+
targetId: params.userId,
|
|
129
|
+
reason: params.reason,
|
|
130
|
+
correlationId: params.correlationId,
|
|
131
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function revokeAdministratorGrant(
|
|
137
|
+
params: AdministrationMutationContext & {grantId: string; reason: string},
|
|
138
|
+
): Promise<AdminGrant> {
|
|
139
|
+
const actorRole = await requireAdminRole({
|
|
140
|
+
userId: params.actorId,
|
|
141
|
+
minimumRole: ADMIN_OWNER_ROLE,
|
|
142
|
+
});
|
|
143
|
+
return await revokeAdminGrantWithAudit({
|
|
144
|
+
grantId: params.grantId,
|
|
145
|
+
actorId: params.actorId,
|
|
146
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
147
|
+
requestFingerprint: administrationCommandFingerprint(REVOKE_COMMAND, {
|
|
148
|
+
grantId: params.grantId,
|
|
149
|
+
reason: params.reason,
|
|
150
|
+
}),
|
|
151
|
+
event: administrationEvent({
|
|
152
|
+
actorId: params.actorId,
|
|
153
|
+
actorRole,
|
|
154
|
+
command: REVOKE_COMMAND,
|
|
155
|
+
targetType: 'admin-grant',
|
|
156
|
+
targetId: params.grantId,
|
|
157
|
+
reason: params.reason,
|
|
158
|
+
correlationId: params.correlationId,
|
|
159
|
+
idempotencyKeyFingerprint: hashAdministrationValue(params.idempotencyKey),
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export {
|
|
165
|
+
AdminBootstrapClosedError,
|
|
166
|
+
AdminGrantAlreadyExistsError,
|
|
167
|
+
AdminGrantNotFoundError,
|
|
168
|
+
AdminIdempotencyKeyReuseError,
|
|
169
|
+
InvalidAdminBootstrapTokenError,
|
|
170
|
+
LastAdminOwnerError,
|
|
171
|
+
UserNotFoundError,
|
|
172
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {provisionUser, signup} from '#core/auth.js';
|
|
2
|
+
|
|
3
|
+
const testConfig = vi.hoisted(() => ({
|
|
4
|
+
AUTH_JWT_EXPIRES_IN: '15m',
|
|
5
|
+
AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
|
|
6
|
+
AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
|
|
7
|
+
AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
|
|
8
|
+
AUTH_SIGNUP_GATE_ENABLED: true,
|
|
9
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: 'allowed.example',
|
|
10
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: '',
|
|
11
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined as string | undefined,
|
|
12
|
+
CLIENT_BASE_URL: 'https://app.example.test',
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('#config.js', () => ({config: testConfig}));
|
|
16
|
+
|
|
17
|
+
describe('low-level account creation defaults', () => {
|
|
18
|
+
test('signup uses the environment policy when no policy is supplied', async () => {
|
|
19
|
+
const email = `signup-default-${crypto.randomUUID()}@allowed.example`;
|
|
20
|
+
|
|
21
|
+
const user = await signup({email, password: 'correct horse battery staple'});
|
|
22
|
+
|
|
23
|
+
expect(user.email).toBe(email);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('signup rejects an unlisted email when no policy is supplied', async () => {
|
|
27
|
+
const email = `signup-default-${crypto.randomUUID()}@blocked.example`;
|
|
28
|
+
|
|
29
|
+
await expect(signup({email, password: 'correct horse battery staple'})).rejects.toMatchObject({
|
|
30
|
+
name: 'SignupNotAllowedError',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('provisionUser uses the environment policy when no policy is supplied', async () => {
|
|
35
|
+
const email = `provision-default-${crypto.randomUUID()}@allowed.example`;
|
|
36
|
+
|
|
37
|
+
const user = await provisionUser({email});
|
|
38
|
+
|
|
39
|
+
expect(user.email).toBe(email);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('provisionUser rejects an unlisted email when no policy is supplied', async () => {
|
|
43
|
+
const email = `provision-default-${crypto.randomUUID()}@blocked.example`;
|
|
44
|
+
|
|
45
|
+
await expect(provisionUser({email})).rejects.toMatchObject({
|
|
46
|
+
name: 'SignupNotAllowedError',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('an explicit policy overrides the environment policy for both paths', async () => {
|
|
51
|
+
const signupEmail = `signup-override-${crypto.randomUUID()}@blocked.example`;
|
|
52
|
+
const provisionEmail = `provision-override-${crypto.randomUUID()}@blocked.example`;
|
|
53
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
|
|
54
|
+
const signupPolicy = {isSignupAllowed};
|
|
55
|
+
|
|
56
|
+
await signup({
|
|
57
|
+
email: signupEmail,
|
|
58
|
+
password: 'correct horse battery staple',
|
|
59
|
+
signupPolicy,
|
|
60
|
+
});
|
|
61
|
+
await provisionUser({email: provisionEmail, signupPolicy});
|
|
62
|
+
|
|
63
|
+
expect(isSignupAllowed).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/core/auth.test.ts
CHANGED
|
@@ -61,6 +61,10 @@ vi.mock('#config.js', () => ({
|
|
|
61
61
|
AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
|
|
62
62
|
AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
|
|
63
63
|
AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
|
|
64
|
+
AUTH_SIGNUP_GATE_ENABLED: false,
|
|
65
|
+
AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS: '',
|
|
66
|
+
AUTH_SIGNUP_ALLOWED_EMAILS: '',
|
|
67
|
+
AUTH_SIGNUP_NOT_ALLOWED_MESSAGE: undefined,
|
|
64
68
|
CLIENT_BASE_URL: testConfig.clientBaseUrl,
|
|
65
69
|
},
|
|
66
70
|
mailer: testConfig.mailer,
|
|
@@ -77,6 +81,7 @@ const workspaces = {
|
|
|
77
81
|
preflightInvitationAcceptance: vi.fn(),
|
|
78
82
|
acceptInvitation: vi.fn(),
|
|
79
83
|
requireActiveMembership: vi.fn(),
|
|
84
|
+
getWorkspaceOperatingState: vi.fn(),
|
|
80
85
|
};
|
|
81
86
|
|
|
82
87
|
const login = (params: {email: string; password: string}) => coreLogin({...params, workspaces});
|
|
@@ -160,6 +165,71 @@ describe('auth core', () => {
|
|
|
160
165
|
await expect(promise).rejects.toBeInstanceOf(EmailTakenError);
|
|
161
166
|
});
|
|
162
167
|
|
|
168
|
+
test('signup normalizes the email before checking the policy', async () => {
|
|
169
|
+
const email = `signup-policy-${crypto.randomUUID()}@example.com`;
|
|
170
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
|
|
171
|
+
|
|
172
|
+
await signup({
|
|
173
|
+
email: ` ${email.toUpperCase()} `,
|
|
174
|
+
password: 'correct horse battery staple',
|
|
175
|
+
signupPolicy: {isSignupAllowed},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(isSignupAllowed).toHaveBeenCalledWith({
|
|
179
|
+
email,
|
|
180
|
+
emailVerified: false,
|
|
181
|
+
source: 'password',
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('signup denies a new user when the policy does not allow it', async () => {
|
|
186
|
+
const email = `signup-policy-denied-${crypto.randomUUID()}@example.com`;
|
|
187
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
signup({
|
|
191
|
+
email,
|
|
192
|
+
password: 'correct horse battery staple',
|
|
193
|
+
signupPolicy: {isSignupAllowed},
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toEqual(
|
|
196
|
+
expect.objectContaining({
|
|
197
|
+
name: 'SignupNotAllowedError',
|
|
198
|
+
message: 'This Shipfox deployment does not accept new accounts right now.',
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('signup fails closed when the policy throws', async () => {
|
|
205
|
+
const email = `signup-policy-error-${crypto.randomUUID()}@example.com`;
|
|
206
|
+
const policyError = new Error('policy unavailable');
|
|
207
|
+
const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
|
|
208
|
+
|
|
209
|
+
await expect(
|
|
210
|
+
signup({
|
|
211
|
+
email,
|
|
212
|
+
password: 'correct horse battery staple',
|
|
213
|
+
signupPolicy: {isSignupAllowed},
|
|
214
|
+
}),
|
|
215
|
+
).rejects.toBe(policyError);
|
|
216
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('signup does not check the policy for an existing user', async () => {
|
|
220
|
+
const existing = await userFactory.create({emailVerifiedAt: new Date()});
|
|
221
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
|
|
222
|
+
|
|
223
|
+
await expect(
|
|
224
|
+
signup({
|
|
225
|
+
email: ` ${existing.email.toUpperCase()} `,
|
|
226
|
+
password: 'correct horse battery staple',
|
|
227
|
+
signupPolicy: {isSignupAllowed},
|
|
228
|
+
}),
|
|
229
|
+
).rejects.toBeInstanceOf(EmailTakenError);
|
|
230
|
+
expect(isSignupAllowed).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
|
|
163
233
|
test('signup with an invitation writes the signed-up event with its user insert', async () => {
|
|
164
234
|
const email = `signup-invitation-${crypto.randomUUID()}@example.com`;
|
|
165
235
|
const userId = crypto.randomUUID();
|
|
@@ -175,6 +245,9 @@ describe('auth core', () => {
|
|
|
175
245
|
name: 'Invited User',
|
|
176
246
|
invitationToken: `invite-${crypto.randomUUID()}`,
|
|
177
247
|
workspaces,
|
|
248
|
+
signupPolicy: {
|
|
249
|
+
isSignupAllowed: vi.fn().mockRejectedValue(new Error('policy unavailable')),
|
|
250
|
+
},
|
|
178
251
|
});
|
|
179
252
|
|
|
180
253
|
const events = await outboxEventsTo(email, AUTH_USER_SIGNED_UP);
|
|
@@ -199,24 +272,66 @@ describe('auth core', () => {
|
|
|
199
272
|
expect(user.status).toBe('active');
|
|
200
273
|
});
|
|
201
274
|
|
|
275
|
+
test('provisionUser normalizes the email before checking the policy', async () => {
|
|
276
|
+
const email = `provision-policy-${crypto.randomUUID()}@example.com`;
|
|
277
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: true});
|
|
278
|
+
|
|
279
|
+
await provisionUser({
|
|
280
|
+
email: ` ${email.toUpperCase()} `,
|
|
281
|
+
signupPolicy: {isSignupAllowed},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(isSignupAllowed).toHaveBeenCalledWith({
|
|
285
|
+
email,
|
|
286
|
+
emailVerified: true,
|
|
287
|
+
source: 'external-identity',
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('provisionUser denies a new user when the policy does not allow it', async () => {
|
|
292
|
+
const email = `provision-policy-denied-${crypto.randomUUID()}@example.com`;
|
|
293
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false, message: 'Closed beta'});
|
|
294
|
+
|
|
295
|
+
await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toEqual(
|
|
296
|
+
expect.objectContaining({
|
|
297
|
+
name: 'SignupNotAllowedError',
|
|
298
|
+
message: 'Closed beta',
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('provisionUser fails closed when the policy throws', async () => {
|
|
305
|
+
const email = `provision-policy-error-${crypto.randomUUID()}@example.com`;
|
|
306
|
+
const policyError = new Error('policy unavailable');
|
|
307
|
+
const isSignupAllowed = vi.fn().mockRejectedValue(policyError);
|
|
308
|
+
|
|
309
|
+
await expect(provisionUser({email, signupPolicy: {isSignupAllowed}})).rejects.toBe(policyError);
|
|
310
|
+
expect(await findUserByEmail({email})).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
202
313
|
test('provisionUser returns existing unverified and suspended users unchanged', async () => {
|
|
203
314
|
const unverified = await userFactory.create();
|
|
204
315
|
const suspended = await userFactory.create({emailVerifiedAt: new Date()});
|
|
205
316
|
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
|
|
206
317
|
const storedUnverified = await findUserById({id: unverified.id});
|
|
207
318
|
const storedSuspended = await findUserById({id: suspended.id});
|
|
319
|
+
const isSignupAllowed = vi.fn().mockResolvedValue({allowed: false});
|
|
208
320
|
|
|
209
321
|
const existingUnverified = await provisionUser({
|
|
210
322
|
email: ` ${unverified.email.toUpperCase()} `,
|
|
211
323
|
name: 'Replacement Name',
|
|
324
|
+
signupPolicy: {isSignupAllowed},
|
|
212
325
|
});
|
|
213
326
|
const existingSuspended = await provisionUser({
|
|
214
327
|
email: ` ${suspended.email.toUpperCase()} `,
|
|
215
328
|
name: 'Replacement Name',
|
|
329
|
+
signupPolicy: {isSignupAllowed},
|
|
216
330
|
});
|
|
217
331
|
|
|
218
332
|
expect(existingUnverified).toEqual(storedUnverified);
|
|
219
333
|
expect(existingSuspended).toEqual(storedSuspended);
|
|
334
|
+
expect(isSignupAllowed).not.toHaveBeenCalled();
|
|
220
335
|
});
|
|
221
336
|
|
|
222
337
|
test('provisionUser returns one unchanged user for concurrent callbacks', async () => {
|