@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.
- package/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +13 -0
- 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/errors.d.ts +14 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +26 -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/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 +100 -4
- package/dist/db/agent-access.d.ts.map +1 -1
- package/dist/db/agent-access.js +483 -45
- package/dist/db/agent-access.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- 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/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 +24 -0
- package/dist/presentation/routes/rate-limit.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/package.json +7 -4
- package/src/core/cimd.test.ts +213 -0
- package/src/core/cimd.ts +532 -0
- package/src/core/errors.ts +41 -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/agent-access-retention.ts +10 -0
- package/src/db/agent-access.test.ts +697 -3
- package/src/db/agent-access.ts +953 -91
- package/src/index.test.ts +27 -0
- package/src/index.ts +76 -0
- package/src/metrics/instance.ts +5 -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 +12 -0
- 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/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 {
|
|
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() -
|
|
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() -
|
|
1048
|
+
expiresAt: new Date(Date.now() - 1_000),
|
|
355
1049
|
});
|
|
356
1050
|
|
|
357
1051
|
const pendingRequest = await findPendingAgentAuthorizationRequest({id: request.id});
|