@shipfox/api-auth 19.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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +13 -0
  3. package/dist/core/cimd.d.ts +39 -0
  4. package/dist/core/cimd.d.ts.map +1 -0
  5. package/dist/core/cimd.js +392 -0
  6. package/dist/core/cimd.js.map +1 -0
  7. package/dist/core/errors.d.ts +14 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +26 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/oauth-client-resolver.d.ts +54 -0
  12. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  13. package/dist/core/oauth-client-resolver.js +154 -0
  14. package/dist/core/oauth-client-resolver.js.map +1 -0
  15. package/dist/core/oauth-client.d.ts +39 -0
  16. package/dist/core/oauth-client.d.ts.map +1 -0
  17. package/dist/core/oauth-client.js +236 -0
  18. package/dist/core/oauth-client.js.map +1 -0
  19. package/dist/db/agent-access-retention.d.ts +10 -0
  20. package/dist/db/agent-access-retention.d.ts.map +1 -0
  21. package/dist/db/agent-access-retention.js +11 -0
  22. package/dist/db/agent-access-retention.js.map +1 -0
  23. package/dist/db/agent-access.d.ts +100 -4
  24. package/dist/db/agent-access.d.ts.map +1 -1
  25. package/dist/db/agent-access.js +483 -45
  26. package/dist/db/agent-access.js.map +1 -1
  27. package/dist/index.d.ts +9 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +25 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/instance.d.ts +1 -1
  32. package/dist/metrics/instance.d.ts.map +1 -1
  33. package/dist/metrics/instance.js.map +1 -1
  34. package/dist/presentation/routes/oauth.d.ts +9 -0
  35. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  36. package/dist/presentation/routes/oauth.js +150 -0
  37. package/dist/presentation/routes/oauth.js.map +1 -0
  38. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  39. package/dist/presentation/routes/rate-limit.js +24 -0
  40. package/dist/presentation/routes/rate-limit.js.map +1 -1
  41. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  42. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  43. package/dist/temporal/activities/agent-access-retention.js +58 -0
  44. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  45. package/dist/temporal/activities/index.d.ts +5 -0
  46. package/dist/temporal/activities/index.d.ts.map +1 -0
  47. package/dist/temporal/activities/index.js +8 -0
  48. package/dist/temporal/activities/index.js.map +1 -0
  49. package/dist/temporal/constants.d.ts +2 -0
  50. package/dist/temporal/constants.d.ts.map +1 -0
  51. package/dist/temporal/constants.js +3 -0
  52. package/dist/temporal/constants.js.map +1 -0
  53. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  54. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  55. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  56. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  57. package/dist/temporal/workflows/index.bundle.js +25227 -0
  58. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  59. package/dist/temporal/workflows/index.d.ts +2 -0
  60. package/dist/temporal/workflows/index.d.ts.map +1 -0
  61. package/dist/temporal/workflows/index.js +3 -0
  62. package/dist/temporal/workflows/index.js.map +1 -0
  63. package/dist/tsconfig.test.tsbuildinfo +1 -1
  64. package/package.json +7 -4
  65. package/src/core/cimd.test.ts +213 -0
  66. package/src/core/cimd.ts +532 -0
  67. package/src/core/errors.ts +41 -0
  68. package/src/core/oauth-client-resolver.test.ts +198 -0
  69. package/src/core/oauth-client-resolver.ts +253 -0
  70. package/src/core/oauth-client.test.ts +157 -0
  71. package/src/core/oauth-client.ts +331 -0
  72. package/src/db/agent-access-retention.ts +10 -0
  73. package/src/db/agent-access.test.ts +697 -3
  74. package/src/db/agent-access.ts +953 -91
  75. package/src/index.test.ts +27 -0
  76. package/src/index.ts +76 -0
  77. package/src/metrics/instance.ts +5 -1
  78. package/src/presentation/routes/oauth.test.ts +133 -0
  79. package/src/presentation/routes/oauth.ts +144 -0
  80. package/src/presentation/routes/rate-limit.ts +12 -0
  81. package/src/temporal/activities/agent-access-retention.ts +76 -0
  82. package/src/temporal/activities/index.ts +5 -0
  83. package/src/temporal/constants.ts +1 -0
  84. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  85. package/src/temporal/workflows/index.ts +1 -0
  86. package/tsconfig.build.tsbuildinfo +1 -1
@@ -2,6 +2,7 @@ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
2
  import {eq, sql} from 'drizzle-orm';
3
3
  import {userFactory} from '#test/index.js';
4
4
  import {
5
+ agentRefreshTokenExpiresAt,
5
6
  consumeAgentAuthorizationCode,
6
7
  consumeAgentAuthorizationRequest,
7
8
  createAgentAuthorizationCode,
@@ -12,19 +13,34 @@ import {
12
13
  createAgentRefreshToken,
13
14
  findActiveAgentAuthorizationCodeByHash,
14
15
  findActiveAgentPersonalAccessTokenByHash,
16
+ findActiveAgentRefreshTokenByGrantId,
15
17
  findActiveAgentRefreshTokenByHash,
18
+ findAgentAuthorizationCodeByHash,
16
19
  findAgentClientByClientId,
20
+ findAgentGrant,
17
21
  findAgentPersonalAccessTokenByHash,
18
22
  findAgentRefreshTokenByHash,
19
23
  findPendingAgentAuthorizationRequest,
24
+ isWithinAgentRefreshRotationGrace,
20
25
  lockAgentGrant,
21
26
  markAgentPersonalAccessTokenUsed,
22
27
  pruneAgentAccess,
28
+ pruneAgentAccessBatch,
29
+ resolveAgentRefreshTokenReplay,
30
+ revokeAgentGrant,
23
31
  revokeAgentPersonalAccessToken,
24
32
  rotateAgentRefreshToken,
33
+ transitionAgentGrantsToTerminal,
34
+ upsertCimdAgentClient,
25
35
  } from './agent-access.js';
26
36
  import {db} from './db.js';
27
- import {agentClients} from './schema/agent-access.js';
37
+ import {
38
+ agentClients,
39
+ agentGrants,
40
+ agentPersonalAccessTokens,
41
+ agentRefreshTokens,
42
+ } from './schema/agent-access.js';
43
+ import {users} from './schema/users.js';
28
44
 
29
45
  const GRANT_LOCK_TEST_TIMEOUT_MS = 30_000;
30
46
 
@@ -53,6 +69,18 @@ function deferred<T>() {
53
69
  }
54
70
 
55
71
  describe('agent-access db', () => {
72
+ test('does not accept a future rotation timestamp as a grace replay', () => {
73
+ const now = new Date('2026-09-01T00:00:00.000Z');
74
+
75
+ expect(
76
+ isWithinAgentRefreshRotationGrace({
77
+ rotatedAt: new Date(now.getTime() + 1_000),
78
+ now,
79
+ graceSeconds: 30,
80
+ }),
81
+ ).toBe(false);
82
+ });
83
+
56
84
  test('consumes a pending authorization request exactly once under concurrency', async () => {
57
85
  const client = await createAgentClient({
58
86
  clientId: `https://client.example/${crypto.randomUUID()}`,
@@ -132,6 +160,29 @@ describe('agent-access db', () => {
132
160
  });
133
161
  });
134
162
 
163
+ test('refreshes validated CIMD metadata while preserving the client row', async () => {
164
+ const clientId = `https://client.example/.well-known/${crypto.randomUUID()}`;
165
+ const first = await upsertCimdAgentClient({
166
+ clientId,
167
+ name: 'First name',
168
+ redirectUris: ['https://client.example/first'],
169
+ });
170
+ const second = await upsertCimdAgentClient({
171
+ clientId,
172
+ name: 'Updated name',
173
+ redirectUris: ['https://client.example/updated'],
174
+ });
175
+
176
+ expect(second).toMatchObject({
177
+ id: first.id,
178
+ clientId,
179
+ name: 'Updated name',
180
+ redirectUris: ['https://client.example/updated'],
181
+ kind: 'cimd',
182
+ unreferencedAt: null,
183
+ });
184
+ });
185
+
135
186
  test('manages refresh tokens and personal access tokens', async () => {
136
187
  const user = await userFactory.create();
137
188
  const client = await createAgentClient({
@@ -187,10 +238,28 @@ describe('agent-access db', () => {
187
238
  expect(
188
239
  await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
189
240
  ).toMatchObject({id: pat.id});
241
+ const firstPatUse = await markAgentPersonalAccessTokenUsed({id: pat.id});
242
+ expect(firstPatUse).toMatchObject({
243
+ id: pat.id,
244
+ lastUsedAt: expect.any(Date),
245
+ });
246
+ const throttledPatUse = await markAgentPersonalAccessTokenUsed({id: pat.id});
247
+ expect(throttledPatUse).toMatchObject({
248
+ id: pat.id,
249
+ lastUsedAt: firstPatUse?.lastUsedAt,
250
+ });
251
+ await db()
252
+ .update(agentPersonalAccessTokens)
253
+ .set({lastUsedAt: new Date(Date.now() - 61_000)})
254
+ .where(eq(agentPersonalAccessTokens.id, pat.id));
190
255
  expect(await markAgentPersonalAccessTokenUsed({id: pat.id})).toMatchObject({
191
256
  id: pat.id,
192
257
  lastUsedAt: expect.any(Date),
193
258
  });
259
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
260
+ expect(
261
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
262
+ ).toBeUndefined();
194
263
  await revokeAgentPersonalAccessToken({id: pat.id});
195
264
  expect(
196
265
  await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
@@ -206,6 +275,580 @@ describe('agent-access db', () => {
206
275
  ).toBeUndefined();
207
276
  });
208
277
 
278
+ test('serializes concurrent refresh rotation and revokes the complete grant family', async () => {
279
+ const user = await userFactory.create();
280
+ const client = await createAgentClient({
281
+ clientId: `https://client.example/${crypto.randomUUID()}`,
282
+ name: 'Test client',
283
+ redirectUris: ['https://client.example/callback'],
284
+ kind: 'registered',
285
+ });
286
+ const grant = await createAgentGrant({
287
+ userId: user.id,
288
+ workspaceId: crypto.randomUUID(),
289
+ clientId: client.id,
290
+ scopes: ['read'],
291
+ });
292
+ const first = await createAgentRefreshToken({
293
+ grantId: grant.id,
294
+ hashedToken: hashOpaqueToken(`concurrent-refresh-${crypto.randomUUID()}`),
295
+ expiresAt: new Date(Date.now() + 60_000),
296
+ });
297
+ const replacements = [
298
+ hashOpaqueToken(`replacement-a-${crypto.randomUUID()}`),
299
+ hashOpaqueToken(`replacement-b-${crypto.randomUUID()}`),
300
+ ];
301
+
302
+ const rotations = await Promise.all(
303
+ replacements.map((replacementHashedToken) =>
304
+ rotateAgentRefreshToken({
305
+ hashedToken: first.hashedToken,
306
+ replacementHashedToken,
307
+ replacementExpiresAt: new Date(Date.now() + 60_000),
308
+ }),
309
+ ),
310
+ );
311
+
312
+ expect(rotations.filter(Boolean)).toHaveLength(1);
313
+ const refreshRows = await db()
314
+ .select({id: agentRefreshTokens.id, hashedToken: agentRefreshTokens.hashedToken})
315
+ .from(agentRefreshTokens)
316
+ .where(eq(agentRefreshTokens.grantId, grant.id));
317
+ expect(refreshRows).toHaveLength(2);
318
+ expect(await findAgentRefreshTokenByHash({hashedToken: first.hashedToken})).toMatchObject({
319
+ id: first.id,
320
+ rotatedAt: expect.any(Date),
321
+ });
322
+ const live = await findActiveAgentRefreshTokenByGrantId({grantId: grant.id});
323
+ expect(live?.hashedToken).toBeOneOf(replacements);
324
+
325
+ const revoked = await revokeAgentGrant({grantId: grant.id});
326
+ expect(revoked).toMatchObject({
327
+ id: grant.id,
328
+ revokedAt: expect.any(Date),
329
+ terminalAt: expect.any(Date),
330
+ });
331
+ expect(await findAgentRefreshTokenByHash({hashedToken: first.hashedToken})).toBeUndefined();
332
+ expect(await findActiveAgentRefreshTokenByGrantId({grantId: grant.id})).toBeUndefined();
333
+ for (const hashedToken of replacements) {
334
+ expect(await findAgentRefreshTokenByHash({hashedToken})).toBeUndefined();
335
+ }
336
+ });
337
+
338
+ test('serves retained refresh replays during grace and revokes reuse after grace', async () => {
339
+ const user = await userFactory.create();
340
+ const client = await createAgentClient({
341
+ clientId: `https://client.example/${crypto.randomUUID()}`,
342
+ name: 'Test client',
343
+ redirectUris: ['https://client.example/callback'],
344
+ kind: 'registered',
345
+ });
346
+ const grant = await createAgentGrant({
347
+ userId: user.id,
348
+ workspaceId: crypto.randomUUID(),
349
+ clientId: client.id,
350
+ scopes: ['read'],
351
+ });
352
+ const predecessor = await createAgentRefreshToken({
353
+ grantId: grant.id,
354
+ hashedToken: hashOpaqueToken(`grace-refresh-${crypto.randomUUID()}`),
355
+ });
356
+ const replacementHashedToken = hashOpaqueToken(`grace-successor-${crypto.randomUUID()}`);
357
+ const expectedSlidingExpiry = agentRefreshTokenExpiresAt();
358
+ const successor = await rotateAgentRefreshToken({
359
+ hashedToken: predecessor.hashedToken,
360
+ replacementHashedToken,
361
+ });
362
+ if (!successor) throw new Error('Expected refresh rotation to create a successor');
363
+
364
+ expect(successor?.expiresAt.getTime()).toBeGreaterThanOrEqual(
365
+ expectedSlidingExpiry.getTime() - 1_000,
366
+ );
367
+ const rotated = await findAgentRefreshTokenByHash({hashedToken: predecessor.hashedToken});
368
+ expect(rotated?.rotatedAt).toEqual(expect.any(Date));
369
+ if (!rotated?.rotatedAt) throw new Error('Expected the predecessor to be marked rotated');
370
+ const graceReplay = await resolveAgentRefreshTokenReplay({
371
+ hashedToken: predecessor.hashedToken,
372
+ now: rotated.rotatedAt,
373
+ });
374
+ expect(graceReplay).toMatchObject({
375
+ kind: 'grace',
376
+ grant: {id: grant.id, revokedAt: null},
377
+ predecessor: {id: predecessor.id},
378
+ successor: {id: successor.id},
379
+ });
380
+
381
+ await db()
382
+ .update(agentRefreshTokens)
383
+ .set({rotatedAt: new Date(Date.now() - 31_000)})
384
+ .where(eq(agentRefreshTokens.id, predecessor.id));
385
+ const reused = await resolveAgentRefreshTokenReplay({
386
+ hashedToken: predecessor.hashedToken,
387
+ });
388
+ expect(reused).toMatchObject({
389
+ kind: 'reused',
390
+ grant: {
391
+ id: grant.id,
392
+ revokedAt: expect.any(Date),
393
+ terminalAt: expect.any(Date),
394
+ },
395
+ });
396
+ expect(
397
+ await findAgentRefreshTokenByHash({hashedToken: predecessor.hashedToken}),
398
+ ).toBeUndefined();
399
+ expect(
400
+ await findAgentRefreshTokenByHash({hashedToken: replacementHashedToken}),
401
+ ).toBeUndefined();
402
+ });
403
+
404
+ test('rejects code exchange and refresh rotation for suspended users', async () => {
405
+ const user = await userFactory.create();
406
+ const client = await createAgentClient({
407
+ clientId: `https://client.example/${crypto.randomUUID()}`,
408
+ name: 'Test client',
409
+ redirectUris: ['https://client.example/callback'],
410
+ kind: 'registered',
411
+ });
412
+ const grant = await createAgentGrant({
413
+ userId: user.id,
414
+ workspaceId: crypto.randomUUID(),
415
+ clientId: client.id,
416
+ scopes: ['read'],
417
+ });
418
+ const refreshToken = await createAgentRefreshToken({
419
+ grantId: grant.id,
420
+ hashedToken: hashOpaqueToken(`suspended-refresh-${crypto.randomUUID()}`),
421
+ expiresAt: new Date(Date.now() + 60_000),
422
+ });
423
+ const code = await createAgentAuthorizationCode({
424
+ grantId: grant.id,
425
+ hashedCode: hashOpaqueToken(`suspended-code-${crypto.randomUUID()}`),
426
+ codeChallenge: 'challenge',
427
+ redirectUri: 'https://client.example/callback',
428
+ resource: 'https://api.example/mcp',
429
+ expiresAt: new Date(Date.now() + 60_000),
430
+ });
431
+ await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
432
+
433
+ expect(
434
+ await rotateAgentRefreshToken({
435
+ hashedToken: refreshToken.hashedToken,
436
+ replacementHashedToken: hashOpaqueToken(`unused-suspended-${crypto.randomUUID()}`),
437
+ }),
438
+ ).toBeUndefined();
439
+ expect(await consumeAgentAuthorizationCode({hashedCode: code.hashedCode})).toBeUndefined();
440
+ expect(
441
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
442
+ ).toMatchObject({id: refreshToken.id});
443
+ expect(
444
+ await findActiveAgentAuthorizationCodeByHash({hashedCode: code.hashedCode}),
445
+ ).toMatchObject({
446
+ id: code.id,
447
+ });
448
+ });
449
+
450
+ test('treats a replay after pruning as an unknown token without revoking its grant', async () => {
451
+ const user = await userFactory.create();
452
+ const client = await createAgentClient({
453
+ clientId: `https://client.example/${crypto.randomUUID()}`,
454
+ name: 'Pruned replay client',
455
+ redirectUris: ['https://client.example/callback'],
456
+ kind: 'registered',
457
+ });
458
+ const grant = await createAgentGrant({
459
+ userId: user.id,
460
+ workspaceId: crypto.randomUUID(),
461
+ clientId: client.id,
462
+ scopes: ['read'],
463
+ });
464
+ const predecessor = await createAgentRefreshToken({
465
+ grantId: grant.id,
466
+ hashedToken: hashOpaqueToken(`pruned-replay-${crypto.randomUUID()}`),
467
+ expiresAt: new Date(Date.now() + 60_000),
468
+ });
469
+ const successorHashedToken = hashOpaqueToken(`pruned-successor-${crypto.randomUUID()}`);
470
+ const successor = await rotateAgentRefreshToken({
471
+ hashedToken: predecessor.hashedToken,
472
+ replacementHashedToken: successorHashedToken,
473
+ replacementExpiresAt: new Date(Date.now() + 60_000),
474
+ });
475
+ if (!successor) throw new Error('Expected refresh rotation to create a successor');
476
+ await db()
477
+ .update(agentRefreshTokens)
478
+ .set({rotatedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)})
479
+ .where(eq(agentRefreshTokens.id, predecessor.id));
480
+
481
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
482
+
483
+ expect(
484
+ await resolveAgentRefreshTokenReplay({hashedToken: predecessor.hashedToken}),
485
+ ).toBeUndefined();
486
+ expect(
487
+ await findActiveAgentRefreshTokenByHash({hashedToken: successorHashedToken}),
488
+ ).toMatchObject({
489
+ id: successor.id,
490
+ });
491
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({
492
+ id: grant.id,
493
+ revokedAt: null,
494
+ terminalAt: null,
495
+ });
496
+ });
497
+
498
+ test('keeps terminal transitions serialized with authorization-code exchange', async () => {
499
+ const user = await userFactory.create();
500
+ const client = await createAgentClient({
501
+ clientId: `https://client.example/${crypto.randomUUID()}`,
502
+ name: 'Test client',
503
+ redirectUris: ['https://client.example/callback'],
504
+ kind: 'registered',
505
+ });
506
+ const emptyGrant = await createAgentGrant({
507
+ userId: user.id,
508
+ workspaceId: crypto.randomUUID(),
509
+ clientId: client.id,
510
+ scopes: ['read'],
511
+ });
512
+ expect(await transitionAgentGrantsToTerminal()).toBeGreaterThanOrEqual(1);
513
+ expect(await findAgentGrant({id: emptyGrant.id})).toMatchObject({
514
+ terminalAt: expect.any(Date),
515
+ });
516
+
517
+ const grant = await createAgentGrant({
518
+ userId: user.id,
519
+ workspaceId: crypto.randomUUID(),
520
+ clientId: client.id,
521
+ scopes: ['read'],
522
+ });
523
+ const code = await createAgentAuthorizationCode({
524
+ grantId: grant.id,
525
+ hashedCode: hashOpaqueToken(`racing-code-${crypto.randomUUID()}`),
526
+ codeChallenge: 'challenge',
527
+ redirectUri: 'https://client.example/callback',
528
+ resource: 'https://api.example/mcp',
529
+ expiresAt: new Date(Date.now() + 60_000),
530
+ });
531
+ await transitionAgentGrantsToTerminal({limit: 1_000});
532
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({terminalAt: null});
533
+
534
+ const [consumed, transitioned] = await Promise.all([
535
+ consumeAgentAuthorizationCode({hashedCode: code.hashedCode}),
536
+ transitionAgentGrantsToTerminal(),
537
+ ]);
538
+
539
+ expect(consumed).toMatchObject({id: code.id, grantId: grant.id});
540
+ expect(transitioned).toBeLessThanOrEqual(1);
541
+ expect(await consumeAgentAuthorizationCode({hashedCode: code.hashedCode})).toBeUndefined();
542
+ if (transitioned === 0) {
543
+ expect(await transitionAgentGrantsToTerminal({limit: 1_000})).toBe(1);
544
+ }
545
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({
546
+ terminalAt: expect.any(Date),
547
+ });
548
+ });
549
+
550
+ test('applies credential retention windows and retires clients after their grants', async () => {
551
+ const now = new Date('2026-09-01T00:00:00.000Z');
552
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
553
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
554
+ const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
555
+ const user = await userFactory.create();
556
+ const client = await createAgentClient({
557
+ clientId: `https://client.example/${crypto.randomUUID()}`,
558
+ name: 'Retention client',
559
+ redirectUris: ['https://client.example/callback'],
560
+ kind: 'registered',
561
+ });
562
+ const request = await createAgentAuthorizationRequest({
563
+ clientId: client.id,
564
+ redirectUri: 'https://client.example/callback',
565
+ resource: 'https://api.example/mcp',
566
+ scopes: ['read'],
567
+ codeChallenge: 'challenge',
568
+ state: null,
569
+ expiresAt: oneDayAgo,
570
+ });
571
+ const grant = await createAgentGrant({
572
+ userId: user.id,
573
+ workspaceId: crypto.randomUUID(),
574
+ clientId: client.id,
575
+ scopes: ['read'],
576
+ });
577
+ const code = await createAgentAuthorizationCode({
578
+ grantId: grant.id,
579
+ hashedCode: hashOpaqueToken(`retention-code-${crypto.randomUUID()}`),
580
+ codeChallenge: 'challenge',
581
+ redirectUri: 'https://client.example/callback',
582
+ resource: 'https://api.example/mcp',
583
+ expiresAt: oneDayAgo,
584
+ });
585
+ const rotated = await createAgentRefreshToken({
586
+ grantId: grant.id,
587
+ hashedToken: hashOpaqueToken(`retention-rotated-${crypto.randomUUID()}`),
588
+ expiresAt: new Date(now.getTime() + 60_000),
589
+ });
590
+ await db()
591
+ .update(agentRefreshTokens)
592
+ .set({rotatedAt: thirtyDaysAgo})
593
+ .where(eq(agentRefreshTokens.id, rotated.id));
594
+ const revoked = await createAgentRefreshToken({
595
+ grantId: grant.id,
596
+ hashedToken: hashOpaqueToken(`retention-revoked-${crypto.randomUUID()}`),
597
+ expiresAt: new Date(now.getTime() + 60_000),
598
+ });
599
+ await db()
600
+ .update(agentRefreshTokens)
601
+ .set({revokedAt: thirtyDaysAgo})
602
+ .where(eq(agentRefreshTokens.id, revoked.id));
603
+ const expired = await createAgentRefreshToken({
604
+ grantId: grant.id,
605
+ hashedToken: hashOpaqueToken(`retention-expired-${crypto.randomUUID()}`),
606
+ expiresAt: thirtyDaysAgo,
607
+ });
608
+ const expiredPat = await createAgentPersonalAccessToken({
609
+ userId: user.id,
610
+ workspaceId: grant.workspaceId,
611
+ hashedToken: hashOpaqueToken(`retention-expired-pat-${crypto.randomUUID()}`),
612
+ prefix: 'sf_pat_expired',
613
+ name: 'Expired PAT',
614
+ scopes: ['read'],
615
+ expiresAt: ninetyDaysAgo,
616
+ });
617
+ const revokedPat = await createAgentPersonalAccessToken({
618
+ userId: user.id,
619
+ workspaceId: grant.workspaceId,
620
+ hashedToken: hashOpaqueToken(`retention-revoked-pat-${crypto.randomUUID()}`),
621
+ prefix: 'sf_pat_revoked',
622
+ name: 'Revoked PAT',
623
+ scopes: ['read'],
624
+ expiresAt: new Date(now.getTime() + 60_000),
625
+ });
626
+ await db()
627
+ .update(agentPersonalAccessTokens)
628
+ .set({revokedAt: ninetyDaysAgo})
629
+ .where(eq(agentPersonalAccessTokens.id, revokedPat.id));
630
+
631
+ await pruneAgentAccess({now});
632
+
633
+ expect(await findPendingAgentAuthorizationRequest({id: request.id})).toBeUndefined();
634
+ expect(await findAgentAuthorizationCodeByHash({hashedCode: code.hashedCode})).toBeUndefined();
635
+ for (const token of [rotated, revoked, expired]) {
636
+ expect(await findAgentRefreshTokenByHash({hashedToken: token.hashedToken})).toBeUndefined();
637
+ }
638
+ expect(
639
+ await findAgentPersonalAccessTokenByHash({hashedToken: expiredPat.hashedToken}),
640
+ ).toBeUndefined();
641
+ expect(
642
+ await findAgentPersonalAccessTokenByHash({hashedToken: revokedPat.hashedToken}),
643
+ ).toBeUndefined();
644
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({
645
+ terminalAt: expect.any(Date),
646
+ });
647
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
648
+ id: client.id,
649
+ unreferencedAt: null,
650
+ });
651
+
652
+ await db()
653
+ .update(agentGrants)
654
+ .set({terminalAt: ninetyDaysAgo})
655
+ .where(eq(agentGrants.id, grant.id));
656
+ await pruneAgentAccess({now});
657
+ expect(await findAgentGrant({id: grant.id})).toBeUndefined();
658
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
659
+ id: client.id,
660
+ unreferencedAt: expect.any(Date),
661
+ });
662
+
663
+ await pruneAgentAccess({now: new Date(now.getTime() + 31 * 24 * 60 * 60 * 1000)});
664
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toBeUndefined();
665
+ });
666
+
667
+ test('retains live credentials and rejects new children after grant revocation', async () => {
668
+ const user = await userFactory.create();
669
+ const client = await createAgentClient({
670
+ clientId: `https://client.example/${crypto.randomUUID()}`,
671
+ name: 'Live client',
672
+ redirectUris: ['https://client.example/callback'],
673
+ kind: 'registered',
674
+ });
675
+ const grant = await createAgentGrant({
676
+ userId: user.id,
677
+ workspaceId: crypto.randomUUID(),
678
+ clientId: client.id,
679
+ scopes: ['read'],
680
+ });
681
+ const refreshToken = await createAgentRefreshToken({
682
+ grantId: grant.id,
683
+ hashedToken: hashOpaqueToken(`live-refresh-${crypto.randomUUID()}`),
684
+ expiresAt: new Date(Date.now() + 60_000),
685
+ });
686
+ const pat = await createAgentPersonalAccessToken({
687
+ userId: user.id,
688
+ workspaceId: grant.workspaceId,
689
+ hashedToken: hashOpaqueToken(`live-pat-${crypto.randomUUID()}`),
690
+ prefix: 'sf_pat_live',
691
+ name: 'Live PAT',
692
+ scopes: ['read'],
693
+ expiresAt: new Date(Date.now() + 60_000),
694
+ });
695
+
696
+ expect(await transitionAgentGrantsToTerminal({limit: 1})).toBe(0);
697
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
698
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({terminalAt: null});
699
+ expect(
700
+ await findActiveAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
701
+ ).toMatchObject({
702
+ id: refreshToken.id,
703
+ });
704
+ expect(
705
+ await findActiveAgentPersonalAccessTokenByHash({hashedToken: pat.hashedToken}),
706
+ ).toMatchObject({
707
+ id: pat.id,
708
+ });
709
+
710
+ await revokeAgentGrant({grantId: grant.id});
711
+ await expect(
712
+ createAgentRefreshToken({
713
+ grantId: grant.id,
714
+ hashedToken: hashOpaqueToken(`rejected-refresh-${crypto.randomUUID()}`),
715
+ expiresAt: new Date(Date.now() + 60_000),
716
+ }),
717
+ ).rejects.toThrow('no longer active');
718
+ await expect(
719
+ createAgentAuthorizationCode({
720
+ grantId: grant.id,
721
+ hashedCode: hashOpaqueToken(`rejected-code-${crypto.randomUUID()}`),
722
+ codeChallenge: 'challenge',
723
+ redirectUri: 'https://client.example/callback',
724
+ resource: 'https://api.example/mcp',
725
+ expiresAt: new Date(Date.now() + 60_000),
726
+ }),
727
+ ).rejects.toThrow('no longer active');
728
+ });
729
+
730
+ test('keeps a never-granted client while it has a live authorization request', async () => {
731
+ const client = await createAgentClient({
732
+ clientId: `https://client.example/${crypto.randomUUID()}`,
733
+ name: 'Pending client',
734
+ redirectUris: ['https://client.example/callback'],
735
+ kind: 'cimd',
736
+ });
737
+ const oldCreatedAt = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
738
+ await db()
739
+ .update(agentClients)
740
+ .set({createdAt: oldCreatedAt})
741
+ .where(eq(agentClients.id, client.id));
742
+ const request = await createAgentAuthorizationRequest({
743
+ clientId: client.id,
744
+ redirectUri: 'https://client.example/callback',
745
+ resource: 'https://api.example/mcp',
746
+ scopes: ['read'],
747
+ codeChallenge: 'challenge',
748
+ state: null,
749
+ expiresAt: new Date(Date.now() + 60_000),
750
+ });
751
+
752
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
753
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toMatchObject({
754
+ id: client.id,
755
+ });
756
+
757
+ expect(await consumeAgentAuthorizationRequest({id: request.id})).toMatchObject({
758
+ id: request.id,
759
+ });
760
+ await pruneAgentAccess({retentionDays: 0, now: new Date(Date.now() + 1_000)});
761
+ expect(await findAgentClientByClientId({clientId: client.clientId})).toBeUndefined();
762
+ });
763
+
764
+ test('retains a child revoked after grant terminalization until its own horizon', async () => {
765
+ const now = new Date('2026-09-01T00:00:00.000Z');
766
+ const user = await userFactory.create();
767
+ const client = await createAgentClient({
768
+ clientId: `https://client.example/${crypto.randomUUID()}`,
769
+ name: 'Child retention client',
770
+ redirectUris: ['https://client.example/callback'],
771
+ kind: 'registered',
772
+ });
773
+ const grant = await createAgentGrant({
774
+ userId: user.id,
775
+ workspaceId: crypto.randomUUID(),
776
+ clientId: client.id,
777
+ scopes: ['read'],
778
+ });
779
+ const refreshToken = await createAgentRefreshToken({
780
+ grantId: grant.id,
781
+ hashedToken: hashOpaqueToken(`late-revoked-refresh-${crypto.randomUUID()}`),
782
+ expiresAt: new Date(now.getTime() + 60_000),
783
+ });
784
+ const terminalAt = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
785
+ await db().update(agentGrants).set({terminalAt}).where(eq(agentGrants.id, grant.id));
786
+ await db()
787
+ .update(agentRefreshTokens)
788
+ .set({revokedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000)})
789
+ .where(eq(agentRefreshTokens.id, refreshToken.id));
790
+
791
+ await pruneAgentAccess({now});
792
+ expect(await findAgentGrant({id: grant.id})).toMatchObject({id: grant.id});
793
+ expect(
794
+ await findAgentRefreshTokenByHash({hashedToken: refreshToken.hashedToken}),
795
+ ).toBeUndefined();
796
+ const retainedRefresh = await db()
797
+ .select({id: agentRefreshTokens.id})
798
+ .from(agentRefreshTokens)
799
+ .where(eq(agentRefreshTokens.id, refreshToken.id));
800
+ expect(retainedRefresh).toHaveLength(1);
801
+
802
+ await pruneAgentAccess({now: new Date(now.getTime() + 31 * 24 * 60 * 60 * 1000)});
803
+ expect(await findAgentGrant({id: grant.id})).toBeUndefined();
804
+ });
805
+
806
+ test('does not let retained children starve newer terminal grants', async () => {
807
+ const now = new Date('2026-09-01T00:00:00.000Z');
808
+ const user = await userFactory.create();
809
+ const blockedClient = await createAgentClient({
810
+ clientId: `https://client.example/${crypto.randomUUID()}`,
811
+ name: 'Blocked terminal client',
812
+ redirectUris: ['https://client.example/callback'],
813
+ kind: 'registered',
814
+ });
815
+ const deletableClient = await createAgentClient({
816
+ clientId: `https://client.example/${crypto.randomUUID()}`,
817
+ name: 'Deletable terminal client',
818
+ redirectUris: ['https://client.example/callback'],
819
+ kind: 'registered',
820
+ });
821
+ const blockedGrant = await createAgentGrant({
822
+ userId: user.id,
823
+ workspaceId: crypto.randomUUID(),
824
+ clientId: blockedClient.id,
825
+ scopes: ['read'],
826
+ });
827
+ const deletableGrant = await createAgentGrant({
828
+ userId: user.id,
829
+ workspaceId: crypto.randomUUID(),
830
+ clientId: deletableClient.id,
831
+ scopes: ['read'],
832
+ });
833
+ const retainedChild = await createAgentRefreshToken({
834
+ grantId: blockedGrant.id,
835
+ hashedToken: hashOpaqueToken(`starving-child-${crypto.randomUUID()}`),
836
+ expiresAt: new Date(now.getTime() + 60_000),
837
+ });
838
+ const terminalAt = new Date(now.getTime() - 91 * 24 * 60 * 60 * 1000);
839
+ await db().update(agentGrants).set({terminalAt}).where(eq(agentGrants.id, blockedGrant.id));
840
+ await db().update(agentGrants).set({terminalAt}).where(eq(agentGrants.id, deletableGrant.id));
841
+ await db()
842
+ .update(agentRefreshTokens)
843
+ .set({revokedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000)})
844
+ .where(eq(agentRefreshTokens.id, retainedChild.id));
845
+
846
+ await pruneAgentAccess({now, limit: 1});
847
+
848
+ expect(await findAgentGrant({id: blockedGrant.id})).toMatchObject({id: blockedGrant.id});
849
+ expect(await findAgentGrant({id: deletableGrant.id})).toBeUndefined();
850
+ });
851
+
209
852
  test('consumes an authorization code exactly once and preserves its bindings', async () => {
210
853
  const user = await userFactory.create();
211
854
  const client = await createAgentClient({
@@ -322,6 +965,57 @@ describe('agent-access db', () => {
322
965
  GRANT_LOCK_TEST_TIMEOUT_MS,
323
966
  );
324
967
 
968
+ test(
969
+ 'bounds retention waits on a locked terminal grant',
970
+ async () => {
971
+ const user = await userFactory.create();
972
+ const client = await createAgentClient({
973
+ clientId: `https://client.example/${crypto.randomUUID()}`,
974
+ name: 'Retention lock client',
975
+ redirectUris: ['https://client.example/callback'],
976
+ kind: 'registered',
977
+ });
978
+ const grant = await createAgentGrant({
979
+ userId: user.id,
980
+ workspaceId: crypto.randomUUID(),
981
+ clientId: client.id,
982
+ scopes: ['read'],
983
+ });
984
+ await db()
985
+ .update(agentGrants)
986
+ .set({terminalAt: new Date('2000-01-01T00:00:00.000Z')})
987
+ .where(eq(agentGrants.id, grant.id));
988
+
989
+ const holderReady = deferred<void>();
990
+ const releaseHolder = deferred<void>();
991
+ const holder = db().transaction(async (tx) => {
992
+ await tx
993
+ .update(agentGrants)
994
+ .set({updatedAt: sql`now()`})
995
+ .where(eq(agentGrants.id, grant.id));
996
+ holderReady.resolve();
997
+ await releaseHolder.promise;
998
+ });
999
+
1000
+ try {
1001
+ await holderReady.promise;
1002
+ await expect(
1003
+ pruneAgentAccessBatch({
1004
+ now: new Date('2026-09-01T00:00:00.000Z'),
1005
+ limit: 1,
1006
+ deadlineMs: Date.now() + 1_500,
1007
+ }),
1008
+ ).rejects.toMatchObject({
1009
+ cause: {code: expect.stringMatching('^(55P03|57014)$')},
1010
+ });
1011
+ } finally {
1012
+ releaseHolder.resolve();
1013
+ await holder;
1014
+ }
1015
+ },
1016
+ GRANT_LOCK_TEST_TIMEOUT_MS,
1017
+ );
1018
+
325
1019
  test('does not consume an expired request or code', async () => {
326
1020
  const client = await createAgentClient({
327
1021
  clientId: `https://client.example/${crypto.randomUUID()}`,
@@ -336,7 +1030,7 @@ describe('agent-access db', () => {
336
1030
  scopes: ['read'],
337
1031
  codeChallenge: 'challenge',
338
1032
  state: 'state',
339
- expiresAt: new Date(Date.now() - 1),
1033
+ expiresAt: new Date(Date.now() - 1_000),
340
1034
  });
341
1035
  const user = await userFactory.create();
342
1036
  const grant = await createAgentGrant({
@@ -351,7 +1045,7 @@ describe('agent-access db', () => {
351
1045
  codeChallenge: 'challenge',
352
1046
  redirectUri: 'https://client.example/callback',
353
1047
  resource: 'https://api.example/mcp',
354
- expiresAt: new Date(Date.now() - 1),
1048
+ expiresAt: new Date(Date.now() - 1_000),
355
1049
  });
356
1050
 
357
1051
  const pendingRequest = await findPendingAgentAuthorizationRequest({id: request.id});