@shipfox/api-auth 18.0.0 → 20.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +47 -0
- package/README.md +24 -9
- package/dist/core/administration.d.ts +15 -0
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +44 -2
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +5 -3
- package/dist/core/auth.js.map +1 -1
- package/dist/core/cimd.d.ts +39 -0
- package/dist/core/cimd.d.ts.map +1 -0
- package/dist/core/cimd.js +392 -0
- package/dist/core/cimd.js.map +1 -0
- package/dist/core/entities/agent-access.d.ts +75 -0
- package/dist/core/entities/agent-access.d.ts.map +1 -0
- package/dist/core/entities/agent-access.js +3 -0
- package/dist/core/entities/agent-access.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +32 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/oauth-client-resolver.d.ts +54 -0
- package/dist/core/oauth-client-resolver.d.ts.map +1 -0
- package/dist/core/oauth-client-resolver.js +154 -0
- package/dist/core/oauth-client-resolver.js.map +1 -0
- package/dist/core/oauth-client.d.ts +39 -0
- package/dist/core/oauth-client.d.ts.map +1 -0
- package/dist/core/oauth-client.js +236 -0
- package/dist/core/oauth-client.js.map +1 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +30 -26
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/admin-user-summary.d.ts +22 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -1
- package/dist/db/admin-user-summary.js +75 -1
- package/dist/db/admin-user-summary.js.map +1 -1
- package/dist/db/admin-users.d.ts +11 -0
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +4 -1
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/agent-access-retention.d.ts +10 -0
- package/dist/db/agent-access-retention.d.ts.map +1 -0
- package/dist/db/agent-access-retention.js +11 -0
- package/dist/db/agent-access-retention.js.map +1 -0
- package/dist/db/agent-access.d.ts +255 -0
- package/dist/db/agent-access.d.ts.map +1 -0
- package/dist/db/agent-access.js +705 -0
- package/dist/db/agent-access.js.map +1 -0
- package/dist/db/db.d.ts +2604 -238
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +7 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/agent-access.d.ts +1204 -0
- package/dist/db/schema/agent-access.d.ts.map +1 -0
- package/dist/db/schema/agent-access.js +267 -0
- package/dist/db/schema/agent-access.js.map +1 -0
- package/dist/db/schema/users.d.ts.map +1 -1
- package/dist/db/schema/users.js +8 -3
- package/dist/db/schema/users.js.map +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -2
- 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/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +123 -20
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
- package/dist/presentation/routes/oauth.d.ts +9 -0
- package/dist/presentation/routes/oauth.d.ts.map +1 -0
- package/dist/presentation/routes/oauth.js +150 -0
- package/dist/presentation/routes/oauth.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +34 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
- package/dist/presentation/routes/registration/signup.js +59 -50
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
- package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
- package/dist/temporal/activities/agent-access-retention.js +58 -0
- package/dist/temporal/activities/agent-access-retention.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +5 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +8 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +3 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
- package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
- package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25227 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0003_brainy_luckman.sql +6 -0
- package/drizzle/0004_yummy_runaways.sql +110 -0
- package/drizzle/meta/0003_snapshot.json +844 -0
- package/drizzle/meta/0004_snapshot.json +1751 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +11 -8
- package/src/core/admin-role.test.ts +70 -0
- package/src/core/administration.ts +78 -1
- package/src/core/auth.ts +3 -5
- package/src/core/cimd.test.ts +213 -0
- package/src/core/cimd.ts +532 -0
- package/src/core/entities/agent-access.ts +80 -0
- package/src/core/errors.ts +48 -0
- package/src/core/oauth-client-resolver.test.ts +198 -0
- package/src/core/oauth-client-resolver.ts +253 -0
- package/src/core/oauth-client.test.ts +157 -0
- package/src/core/oauth-client.ts +331 -0
- package/src/db/admin-user-moderation.ts +53 -45
- package/src/db/admin-user-summary.test.ts +228 -0
- package/src/db/admin-user-summary.ts +143 -1
- package/src/db/admin-users.ts +21 -1
- package/src/db/agent-access-retention.ts +10 -0
- package/src/db/agent-access.test.ts +1064 -0
- package/src/db/agent-access.ts +1491 -0
- package/src/db/db.ts +14 -0
- package/src/db/schema/agent-access.ts +273 -0
- package/src/db/schema/users.ts +9 -3
- package/src/index.test.ts +27 -0
- package/src/index.ts +82 -0
- package/src/metrics/instance.ts +6 -1
- package/src/presentation/routes/administration.test.ts +359 -2
- package/src/presentation/routes/administration.ts +142 -20
- package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
- package/src/presentation/routes/oauth.test.ts +133 -0
- package/src/presentation/routes/oauth.ts +144 -0
- package/src/presentation/routes/rate-limit.ts +16 -0
- package/src/presentation/routes/registration/signup.ts +52 -41
- package/src/temporal/activities/agent-access-retention.ts +76 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/constants.ts +1 -0
- package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/test/globalSetup.ts +1 -1
- package/test/routes.ts +3 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
"when": 1785075183023,
|
|
23
23
|
"tag": "0002_burly_whizzer",
|
|
24
24
|
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1788171920746,
|
|
30
|
+
"tag": "0003_brainy_luckman",
|
|
31
|
+
"breakpoints": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"idx": 4,
|
|
35
|
+
"version": "7",
|
|
36
|
+
"when": 1788195662870,
|
|
37
|
+
"tag": "0004_yummy_runaways",
|
|
38
|
+
"breakpoints": true
|
|
25
39
|
}
|
|
26
40
|
]
|
|
27
41
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "20.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -24,12 +24,14 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@fastify/cookie": "^11.0.2",
|
|
26
26
|
"@node-rs/argon2": "^2.0.2",
|
|
27
|
+
"@temporalio/activity": "1.18.1",
|
|
28
|
+
"@temporalio/workflow": "1.18.1",
|
|
27
29
|
"drizzle-orm": "^0.45.2",
|
|
28
30
|
"zod": "^4.4.3",
|
|
29
31
|
"@shipfox/api-common-dto": "15.0.0",
|
|
30
|
-
"@shipfox/api-auth-context": "
|
|
31
|
-
"@shipfox/api-auth-dto": "
|
|
32
|
-
"@shipfox/api-email-challenges": "1.1.
|
|
32
|
+
"@shipfox/api-auth-context": "20.0.0",
|
|
33
|
+
"@shipfox/api-auth-dto": "20.0.0",
|
|
34
|
+
"@shipfox/api-email-challenges": "1.1.12",
|
|
33
35
|
"@shipfox/api-workspaces-dto": "15.0.0",
|
|
34
36
|
"@shipfox/inter-module": "0.2.3",
|
|
35
37
|
"@shipfox/config": "1.2.4",
|
|
@@ -39,12 +41,13 @@
|
|
|
39
41
|
"@shipfox/node-jwt": "0.4.0",
|
|
40
42
|
"@shipfox/node-email": "0.3.5",
|
|
41
43
|
"@shipfox/node-mailer": "0.2.6",
|
|
42
|
-
"@shipfox/node-module": "1.0.
|
|
44
|
+
"@shipfox/node-module": "1.0.8",
|
|
43
45
|
"@shipfox/node-opentelemetry": "0.6.5",
|
|
44
|
-
"@shipfox/node-outbox": "0.2.
|
|
46
|
+
"@shipfox/node-outbox": "0.2.7",
|
|
45
47
|
"@shipfox/node-postgres": "0.5.1",
|
|
46
48
|
"@shipfox/node-rate-limit": "0.4.0",
|
|
47
|
-
"@shipfox/node-
|
|
49
|
+
"@shipfox/node-temporal": "0.4.6",
|
|
50
|
+
"@shipfox/node-tokens": "1.1.0"
|
|
48
51
|
},
|
|
49
52
|
"imports": {
|
|
50
53
|
"#*": "./dist/*"
|
|
@@ -58,7 +61,7 @@
|
|
|
58
61
|
}
|
|
59
62
|
},
|
|
60
63
|
"scripts": {
|
|
61
|
-
"build": "shipfox-swc",
|
|
64
|
+
"build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
|
|
62
65
|
"check": "shipfox-biome-check",
|
|
63
66
|
"check:fix": "shipfox-biome-check --write",
|
|
64
67
|
"depcruise": "shipfox-depcruise",
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import {createAdminGrant} from '#db/admin-grants.js';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {users} from '#db/schema/users.js';
|
|
2
4
|
import {userFactory} from '#test/index.js';
|
|
3
5
|
import {hasMinimumAdminRole, highestAdminRole, requireAdminRole} from './admin-role.js';
|
|
6
|
+
import {listAdministratorUsers} from './administration.js';
|
|
4
7
|
import type {AdminRoleRequiredError} from './errors.js';
|
|
8
|
+
import {InvalidAdministratorUserDirectoryFilterError} from './errors.js';
|
|
5
9
|
|
|
6
10
|
describe('admin role policy', () => {
|
|
7
11
|
test.each([
|
|
@@ -23,6 +27,72 @@ describe('admin role policy', () => {
|
|
|
23
27
|
expect(highestAdminRole([])).toBeNull();
|
|
24
28
|
});
|
|
25
29
|
|
|
30
|
+
test('protects the administrator user directory with the observer role', async () => {
|
|
31
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
32
|
+
|
|
33
|
+
await expect(listAdministratorUsers({actorId: user.id, limit: 10})).rejects.toEqual(
|
|
34
|
+
expect.objectContaining<Partial<AdminRoleRequiredError>>({
|
|
35
|
+
minimumRole: 'admin-observer',
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('bounds administrator user directory search input', async () => {
|
|
41
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
42
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
43
|
+
|
|
44
|
+
await expect(
|
|
45
|
+
listAdministratorUsers({
|
|
46
|
+
actorId: user.id,
|
|
47
|
+
limit: 10,
|
|
48
|
+
search: Array.from({length: 11}, () => 'term').join(' '),
|
|
49
|
+
}),
|
|
50
|
+
).rejects.toThrow('at most 10 terms');
|
|
51
|
+
await expect(
|
|
52
|
+
listAdministratorUsers({actorId: user.id, limit: 10, search: 'x'.repeat(101)}),
|
|
53
|
+
).rejects.toThrow('at most 100 characters');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('rejects an active eligibility filter combined with a non-active status', async () => {
|
|
57
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
58
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
listAdministratorUsers({
|
|
62
|
+
actorId: user.id,
|
|
63
|
+
limit: 10,
|
|
64
|
+
eligible: true,
|
|
65
|
+
status: 'suspended',
|
|
66
|
+
}),
|
|
67
|
+
).rejects.toBeInstanceOf(InvalidAdministratorUserDirectoryFilterError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('caps the directory page size in the core operation', async () => {
|
|
71
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
72
|
+
await createAdminGrant({userId: user.id, role: 'admin-observer'});
|
|
73
|
+
const marker = `admin-directory-cap-${crypto.randomUUID()}`;
|
|
74
|
+
await db()
|
|
75
|
+
.insert(users)
|
|
76
|
+
.values(
|
|
77
|
+
Array.from({length: 101}, (_, index) => ({
|
|
78
|
+
email: `${marker}-${index}@example.com`,
|
|
79
|
+
name: `Directory User ${index}`,
|
|
80
|
+
hashedPassword: null,
|
|
81
|
+
emailVerifiedAt: new Date(),
|
|
82
|
+
})),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await listAdministratorUsers({
|
|
86
|
+
actorId: user.id,
|
|
87
|
+
limit: 1_000,
|
|
88
|
+
search: marker,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.users).toHaveLength(100);
|
|
92
|
+
expect(result.rows).toEqual(result.users);
|
|
93
|
+
expect(result.nextCursor).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
26
96
|
test('evaluates the current role from Auth storage for every required minimum', async () => {
|
|
27
97
|
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
28
98
|
await createAdminGrant({userId: user.id, role: 'admin-operator'});
|
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
suspendUserWithAudit,
|
|
19
19
|
type UserModerationResult,
|
|
20
20
|
} from '#db/admin-user-moderation.js';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
findAdministratorUser as findAdministratorUserInDb,
|
|
23
|
+
listAdministratorUsers as listAdministratorUsersInDb,
|
|
24
|
+
} from '#db/admin-users.js';
|
|
22
25
|
import {
|
|
23
26
|
type ImpersonationResult,
|
|
24
27
|
impersonateUserWithAudit,
|
|
@@ -45,6 +48,7 @@ import {
|
|
|
45
48
|
ImpersonationExpiredError,
|
|
46
49
|
ImpersonationTargetNotActiveError,
|
|
47
50
|
InvalidAdminBootstrapTokenError,
|
|
51
|
+
InvalidAdministratorUserDirectoryFilterError,
|
|
48
52
|
InvalidCredentialsError,
|
|
49
53
|
LastAdminOwnerError,
|
|
50
54
|
UserNotFoundError,
|
|
@@ -53,6 +57,10 @@ import {
|
|
|
53
57
|
const ADMIN_OWNER_ROLE: AdminRole = 'admin-owner';
|
|
54
58
|
const ADMIN_OBSERVER_ROLE: AdminRole = 'admin-observer';
|
|
55
59
|
const ADMIN_OPERATOR_ROLE: AdminRole = 'admin-operator';
|
|
60
|
+
const ADMINISTRATOR_DIRECTORY_MAX_PAGE_SIZE = 100;
|
|
61
|
+
const ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS = 10;
|
|
62
|
+
const ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH = 100;
|
|
63
|
+
const ADMINISTRATOR_DIRECTORY_SEARCH_TERM_SEPARATOR = /\s+/g;
|
|
56
64
|
const BOOTSTRAP_COMMAND = 'auth.admin_grant.bootstrap';
|
|
57
65
|
const GRANT_COMMAND = 'auth.admin_grant.grant';
|
|
58
66
|
const REVOKE_COMMAND = 'auth.admin_grant.revoke';
|
|
@@ -192,6 +200,75 @@ export async function findAdministratorUserSummary(
|
|
|
192
200
|
return user;
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
export interface ListAdministratorUsersParams {
|
|
204
|
+
actorId: string;
|
|
205
|
+
limit: number;
|
|
206
|
+
cursor?: TimestampIdCursor | undefined;
|
|
207
|
+
search?: string | undefined;
|
|
208
|
+
status?: AdministratorUserSummary['status'] | undefined;
|
|
209
|
+
eligible?: boolean | undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ListAdministratorUsersResult {
|
|
213
|
+
/** Retained for compatibility with the original database-shaped result. */
|
|
214
|
+
rows: AdministratorUserSummary[];
|
|
215
|
+
users: AdministratorUserSummary[];
|
|
216
|
+
nextCursor: TimestampIdCursor | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeAdministratorUserDirectorySearch(search: string | undefined): string | undefined {
|
|
220
|
+
const normalizedSearch = search
|
|
221
|
+
?.trim()
|
|
222
|
+
.replace(ADMINISTRATOR_DIRECTORY_SEARCH_TERM_SEPARATOR, ' ');
|
|
223
|
+
if (!normalizedSearch) return;
|
|
224
|
+
|
|
225
|
+
const terms = normalizedSearch.split(' ');
|
|
226
|
+
if (terms.length > ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS) {
|
|
227
|
+
throw new InvalidAdministratorUserDirectoryFilterError(
|
|
228
|
+
`Administrator user directory search accepts at most ${ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERMS} terms`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (terms.some((term) => term.length > ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH)) {
|
|
233
|
+
throw new InvalidAdministratorUserDirectoryFilterError(
|
|
234
|
+
`Administrator user directory search terms must be at most ${ADMINISTRATOR_DIRECTORY_MAX_SEARCH_TERM_LENGTH} characters`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return normalizedSearch;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateAdministratorUserDirectoryFilters(
|
|
242
|
+
params: ListAdministratorUsersParams,
|
|
243
|
+
): string | undefined {
|
|
244
|
+
const normalizedSearch = normalizeAdministratorUserDirectorySearch(params.search);
|
|
245
|
+
if (params.eligible === true && params.status && params.status !== 'active') {
|
|
246
|
+
throw new InvalidAdministratorUserDirectoryFilterError(
|
|
247
|
+
'eligible users must have active status',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return normalizedSearch;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function listAdministratorUsers(
|
|
254
|
+
params: ListAdministratorUsersParams,
|
|
255
|
+
): Promise<ListAdministratorUsersResult> {
|
|
256
|
+
await requireAdminRole({userId: params.actorId, minimumRole: ADMIN_OBSERVER_ROLE});
|
|
257
|
+
const normalizedSearch = validateAdministratorUserDirectoryFilters(params);
|
|
258
|
+
const result = await listAdministratorUsersInDb({
|
|
259
|
+
...params,
|
|
260
|
+
limit: Math.min(params.limit, ADMINISTRATOR_DIRECTORY_MAX_PAGE_SIZE),
|
|
261
|
+
search: normalizedSearch,
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
rows: result.rows,
|
|
265
|
+
// Keep the original `rows` result while exposing the domain-named field
|
|
266
|
+
// used by the directory route.
|
|
267
|
+
users: result.rows,
|
|
268
|
+
nextCursor: result.nextCursor,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
195
272
|
export async function listAdministratorGrantSummaries(params: {
|
|
196
273
|
actorId: string;
|
|
197
274
|
limit: number;
|
package/src/core/auth.ts
CHANGED
|
@@ -442,11 +442,9 @@ export type CreateSessionForUserError =
|
|
|
442
442
|
export async function createSessionForUser(
|
|
443
443
|
params: CreateSessionForUserParams,
|
|
444
444
|
): Promise<CreateSessionForUserResult> {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
? await findUserByEmail({email: emailSchema.parse(params.email)})
|
|
449
|
-
: undefined;
|
|
445
|
+
let user: User | undefined;
|
|
446
|
+
if (params.userId) user = await findUserById({id: params.userId});
|
|
447
|
+
else if (params.email) user = await findUserByEmail({email: emailSchema.parse(params.email)});
|
|
450
448
|
|
|
451
449
|
if (!user) {
|
|
452
450
|
throw new UserNotFoundError(params.userId ?? params.email ?? 'unknown');
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {describe, expect, it, vi} from '@shipfox/vitest/vi';
|
|
2
|
+
import {
|
|
3
|
+
type CimdAddress,
|
|
4
|
+
type CimdHttpResponse,
|
|
5
|
+
fetchClientIdMetadata,
|
|
6
|
+
isPublicUnicastAddress,
|
|
7
|
+
} from './cimd.js';
|
|
8
|
+
import {InvalidOAuthClientMetadataError} from './errors.js';
|
|
9
|
+
|
|
10
|
+
const clientId = 'https://client.example/.well-known/oauth-client';
|
|
11
|
+
const publicAddress: CimdAddress = {address: '93.184.216.34', family: 4};
|
|
12
|
+
const validDocument = {
|
|
13
|
+
client_id: clientId,
|
|
14
|
+
client_name: 'Desktop agent',
|
|
15
|
+
redirect_uris: ['https://client.example/callback'],
|
|
16
|
+
token_endpoint_auth_method: 'none',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function response(
|
|
20
|
+
document: unknown,
|
|
21
|
+
headers: Record<string, string | string[] | undefined> = {},
|
|
22
|
+
statusCode = 200,
|
|
23
|
+
) {
|
|
24
|
+
return {
|
|
25
|
+
statusCode,
|
|
26
|
+
headers,
|
|
27
|
+
body: Buffer.from(JSON.stringify(document)),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('CIMD fetch', () => {
|
|
32
|
+
it('allows global unicast addresses and rejects special-use ranges', () => {
|
|
33
|
+
expect(isPublicUnicastAddress('93.184.216.34', 4)).toBe(true);
|
|
34
|
+
for (const address of [
|
|
35
|
+
'10.0.0.1',
|
|
36
|
+
'127.0.0.1',
|
|
37
|
+
'169.254.1.1',
|
|
38
|
+
'172.16.0.1',
|
|
39
|
+
'192.168.1.1',
|
|
40
|
+
'192.88.99.1',
|
|
41
|
+
'224.0.0.1',
|
|
42
|
+
]) {
|
|
43
|
+
expect(isPublicUnicastAddress(address, 4)).toBe(false);
|
|
44
|
+
}
|
|
45
|
+
expect(isPublicUnicastAddress('2001:4860:4860::8888', 6)).toBe(true);
|
|
46
|
+
expect(isPublicUnicastAddress('2001:db8::1', 6)).toBe(false);
|
|
47
|
+
expect(isPublicUnicastAddress('fe80::1', 6)).toBe(false);
|
|
48
|
+
for (const address of ['2002:0a00:0001::1', '::ffff:10.0.0.1', '::', '1::2::3']) {
|
|
49
|
+
expect(isPublicUnicastAddress(address, 6)).toBe(false);
|
|
50
|
+
}
|
|
51
|
+
expect(isPublicUnicastAddress('fe80::1%eth0', 6)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('resolves once and passes the pinned public address to the request', async () => {
|
|
55
|
+
const resolveAddress = vi.fn(async () => [publicAddress]);
|
|
56
|
+
const request = vi.fn((params: {address: CimdAddress}) => {
|
|
57
|
+
expect(params.address).toEqual(publicAddress);
|
|
58
|
+
return Promise.resolve(response(validDocument, {'cache-control': 'max-age=30'}));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await fetchClientIdMetadata(clientId, {resolveAddress, request});
|
|
62
|
+
|
|
63
|
+
expect(resolveAddress).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(result.metadata).toMatchObject({
|
|
66
|
+
clientId,
|
|
67
|
+
clientName: 'Desktop agent',
|
|
68
|
+
redirectUris: ['https://client.example/callback'],
|
|
69
|
+
});
|
|
70
|
+
expect(result.cacheMaxAgeSeconds).toBe(30);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects a DNS answer containing a private or link-local address', async () => {
|
|
74
|
+
const request = vi.fn(async () => response(validDocument));
|
|
75
|
+
await expect(
|
|
76
|
+
fetchClientIdMetadata(clientId, {
|
|
77
|
+
resolveAddress: async () => [publicAddress, {address: '169.254.1.1', family: 4}],
|
|
78
|
+
request,
|
|
79
|
+
}),
|
|
80
|
+
).rejects.toMatchObject({
|
|
81
|
+
name: 'OAuthMetadataFetchError',
|
|
82
|
+
reason: 'private-address',
|
|
83
|
+
});
|
|
84
|
+
expect(request).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects redirects, oversized bodies, malformed JSON, and identity mismatches', async () => {
|
|
88
|
+
await expect(
|
|
89
|
+
fetchClientIdMetadata(clientId, {
|
|
90
|
+
resolveAddress: async () => [publicAddress],
|
|
91
|
+
request: async () => ({
|
|
92
|
+
statusCode: 302,
|
|
93
|
+
headers: {location: 'https://other.example'},
|
|
94
|
+
body: new Uint8Array(),
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'redirected'});
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
fetchClientIdMetadata(clientId, {
|
|
101
|
+
resolveAddress: async () => [publicAddress],
|
|
102
|
+
maxBodyBytes: 8,
|
|
103
|
+
request: async () => response(validDocument),
|
|
104
|
+
}),
|
|
105
|
+
).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'response-too-large'});
|
|
106
|
+
|
|
107
|
+
await expect(
|
|
108
|
+
fetchClientIdMetadata(clientId, {
|
|
109
|
+
resolveAddress: async () => [publicAddress],
|
|
110
|
+
request: async () => ({statusCode: 200, headers: {}, body: Buffer.from('{bad json')}),
|
|
111
|
+
}),
|
|
112
|
+
).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
|
|
113
|
+
|
|
114
|
+
await expect(
|
|
115
|
+
fetchClientIdMetadata(clientId, {
|
|
116
|
+
resolveAddress: async () => [publicAddress],
|
|
117
|
+
request: async () =>
|
|
118
|
+
response({...validDocument, client_id: 'https://other.example/.well-known/client'}),
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('honors cache directives and rejects invalid response headers', async () => {
|
|
124
|
+
const cacheCases = [
|
|
125
|
+
['no-store', 0],
|
|
126
|
+
['no-cache', 0],
|
|
127
|
+
['max-age = 0', 0],
|
|
128
|
+
['max-age=30', 30],
|
|
129
|
+
['max-age=999999', 900],
|
|
130
|
+
['max-age=not-a-number', 900],
|
|
131
|
+
[undefined, 900],
|
|
132
|
+
] as const;
|
|
133
|
+
|
|
134
|
+
for (const [cacheControl, expectedMaxAge] of cacheCases) {
|
|
135
|
+
const headers = cacheControl === undefined ? {} : {'cache-control': cacheControl};
|
|
136
|
+
const result = await fetchClientIdMetadata(clientId, {
|
|
137
|
+
resolveAddress: async () => [publicAddress],
|
|
138
|
+
request: async () => response(validDocument, headers),
|
|
139
|
+
});
|
|
140
|
+
expect(result.cacheMaxAgeSeconds).toBe(expectedMaxAge);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
fetchClientIdMetadata(clientId, {
|
|
145
|
+
resolveAddress: async () => [publicAddress],
|
|
146
|
+
request: async () => response(validDocument, {'content-length': '9'}),
|
|
147
|
+
maxBodyBytes: 8,
|
|
148
|
+
}),
|
|
149
|
+
).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'response-too-large'});
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
fetchClientIdMetadata(clientId, {
|
|
153
|
+
resolveAddress: async () => [publicAddress],
|
|
154
|
+
request: async () => response(validDocument, {'content-encoding': 'gzip'}),
|
|
155
|
+
}),
|
|
156
|
+
).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'invalid-response'});
|
|
157
|
+
|
|
158
|
+
await expect(
|
|
159
|
+
fetchClientIdMetadata(clientId, {
|
|
160
|
+
resolveAddress: async () => [publicAddress],
|
|
161
|
+
request: async () => response(validDocument, {}, 500),
|
|
162
|
+
}),
|
|
163
|
+
).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'invalid-response'});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('tries the next validated address after a connection failure', async () => {
|
|
167
|
+
const secondAddress: CimdAddress = {address: '93.184.216.35', family: 4};
|
|
168
|
+
const request = vi.fn(({address}: {address: CimdAddress}) => {
|
|
169
|
+
if (address === publicAddress) return Promise.reject(new Error('connection refused'));
|
|
170
|
+
return Promise.resolve(response(validDocument));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
fetchClientIdMetadata(clientId, {
|
|
175
|
+
resolveAddress: async () => [publicAddress, secondAddress],
|
|
176
|
+
request,
|
|
177
|
+
}),
|
|
178
|
+
).resolves.toMatchObject({metadata: {clientId}});
|
|
179
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
180
|
+
expect(request).toHaveBeenLastCalledWith(expect.objectContaining({address: secondAddress}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('aborts an in-flight request when the overall timeout expires', async () => {
|
|
184
|
+
let signal: AbortSignal | undefined;
|
|
185
|
+
const request = vi.fn(
|
|
186
|
+
({signal: requestSignal}: {signal?: AbortSignal}) =>
|
|
187
|
+
new Promise<CimdHttpResponse>((_resolve, reject) => {
|
|
188
|
+
signal = requestSignal;
|
|
189
|
+
requestSignal?.addEventListener('abort', () => reject(new Error('request aborted')), {
|
|
190
|
+
once: true,
|
|
191
|
+
});
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
fetchClientIdMetadata(clientId, {
|
|
197
|
+
resolveAddress: async () => [publicAddress],
|
|
198
|
+
request,
|
|
199
|
+
timeoutMs: 10,
|
|
200
|
+
}),
|
|
201
|
+
).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'timeout'});
|
|
202
|
+
expect(signal?.aborted).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not fetch a credential-bearing client ID', async () => {
|
|
206
|
+
await expect(
|
|
207
|
+
fetchClientIdMetadata('https://user:password@client.example/.well-known/oauth-client', {
|
|
208
|
+
resolveAddress: async () => [publicAddress],
|
|
209
|
+
request: async () => response(validDocument),
|
|
210
|
+
}),
|
|
211
|
+
).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
|
|
212
|
+
});
|
|
213
|
+
});
|