@shipfox/api-auth 17.0.0 → 19.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.
Files changed (99) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/README.md +24 -9
  4. package/dist/core/administration.d.ts +15 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +44 -2
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +5 -3
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/entities/agent-access.d.ts +75 -0
  12. package/dist/core/entities/agent-access.d.ts.map +1 -0
  13. package/dist/core/entities/agent-access.js +3 -0
  14. package/dist/core/entities/agent-access.js.map +1 -0
  15. package/dist/core/errors.d.ts +3 -0
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/errors.js +6 -0
  18. package/dist/core/errors.js.map +1 -1
  19. package/dist/core/runner-session-token.d.ts.map +1 -1
  20. package/dist/core/runner-session-token.js +2 -1
  21. package/dist/core/runner-session-token.js.map +1 -1
  22. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  23. package/dist/db/admin-user-moderation.js +30 -26
  24. package/dist/db/admin-user-moderation.js.map +1 -1
  25. package/dist/db/admin-user-summary.d.ts +22 -0
  26. package/dist/db/admin-user-summary.d.ts.map +1 -1
  27. package/dist/db/admin-user-summary.js +75 -1
  28. package/dist/db/admin-user-summary.js.map +1 -1
  29. package/dist/db/admin-users.d.ts +11 -0
  30. package/dist/db/admin-users.d.ts.map +1 -1
  31. package/dist/db/admin-users.js +4 -1
  32. package/dist/db/admin-users.js.map +1 -1
  33. package/dist/db/agent-access.d.ts +159 -0
  34. package/dist/db/agent-access.d.ts.map +1 -0
  35. package/dist/db/agent-access.js +267 -0
  36. package/dist/db/agent-access.js.map +1 -0
  37. package/dist/db/db.d.ts +2604 -238
  38. package/dist/db/db.d.ts.map +1 -1
  39. package/dist/db/db.js +7 -0
  40. package/dist/db/db.js.map +1 -1
  41. package/dist/db/schema/agent-access.d.ts +1204 -0
  42. package/dist/db/schema/agent-access.d.ts.map +1 -0
  43. package/dist/db/schema/agent-access.js +267 -0
  44. package/dist/db/schema/agent-access.js.map +1 -0
  45. package/dist/db/schema/users.d.ts.map +1 -1
  46. package/dist/db/schema/users.js +8 -3
  47. package/dist/db/schema/users.js.map +1 -1
  48. package/dist/index.d.ts +3 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/metrics/instance.d.ts +1 -1
  53. package/dist/metrics/instance.d.ts.map +1 -1
  54. package/dist/metrics/instance.js.map +1 -1
  55. package/dist/presentation/routes/administration.d.ts.map +1 -1
  56. package/dist/presentation/routes/administration.js +123 -20
  57. package/dist/presentation/routes/administration.js.map +1 -1
  58. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  59. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  60. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  61. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  62. package/dist/presentation/routes/rate-limit.js +10 -0
  63. package/dist/presentation/routes/rate-limit.js.map +1 -1
  64. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  65. package/dist/presentation/routes/registration/signup.js +59 -50
  66. package/dist/presentation/routes/registration/signup.js.map +1 -1
  67. package/dist/tsconfig.test.tsbuildinfo +1 -1
  68. package/drizzle/0003_brainy_luckman.sql +6 -0
  69. package/drizzle/0004_yummy_runaways.sql +110 -0
  70. package/drizzle/meta/0003_snapshot.json +844 -0
  71. package/drizzle/meta/0004_snapshot.json +1751 -0
  72. package/drizzle/meta/_journal.json +14 -0
  73. package/package.json +7 -7
  74. package/src/core/admin-role.test.ts +70 -0
  75. package/src/core/administration.ts +78 -1
  76. package/src/core/auth.ts +3 -5
  77. package/src/core/entities/agent-access.ts +80 -0
  78. package/src/core/errors.ts +7 -0
  79. package/src/core/runner-session-token.test.ts +2 -0
  80. package/src/core/runner-session-token.ts +1 -0
  81. package/src/db/admin-user-moderation.ts +53 -45
  82. package/src/db/admin-user-summary.test.ts +228 -0
  83. package/src/db/admin-user-summary.ts +143 -1
  84. package/src/db/admin-users.ts +21 -1
  85. package/src/db/agent-access.test.ts +370 -0
  86. package/src/db/agent-access.ts +629 -0
  87. package/src/db/db.ts +14 -0
  88. package/src/db/schema/agent-access.ts +273 -0
  89. package/src/db/schema/users.ts +9 -3
  90. package/src/index.ts +6 -0
  91. package/src/metrics/instance.ts +1 -0
  92. package/src/presentation/routes/administration.test.ts +359 -2
  93. package/src/presentation/routes/administration.ts +142 -20
  94. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  95. package/src/presentation/routes/rate-limit.ts +4 -0
  96. package/src/presentation/routes/registration/signup.ts +52 -41
  97. package/test/globalSetup.ts +1 -1
  98. package/test/routes.ts +3 -1
  99. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,370 @@
1
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
+ import {eq, sql} from 'drizzle-orm';
3
+ import {userFactory} from '#test/index.js';
4
+ import {
5
+ consumeAgentAuthorizationCode,
6
+ consumeAgentAuthorizationRequest,
7
+ createAgentAuthorizationCode,
8
+ createAgentAuthorizationRequest,
9
+ createAgentClient,
10
+ createAgentGrant,
11
+ createAgentPersonalAccessToken,
12
+ createAgentRefreshToken,
13
+ findActiveAgentAuthorizationCodeByHash,
14
+ findActiveAgentPersonalAccessTokenByHash,
15
+ findActiveAgentRefreshTokenByHash,
16
+ findAgentClientByClientId,
17
+ findAgentPersonalAccessTokenByHash,
18
+ findAgentRefreshTokenByHash,
19
+ findPendingAgentAuthorizationRequest,
20
+ lockAgentGrant,
21
+ markAgentPersonalAccessTokenUsed,
22
+ pruneAgentAccess,
23
+ revokeAgentPersonalAccessToken,
24
+ rotateAgentRefreshToken,
25
+ } from './agent-access.js';
26
+ import {db} from './db.js';
27
+ import {agentClients} from './schema/agent-access.js';
28
+
29
+ const GRANT_LOCK_TEST_TIMEOUT_MS = 30_000;
30
+
31
+ async function waitForLockWait(
32
+ waiterPid: number,
33
+ query: () => Promise<{rows: Array<{blockers: number[]}>}>,
34
+ timeoutMs: number,
35
+ ): Promise<void> {
36
+ const deadline = Date.now() + timeoutMs;
37
+ while (Date.now() < deadline) {
38
+ const result = await query();
39
+ if ((result.rows[0]?.blockers.length ?? 0) > 0) return;
40
+ await new Promise<void>((resolve) => setImmediate(resolve));
41
+ }
42
+ throw new Error(`Timed out waiting for PostgreSQL row lock on backend ${waiterPid}`);
43
+ }
44
+
45
+ function deferred<T>() {
46
+ let resolve!: (value: T | PromiseLike<T>) => void;
47
+ let reject!: (reason?: unknown) => void;
48
+ const promise = new Promise<T>((innerResolve, innerReject) => {
49
+ resolve = innerResolve;
50
+ reject = innerReject;
51
+ });
52
+ return {promise, resolve, reject};
53
+ }
54
+
55
+ describe('agent-access db', () => {
56
+ test('consumes a pending authorization request exactly once under concurrency', async () => {
57
+ const client = await createAgentClient({
58
+ clientId: `https://client.example/${crypto.randomUUID()}`,
59
+ name: 'Test client',
60
+ redirectUris: ['https://client.example/callback'],
61
+ kind: 'registered',
62
+ });
63
+ const request = await createAgentAuthorizationRequest({
64
+ clientId: client.id,
65
+ redirectUri: 'https://client.example/callback',
66
+ resource: 'https://api.example/mcp',
67
+ scopes: ['read'],
68
+ codeChallenge: 'challenge',
69
+ state: null,
70
+ expiresAt: new Date(Date.now() + 60_000),
71
+ });
72
+
73
+ expect(request.state).toBeNull();
74
+ expect(await findPendingAgentAuthorizationRequest({id: request.id})).toMatchObject({
75
+ id: request.id,
76
+ state: null,
77
+ });
78
+
79
+ const [first, second] = await Promise.all([
80
+ consumeAgentAuthorizationRequest({id: request.id}),
81
+ consumeAgentAuthorizationRequest({id: request.id}),
82
+ ]);
83
+
84
+ expect([first, second].filter(Boolean)).toHaveLength(1);
85
+ expect([first, second].filter((value) => value === undefined)).toHaveLength(1);
86
+ expect(await findPendingAgentAuthorizationRequest({id: request.id})).toBeUndefined();
87
+ });
88
+
89
+ test('reuses an active grant during reauthorization', async () => {
90
+ const user = await userFactory.create();
91
+ const client = await createAgentClient({
92
+ clientId: `https://client.example/${crypto.randomUUID()}`,
93
+ name: 'Test client',
94
+ redirectUris: ['https://client.example/callback'],
95
+ kind: 'registered',
96
+ });
97
+ const params = {
98
+ userId: user.id,
99
+ workspaceId: crypto.randomUUID(),
100
+ clientId: client.id,
101
+ scopes: ['read'],
102
+ };
103
+
104
+ const duplicateClient = await createAgentClient({
105
+ clientId: client.clientId,
106
+ name: 'Changed name is ignored for an existing client',
107
+ redirectUris: client.redirectUris,
108
+ kind: client.kind,
109
+ });
110
+ expect(duplicateClient.id).toBe(client.id);
111
+
112
+ await db()
113
+ .update(agentClients)
114
+ .set({unreferencedAt: new Date()})
115
+ .where(eq(agentClients.id, client.id));
116
+
117
+ const first = await createAgentGrant(params);
118
+ const refreshToken = await createAgentRefreshToken({
119
+ grantId: first.id,
120
+ hashedToken: hashOpaqueToken(`refresh-${crypto.randomUUID()}`),
121
+ expiresAt: new Date(Date.now() + 60_000),
122
+ });
123
+ const second = await createAgentGrant({...params, scopes: ['read', 'write']});
124
+
125
+ expect(second).toMatchObject({id: first.id, scopes: ['read', 'write']});
126
+ expect(
127
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
128
+ ).toBeUndefined();
129
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
130
+ id: client.id,
131
+ unreferencedAt: null,
132
+ });
133
+ });
134
+
135
+ test('manages refresh tokens and personal access tokens', async () => {
136
+ const user = await userFactory.create();
137
+ const client = await createAgentClient({
138
+ clientId: `https://client.example/${crypto.randomUUID()}`,
139
+ name: 'Test client',
140
+ redirectUris: ['https://client.example/callback'],
141
+ kind: 'registered',
142
+ });
143
+ const grant = await createAgentGrant({
144
+ userId: user.id,
145
+ workspaceId: crypto.randomUUID(),
146
+ clientId: client.id,
147
+ scopes: ['read'],
148
+ });
149
+ const refreshToken = await createAgentRefreshToken({
150
+ grantId: grant.id,
151
+ hashedToken: hashOpaqueToken('refresh-token'),
152
+ expiresAt: new Date(Date.now() + 60_000),
153
+ });
154
+
155
+ expect(
156
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
157
+ ).toMatchObject({id: refreshToken.id});
158
+ const rotated = await rotateAgentRefreshToken({
159
+ hashedToken: refreshToken.hashedToken,
160
+ replacementHashedToken: hashOpaqueToken('replacement-refresh-token'),
161
+ replacementExpiresAt: new Date(Date.now() + 60_000),
162
+ });
163
+ expect(rotated).toMatchObject({
164
+ grantId: grant.id,
165
+ hashedToken: hashOpaqueToken('replacement-refresh-token'),
166
+ });
167
+ expect(
168
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
169
+ ).toBeUndefined();
170
+ expect(
171
+ await rotateAgentRefreshToken({
172
+ hashedToken: refreshToken.hashedToken,
173
+ replacementHashedToken: hashOpaqueToken('unused-refresh-token'),
174
+ replacementExpiresAt: new Date(Date.now() + 60_000),
175
+ }),
176
+ ).toBeUndefined();
177
+
178
+ const pat = await createAgentPersonalAccessToken({
179
+ userId: user.id,
180
+ workspaceId: grant.workspaceId,
181
+ hashedToken: hashOpaqueToken('pat-token'),
182
+ prefix: 'sf_pat_test',
183
+ name: 'Test PAT',
184
+ scopes: ['read'],
185
+ expiresAt: new Date(Date.now() + 60_000),
186
+ });
187
+ expect(
188
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
189
+ ).toMatchObject({id: pat.id});
190
+ expect(await markAgentPersonalAccessTokenUsed({id: pat.id})).toMatchObject({
191
+ id: pat.id,
192
+ lastUsedAt: expect.any(Date),
193
+ });
194
+ await revokeAgentPersonalAccessToken({id: pat.id});
195
+ expect(
196
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
197
+ ).toBeUndefined();
198
+
199
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
200
+
201
+ expect(
202
+ await findAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
203
+ ).toBeUndefined();
204
+ expect(
205
+ await findAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
206
+ ).toBeUndefined();
207
+ });
208
+
209
+ test('consumes an authorization code exactly once and preserves its bindings', async () => {
210
+ const user = await userFactory.create();
211
+ const client = await createAgentClient({
212
+ clientId: `https://client.example/${crypto.randomUUID()}`,
213
+ name: 'Test client',
214
+ redirectUris: ['https://client.example/callback'],
215
+ kind: 'registered',
216
+ });
217
+ const grant = await createAgentGrant({
218
+ userId: user.id,
219
+ workspaceId: crypto.randomUUID(),
220
+ clientId: client.id,
221
+ scopes: ['read'],
222
+ });
223
+ const rawCode = `code-${crypto.randomUUID()}`;
224
+ const code = await createAgentAuthorizationCode({
225
+ grantId: grant.id,
226
+ hashedCode: hashOpaqueToken(rawCode),
227
+ codeChallenge: 'challenge',
228
+ redirectUri: 'https://client.example/callback',
229
+ resource: 'https://api.example/mcp',
230
+ expiresAt: new Date(Date.now() + 60_000),
231
+ });
232
+
233
+ expect(
234
+ await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode}),
235
+ ).toMatchObject({
236
+ id: code.id,
237
+ });
238
+
239
+ const [first, second] = await Promise.all([
240
+ consumeAgentAuthorizationCode({hashedCode: code.hashedCode}),
241
+ consumeAgentAuthorizationCode({hashedCode: code.hashedCode}),
242
+ ]);
243
+ const consumed = [first, second].find(Boolean);
244
+
245
+ expect(
246
+ await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode}),
247
+ ).toBeUndefined();
248
+ expect([first, second].filter(Boolean)).toHaveLength(1);
249
+ expect(consumed).toMatchObject({
250
+ grantId: grant.id,
251
+ codeChallenge: 'challenge',
252
+ redirectUri: 'https://client.example/callback',
253
+ resource: 'https://api.example/mcp',
254
+ });
255
+ });
256
+
257
+ test(
258
+ 'serializes grant lifecycle work on the grant row lock',
259
+ async () => {
260
+ const user = await userFactory.create();
261
+ const client = await createAgentClient({
262
+ clientId: `https://client.example/${crypto.randomUUID()}`,
263
+ name: 'Test client',
264
+ redirectUris: ['https://client.example/callback'],
265
+ kind: 'registered',
266
+ });
267
+ const grant = await createAgentGrant({
268
+ userId: user.id,
269
+ workspaceId: crypto.randomUUID(),
270
+ clientId: client.id,
271
+ scopes: ['read'],
272
+ });
273
+
274
+ let releaseHolder!: () => void;
275
+ const holderReleased = new Promise<void>((resolve) => {
276
+ releaseHolder = resolve;
277
+ });
278
+ let holderReady!: () => void;
279
+ const lockAcquired = new Promise<void>((resolve) => {
280
+ holderReady = resolve;
281
+ });
282
+ const waiterReady = deferred<number>();
283
+ const lockObserved = deferred<void>();
284
+ const holder = db().transaction(async (tx) => {
285
+ try {
286
+ await lockAgentGrant(tx, {grantId: grant.id});
287
+ holderReady();
288
+ const waiterPid = await waiterReady.promise;
289
+ await waitForLockWait(
290
+ waiterPid,
291
+ () => tx.execute(sql`select pg_blocking_pids(${waiterPid}) as blockers`),
292
+ GRANT_LOCK_TEST_TIMEOUT_MS,
293
+ );
294
+ lockObserved.resolve();
295
+ await holderReleased;
296
+ } catch (error) {
297
+ lockObserved.reject(error);
298
+ throw error;
299
+ }
300
+ });
301
+ await lockAcquired;
302
+
303
+ const waiter = db().transaction(async (tx) => {
304
+ const result = await tx.execute(sql`select pg_backend_pid() as pid`);
305
+ const waiterPid = result.rows[0]?.pid;
306
+ if (typeof waiterPid !== 'number') throw new Error('Expected waiter backend pid');
307
+ waiterReady.resolve(waiterPid);
308
+ return await lockAgentGrant(tx, {grantId: grant.id});
309
+ });
310
+ waiter.catch(waiterReady.reject);
311
+ try {
312
+ await lockObserved.promise;
313
+ releaseHolder();
314
+ const lockedGrant = await waiter;
315
+
316
+ expect(lockedGrant?.id).toBe(grant.id);
317
+ } finally {
318
+ releaseHolder();
319
+ await Promise.all([holder, waiter.catch(() => undefined)]);
320
+ }
321
+ },
322
+ GRANT_LOCK_TEST_TIMEOUT_MS,
323
+ );
324
+
325
+ test('does not consume an expired request or code', async () => {
326
+ const client = await createAgentClient({
327
+ clientId: `https://client.example/${crypto.randomUUID()}`,
328
+ name: 'Test client',
329
+ redirectUris: ['https://client.example/callback'],
330
+ kind: 'registered',
331
+ });
332
+ const request = await createAgentAuthorizationRequest({
333
+ clientId: client.id,
334
+ redirectUri: 'https://client.example/callback',
335
+ resource: 'https://api.example/mcp',
336
+ scopes: ['read'],
337
+ codeChallenge: 'challenge',
338
+ state: 'state',
339
+ expiresAt: new Date(Date.now() - 1),
340
+ });
341
+ const user = await userFactory.create();
342
+ const grant = await createAgentGrant({
343
+ userId: user.id,
344
+ workspaceId: crypto.randomUUID(),
345
+ clientId: client.id,
346
+ scopes: ['read'],
347
+ });
348
+ const code = await createAgentAuthorizationCode({
349
+ grantId: grant.id,
350
+ hashedCode: hashOpaqueToken(`expired-${crypto.randomUUID()}`),
351
+ codeChallenge: 'challenge',
352
+ redirectUri: 'https://client.example/callback',
353
+ resource: 'https://api.example/mcp',
354
+ expiresAt: new Date(Date.now() - 1),
355
+ });
356
+
357
+ const pendingRequest = await findPendingAgentAuthorizationRequest({id: request.id});
358
+ const activeCode = await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode});
359
+ const consumedRequest = await consumeAgentAuthorizationRequest({id: request.id});
360
+ const consumedCode = await consumeAgentAuthorizationCode({hashedCode: code.hashedCode});
361
+
362
+ expect(pendingRequest).toBeUndefined();
363
+ expect(activeCode).toBeUndefined();
364
+ expect(consumedRequest).toBeUndefined();
365
+ expect(consumedCode).toBeUndefined();
366
+ expect(
367
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)}),
368
+ ).toBeGreaterThanOrEqual(2);
369
+ });
370
+ });