@robota-sdk/agent-transport 3.0.0-beta.100
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/CHANGELOG.md +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createAccessTokenVerifier } from '../node/access-token-verifier.js';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
IAccessTokenVerifier,
|
|
8
|
+
IAccessTokenVerifierConfig,
|
|
9
|
+
TAccessTokenAdmission,
|
|
10
|
+
TAccessTokenAlgorithm,
|
|
11
|
+
TAccessTokenRefusal,
|
|
12
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
13
|
+
import type { CryptoKey, JWK } from 'jose';
|
|
14
|
+
|
|
15
|
+
const ISSUER = 'https://auth.example.com';
|
|
16
|
+
const RESOURCE = 'https://agent.example.com/mcp';
|
|
17
|
+
const METADATA_URL = `${ISSUER}/.well-known/oauth-authorization-server`;
|
|
18
|
+
const JWKS_URL = `${ISSUER}/jwks.json`;
|
|
19
|
+
const START_MS = 1_800_000_000_000;
|
|
20
|
+
const PUBLIC_ADDRESS = '93.184.216.34';
|
|
21
|
+
|
|
22
|
+
interface ISigner {
|
|
23
|
+
readonly alg: TAccessTokenAlgorithm;
|
|
24
|
+
readonly kid: string;
|
|
25
|
+
readonly privateKey: CryptoKey;
|
|
26
|
+
readonly jwk: JWK;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function signer(alg: TAccessTokenAlgorithm, kid: string): Promise<ISigner> {
|
|
30
|
+
const { publicKey, privateKey } = await generateKeyPair(alg, { extractable: true });
|
|
31
|
+
const jwk = await exportJWK(publicKey);
|
|
32
|
+
return { alg, kid, privateKey, jwk: { ...jwk, kid, alg, use: 'sig' } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface IMintOptions {
|
|
36
|
+
readonly header?: Record<string, unknown>;
|
|
37
|
+
readonly claims?: Record<string, unknown>;
|
|
38
|
+
readonly issuer?: string;
|
|
39
|
+
readonly audience?: string;
|
|
40
|
+
readonly expOffset?: number;
|
|
41
|
+
readonly nbfOffset?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function mint(key: ISigner, clock: IClock, options: IMintOptions = {}): Promise<string> {
|
|
45
|
+
const nowSeconds = Math.floor(clock.ms / 1000);
|
|
46
|
+
const jwt = new SignJWT({
|
|
47
|
+
scope: 'agent:control agent:read',
|
|
48
|
+
client_id: 'cli-1',
|
|
49
|
+
...options.claims,
|
|
50
|
+
})
|
|
51
|
+
.setProtectedHeader({ alg: key.alg, typ: 'at+jwt', kid: key.kid, ...options.header })
|
|
52
|
+
.setIssuer(options.issuer ?? ISSUER)
|
|
53
|
+
.setAudience(options.audience ?? RESOURCE)
|
|
54
|
+
.setSubject('alice')
|
|
55
|
+
.setIssuedAt(nowSeconds)
|
|
56
|
+
.setExpirationTime(nowSeconds + (options.expOffset ?? 300));
|
|
57
|
+
if (options.nbfOffset !== undefined) jwt.setNotBefore(nowSeconds + options.nbfOffset);
|
|
58
|
+
return jwt.sign(key.privateKey);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function b64url(value: unknown): string {
|
|
62
|
+
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface IClock {
|
|
66
|
+
ms: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type TRoute = () => Response | Promise<Response>;
|
|
70
|
+
|
|
71
|
+
/** A stubbed issuer: routes by URL, records every request that reached the network. */
|
|
72
|
+
function issuerNetwork(routes: Record<string, TRoute>) {
|
|
73
|
+
const calls: string[] = [];
|
|
74
|
+
const fetch = (async (input: string | URL | Request) => {
|
|
75
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
76
|
+
calls.push(url);
|
|
77
|
+
const route = routes[url];
|
|
78
|
+
if (route === undefined) return new Response('not found', { status: 404 });
|
|
79
|
+
return route();
|
|
80
|
+
}) as typeof globalThis.fetch;
|
|
81
|
+
return { calls, fetch };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function json(body: unknown): Response {
|
|
85
|
+
return new Response(JSON.stringify(body), {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function standardRoutes(keys: () => readonly JWK[]): Record<string, TRoute> {
|
|
92
|
+
return {
|
|
93
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
94
|
+
[JWKS_URL]: () => json({ keys: keys() }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BASE_CONFIG: IAccessTokenVerifierConfig = {
|
|
99
|
+
issuer: ISSUER,
|
|
100
|
+
resource: RESOURCE,
|
|
101
|
+
algorithms: ['RS256', 'ES256', 'EdDSA'],
|
|
102
|
+
requiredScopes: ['agent:control'],
|
|
103
|
+
allowedSubjects: ['alice'],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface IHarness {
|
|
107
|
+
readonly verifier: IAccessTokenVerifier;
|
|
108
|
+
readonly calls: string[];
|
|
109
|
+
readonly clock: IClock;
|
|
110
|
+
readonly lookups: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function harness(
|
|
114
|
+
routes: Record<string, TRoute>,
|
|
115
|
+
config: Partial<IAccessTokenVerifierConfig> = {},
|
|
116
|
+
addresses: Record<string, string> = {},
|
|
117
|
+
): IHarness {
|
|
118
|
+
const clock: IClock = { ms: START_MS };
|
|
119
|
+
const { calls, fetch } = issuerNetwork(routes);
|
|
120
|
+
const lookups: string[] = [];
|
|
121
|
+
const verifier = createAccessTokenVerifier(
|
|
122
|
+
{ ...BASE_CONFIG, ...config },
|
|
123
|
+
{
|
|
124
|
+
fetch,
|
|
125
|
+
lookup: async (hostname) => {
|
|
126
|
+
lookups.push(hostname);
|
|
127
|
+
return [addresses[hostname] ?? PUBLIC_ADDRESS];
|
|
128
|
+
},
|
|
129
|
+
now: () => clock.ms,
|
|
130
|
+
monotonicNow: () => clock.ms,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
return { verifier, calls, clock, lookups };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function expectRefused(
|
|
137
|
+
verifier: IAccessTokenVerifier,
|
|
138
|
+
token: string,
|
|
139
|
+
refusal: TAccessTokenRefusal,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const result = await verifier.verify(token);
|
|
142
|
+
// Exact equality: a refusal carries the reason word and nothing else.
|
|
143
|
+
expect(result).toStrictEqual({ admitted: false, refusal });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function expectAdmitted(verifier: IAccessTokenVerifier, token: string): Promise<void> {
|
|
147
|
+
expect(await verifier.verify(token)).toStrictEqual({ admitted: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe('createAccessTokenVerifier', () => {
|
|
151
|
+
describe('construction', () => {
|
|
152
|
+
it('refuses a configuration that names no allowed subject or client (single-tenant)', () => {
|
|
153
|
+
expect(() =>
|
|
154
|
+
createAccessTokenVerifier({ ...BASE_CONFIG, allowedSubjects: [], allowedClients: [] }),
|
|
155
|
+
).toThrow(/single-tenant/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('refuses an http issuer', () => {
|
|
159
|
+
expect(() =>
|
|
160
|
+
createAccessTokenVerifier({ ...BASE_CONFIG, issuer: 'http://auth.example.com' }),
|
|
161
|
+
).toThrow(/https/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('refuses an algorithm outside RS256/ES256/EdDSA, and an empty list', () => {
|
|
165
|
+
expect(() =>
|
|
166
|
+
createAccessTokenVerifier({
|
|
167
|
+
...BASE_CONFIG,
|
|
168
|
+
algorithms: ['HS256' as TAccessTokenAlgorithm],
|
|
169
|
+
}),
|
|
170
|
+
).toThrow(/algorithms/);
|
|
171
|
+
expect(() => createAccessTokenVerifier({ ...BASE_CONFIG, algorithms: [] })).toThrow(
|
|
172
|
+
/algorithms/,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('refuses a configuration with no required scope', () => {
|
|
177
|
+
expect(() => createAccessTokenVerifier({ ...BASE_CONFIG, requiredScopes: [] })).toThrow(
|
|
178
|
+
/scope/,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('accepts', () => {
|
|
184
|
+
for (const alg of ['RS256', 'ES256', 'EdDSA'] as const) {
|
|
185
|
+
it(`a valid ${alg} access token`, async () => {
|
|
186
|
+
const key = await signer(alg, `${alg}-1`);
|
|
187
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
188
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it('an application/at+jwt typ, and a token admitted by client allowlist alone', async () => {
|
|
193
|
+
const key = await signer('ES256', 'k1');
|
|
194
|
+
const h = harness(
|
|
195
|
+
standardRoutes(() => [key.jwk]),
|
|
196
|
+
{
|
|
197
|
+
allowedSubjects: [],
|
|
198
|
+
allowedClients: ['cli-1'],
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
await expectAdmitted(
|
|
202
|
+
h.verifier,
|
|
203
|
+
await mint(key, h.clock, { header: { typ: 'application/at+jwt' } }),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('an upper-case AT+JWT typ (media types are case-insensitive)', async () => {
|
|
208
|
+
const key = await signer('ES256', 'k1');
|
|
209
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
210
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock, { header: { typ: 'AT+JWT' } }));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('a token without kid when the key set holds exactly one key', async () => {
|
|
214
|
+
const key = await signer('ES256', 'k1');
|
|
215
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
216
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock, { header: { kid: undefined } }));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('finds keys through OpenID discovery when RFC 8414 metadata is absent', async () => {
|
|
220
|
+
const key = await signer('ES256', 'k1');
|
|
221
|
+
const h = harness({
|
|
222
|
+
[`${ISSUER}/.well-known/openid-configuration`]: () =>
|
|
223
|
+
json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
224
|
+
[JWKS_URL]: () => json({ keys: [key.jwk] }),
|
|
225
|
+
});
|
|
226
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('inserts the well-known segment before an issuer path (RFC 8414 §3.1)', async () => {
|
|
230
|
+
const key = await signer('ES256', 'k1');
|
|
231
|
+
const issuer = `${ISSUER}/tenant-a`;
|
|
232
|
+
const h = harness(
|
|
233
|
+
{
|
|
234
|
+
[`${ISSUER}/.well-known/oauth-authorization-server/tenant-a`]: () =>
|
|
235
|
+
json({ issuer, jwks_uri: JWKS_URL }),
|
|
236
|
+
[JWKS_URL]: () => json({ keys: [key.jwk] }),
|
|
237
|
+
},
|
|
238
|
+
{ issuer },
|
|
239
|
+
);
|
|
240
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock, { issuer }));
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('token type and size', () => {
|
|
245
|
+
it('refuses an ID token (typ JWT) and an untyped token', async () => {
|
|
246
|
+
const key = await signer('ES256', 'k1');
|
|
247
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
248
|
+
await expectRefused(
|
|
249
|
+
h.verifier,
|
|
250
|
+
await mint(key, h.clock, { header: { typ: 'JWT' } }),
|
|
251
|
+
'wrong-type',
|
|
252
|
+
);
|
|
253
|
+
await expectRefused(
|
|
254
|
+
h.verifier,
|
|
255
|
+
await mint(key, h.clock, { header: { typ: undefined } }),
|
|
256
|
+
'wrong-type',
|
|
257
|
+
);
|
|
258
|
+
expect(h.calls).toEqual([]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('refuses an oversize token without parsing it or touching the network', async () => {
|
|
262
|
+
const key = await signer('ES256', 'k1');
|
|
263
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
264
|
+
await expectRefused(h.verifier, 'a'.repeat(9000), 'oversize');
|
|
265
|
+
expect(h.calls).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('refuses garbage as malformed', async () => {
|
|
269
|
+
const h = harness(standardRoutes(() => []));
|
|
270
|
+
await expectRefused(h.verifier, 'not-a-jwt', 'malformed');
|
|
271
|
+
await expectRefused(h.verifier, '', 'malformed');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('refuses a token missing a required claim (client_id)', async () => {
|
|
275
|
+
const key = await signer('ES256', 'k1');
|
|
276
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
277
|
+
await expectRefused(
|
|
278
|
+
h.verifier,
|
|
279
|
+
await mint(key, h.clock, { claims: { client_id: undefined } }),
|
|
280
|
+
'malformed',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('algorithms and keys', () => {
|
|
286
|
+
it('refuses alg none', async () => {
|
|
287
|
+
const h = harness(standardRoutes(() => []));
|
|
288
|
+
const now = Math.floor(START_MS / 1000);
|
|
289
|
+
const token = `${b64url({ alg: 'none', typ: 'at+jwt' })}.${b64url({
|
|
290
|
+
iss: ISSUER,
|
|
291
|
+
aud: RESOURCE,
|
|
292
|
+
sub: 'alice',
|
|
293
|
+
client_id: 'cli-1',
|
|
294
|
+
scope: 'agent:control',
|
|
295
|
+
exp: now + 300,
|
|
296
|
+
})}.`;
|
|
297
|
+
await expectRefused(h.verifier, token, 'unsupported-algorithm');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('refuses HS256, even keyed with a published public key', async () => {
|
|
301
|
+
const key = await signer('RS256', 'k1');
|
|
302
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
303
|
+
const secret = new TextEncoder().encode(JSON.stringify(key.jwk));
|
|
304
|
+
const now = Math.floor(START_MS / 1000);
|
|
305
|
+
const token = await new SignJWT({ scope: 'agent:control', client_id: 'cli-1' })
|
|
306
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'at+jwt', kid: 'k1' })
|
|
307
|
+
.setIssuer(ISSUER)
|
|
308
|
+
.setAudience(RESOURCE)
|
|
309
|
+
.setSubject('alice')
|
|
310
|
+
.setExpirationTime(now + 300)
|
|
311
|
+
.sign(secret);
|
|
312
|
+
await expectRefused(h.verifier, token, 'unsupported-algorithm');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('refuses an algorithm the verifier was not configured with', async () => {
|
|
316
|
+
const key = await signer('RS256', 'k1');
|
|
317
|
+
const h = harness(
|
|
318
|
+
standardRoutes(() => [key.jwk]),
|
|
319
|
+
{ algorithms: ['ES256'] },
|
|
320
|
+
);
|
|
321
|
+
await expectRefused(h.verifier, await mint(key, h.clock), 'unsupported-algorithm');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('refuses when the JWK key type disagrees with the token alg', async () => {
|
|
325
|
+
const rsa = await signer('RS256', 'k1');
|
|
326
|
+
const ec = await signer('ES256', 'k1');
|
|
327
|
+
// The key set publishes an RSA key under the kid an ES256 token names.
|
|
328
|
+
const h = harness(standardRoutes(() => [rsa.jwk]));
|
|
329
|
+
await expectRefused(h.verifier, await mint(ec, h.clock), 'key-mismatch');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('refuses when the JWK curve disagrees (P-384 for ES256)', async () => {
|
|
333
|
+
const ec = await signer('ES256', 'k1');
|
|
334
|
+
const { publicKey } = await generateKeyPair('ES384', { extractable: true });
|
|
335
|
+
const p384 = { ...(await exportJWK(publicKey)), kid: 'k1' };
|
|
336
|
+
const h = harness(standardRoutes(() => [p384]));
|
|
337
|
+
await expectRefused(h.verifier, await mint(ec, h.clock), 'key-mismatch');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('refuses when the JWK alg or use disagrees', async () => {
|
|
341
|
+
const key = await signer('RS256', 'k1');
|
|
342
|
+
const wrongAlg = harness(standardRoutes(() => [{ ...key.jwk, alg: 'PS256' }]));
|
|
343
|
+
await expectRefused(wrongAlg.verifier, await mint(key, wrongAlg.clock), 'key-mismatch');
|
|
344
|
+
const wrongUse = harness(standardRoutes(() => [{ ...key.jwk, use: 'enc' }]));
|
|
345
|
+
await expectRefused(wrongUse.verifier, await mint(key, wrongUse.clock), 'key-mismatch');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('refuses a token without kid when the key set holds more than one key', async () => {
|
|
349
|
+
const a = await signer('ES256', 'a');
|
|
350
|
+
const b = await signer('ES256', 'b');
|
|
351
|
+
const h = harness(standardRoutes(() => [a.jwk, b.jwk]));
|
|
352
|
+
await expectRefused(
|
|
353
|
+
h.verifier,
|
|
354
|
+
await mint(a, h.clock, { header: { kid: undefined } }),
|
|
355
|
+
'ambiguous-key',
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('refuses a signature made by a different key under a known kid', async () => {
|
|
360
|
+
const published = await signer('ES256', 'k1');
|
|
361
|
+
const forger = await signer('ES256', 'k1');
|
|
362
|
+
const h = harness(standardRoutes(() => [published.jwk]));
|
|
363
|
+
await expectRefused(h.verifier, await mint(forger, h.clock), 'bad-signature');
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('claims', () => {
|
|
368
|
+
it('refuses the wrong issuer', async () => {
|
|
369
|
+
const key = await signer('ES256', 'k1');
|
|
370
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
371
|
+
await expectRefused(
|
|
372
|
+
h.verifier,
|
|
373
|
+
await mint(key, h.clock, { issuer: 'https://other.example.com' }),
|
|
374
|
+
'wrong-issuer',
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('applies 60 s of skew to exp', async () => {
|
|
379
|
+
const key = await signer('ES256', 'k1');
|
|
380
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
381
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock, { expOffset: -59 }));
|
|
382
|
+
await expectRefused(h.verifier, await mint(key, h.clock, { expOffset: -61 }), 'expired');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('applies 60 s of skew to nbf', async () => {
|
|
386
|
+
const key = await signer('ES256', 'k1');
|
|
387
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
388
|
+
await expectAdmitted(h.verifier, await mint(key, h.clock, { nbfOffset: 59 }));
|
|
389
|
+
await expectRefused(h.verifier, await mint(key, h.clock, { nbfOffset: 61 }), 'not-yet-valid');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('refuses a token addressed to another resource', async () => {
|
|
393
|
+
const key = await signer('ES256', 'k1');
|
|
394
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
395
|
+
await expectRefused(
|
|
396
|
+
h.verifier,
|
|
397
|
+
await mint(key, h.clock, { audience: 'https://other.example.com/mcp' }),
|
|
398
|
+
'wrong-audience',
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('refuses a token missing a required scope', async () => {
|
|
403
|
+
const key = await signer('ES256', 'k1');
|
|
404
|
+
const h = harness(standardRoutes(() => [key.jwk]));
|
|
405
|
+
await expectRefused(
|
|
406
|
+
h.verifier,
|
|
407
|
+
await mint(key, h.clock, { claims: { scope: 'agent:read' } }),
|
|
408
|
+
'missing-scope',
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('refuses a subject and client that are not allowlisted', async () => {
|
|
413
|
+
const key = await signer('ES256', 'k1');
|
|
414
|
+
const h = harness(
|
|
415
|
+
standardRoutes(() => [key.jwk]),
|
|
416
|
+
{
|
|
417
|
+
allowedSubjects: ['bob'],
|
|
418
|
+
allowedClients: ['cli-2'],
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
await expectRefused(h.verifier, await mint(key, h.clock), 'principal-not-allowed');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('key discovery', () => {
|
|
426
|
+
it('refuses an http jwks_uri without fetching it', async () => {
|
|
427
|
+
const key = await signer('ES256', 'k1');
|
|
428
|
+
const httpJwks = 'http://auth.example.com/jwks.json';
|
|
429
|
+
const h = harness({
|
|
430
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: httpJwks }),
|
|
431
|
+
[httpJwks]: () => json({ keys: [key.jwk] }),
|
|
432
|
+
});
|
|
433
|
+
await expectRefused(h.verifier, await mint(key, h.clock), 'keys-unavailable');
|
|
434
|
+
expect(h.calls).not.toContain(httpJwks);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('refuses metadata reached by a redirect (to http or anywhere)', async () => {
|
|
438
|
+
const key = await signer('ES256', 'k1');
|
|
439
|
+
const httpMetadata = 'http://auth.example.com/metadata';
|
|
440
|
+
const h = harness({
|
|
441
|
+
[METADATA_URL]: () =>
|
|
442
|
+
new Response(null, { status: 302, headers: { location: httpMetadata } }),
|
|
443
|
+
[httpMetadata]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
444
|
+
[JWKS_URL]: () => json({ keys: [key.jwk] }),
|
|
445
|
+
});
|
|
446
|
+
await expectRefused(h.verifier, await mint(key, h.clock), 'keys-unavailable');
|
|
447
|
+
expect(h.calls).not.toContain(httpMetadata);
|
|
448
|
+
expect(h.calls).not.toContain(JWKS_URL);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('refuses metadata whose issuer differs from the configured one', async () => {
|
|
452
|
+
const key = await signer('ES256', 'k1');
|
|
453
|
+
const h = harness({
|
|
454
|
+
[METADATA_URL]: () => json({ issuer: 'https://evil.example.com', jwks_uri: JWKS_URL }),
|
|
455
|
+
[JWKS_URL]: () => json({ keys: [key.jwk] }),
|
|
456
|
+
});
|
|
457
|
+
await expectRefused(h.verifier, await mint(key, h.clock), 'keys-unavailable');
|
|
458
|
+
expect(h.calls).not.toContain(JWKS_URL);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('refuses a key set larger than the byte bound', async () => {
|
|
462
|
+
const key = await signer('ES256', 'k1');
|
|
463
|
+
const h = harness({
|
|
464
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
465
|
+
[JWKS_URL]: () => json({ keys: [key.jwk], padding: 'x'.repeat(70 * 1024) }),
|
|
466
|
+
});
|
|
467
|
+
await expectRefused(h.verifier, await mint(key, h.clock), 'keys-unavailable');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('refetches for an unknown kid, at a bounded rate', async () => {
|
|
471
|
+
const k1 = await signer('ES256', 'k1');
|
|
472
|
+
const k2 = await signer('ES256', 'k2');
|
|
473
|
+
let published: JWK[] = [k1.jwk];
|
|
474
|
+
const h = harness(standardRoutes(() => published));
|
|
475
|
+
const jwksFetches = () => h.calls.filter((url) => url === JWKS_URL).length;
|
|
476
|
+
|
|
477
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
478
|
+
expect(jwksFetches()).toBe(1);
|
|
479
|
+
|
|
480
|
+
// A known kid is served from cache.
|
|
481
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
482
|
+
expect(jwksFetches()).toBe(1);
|
|
483
|
+
|
|
484
|
+
// Inside the interval an unknown kid does not reach the issuer at all.
|
|
485
|
+
h.clock.ms += 1_000;
|
|
486
|
+
await expectRefused(h.verifier, await mint(k2, h.clock), 'unknown-key');
|
|
487
|
+
await expectRefused(h.verifier, await mint(k2, h.clock), 'unknown-key');
|
|
488
|
+
expect(jwksFetches()).toBe(1);
|
|
489
|
+
|
|
490
|
+
// After it, one refetch picks up the rotated key.
|
|
491
|
+
h.clock.ms += 30_000;
|
|
492
|
+
published = [k1.jwk, k2.jwk];
|
|
493
|
+
await expectAdmitted(h.verifier, await mint(k2, h.clock));
|
|
494
|
+
expect(jwksFetches()).toBe(2);
|
|
495
|
+
|
|
496
|
+
// And an invented kid right after costs no further fetch.
|
|
497
|
+
const k3 = await signer('ES256', 'k3');
|
|
498
|
+
await expectRefused(h.verifier, await mint(k3, h.clock), 'unknown-key');
|
|
499
|
+
expect(jwksFetches()).toBe(2);
|
|
500
|
+
// Metadata was needed once; the jwks_uri is remembered.
|
|
501
|
+
expect(h.calls.filter((url) => url === METADATA_URL)).toHaveLength(1);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('fails closed during a key-set outage, and keeps serving cached keys', async () => {
|
|
505
|
+
const k1 = await signer('ES256', 'k1');
|
|
506
|
+
let down = true;
|
|
507
|
+
const h = harness({
|
|
508
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
509
|
+
[JWKS_URL]: () => {
|
|
510
|
+
if (down) throw new TypeError('fetch failed');
|
|
511
|
+
return json({ keys: [k1.jwk] });
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
await expectRefused(h.verifier, await mint(k1, h.clock), 'keys-unavailable');
|
|
515
|
+
down = false;
|
|
516
|
+
h.clock.ms += 31_000;
|
|
517
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
518
|
+
|
|
519
|
+
down = true;
|
|
520
|
+
h.clock.ms += 31_000;
|
|
521
|
+
const k2 = await signer('ES256', 'k2');
|
|
522
|
+
await expectRefused(h.verifier, await mint(k2, h.clock), 'unknown-key');
|
|
523
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('stops admitting a key the issuer withdrew once the cache reaches its maximum age', async () => {
|
|
527
|
+
const k1 = await signer('ES256', 'k1');
|
|
528
|
+
const k2 = await signer('ES256', 'k2');
|
|
529
|
+
let published: JWK[] = [k1.jwk];
|
|
530
|
+
const h = harness(standardRoutes(() => published));
|
|
531
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
532
|
+
|
|
533
|
+
// The issuer rotates k1 out. Before the maximum age the cache still holds it.
|
|
534
|
+
published = [k2.jwk];
|
|
535
|
+
h.clock.ms += 9 * 60_000;
|
|
536
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
537
|
+
|
|
538
|
+
// At the maximum age the key set — and the metadata — is refetched before verifying.
|
|
539
|
+
h.clock.ms += 60_000;
|
|
540
|
+
await expectRefused(h.verifier, await mint(k1, h.clock), 'unknown-key');
|
|
541
|
+
await expectAdmitted(h.verifier, await mint(k2, h.clock));
|
|
542
|
+
expect(h.calls.filter((url) => url === METADATA_URL)).toHaveLength(2);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('admits with cached keys through an outage within the hard age limit', async () => {
|
|
546
|
+
const k1 = await signer('ES256', 'k1');
|
|
547
|
+
let down = false;
|
|
548
|
+
const h = harness({
|
|
549
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
550
|
+
[JWKS_URL]: () => {
|
|
551
|
+
if (down) throw new TypeError('fetch failed');
|
|
552
|
+
return json({ keys: [k1.jwk] });
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
556
|
+
down = true;
|
|
557
|
+
h.clock.ms += 59 * 60_000;
|
|
558
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('refuses once an outage outlasts the hard age limit, and recovers after it', async () => {
|
|
562
|
+
const k1 = await signer('ES256', 'k1');
|
|
563
|
+
let down = false;
|
|
564
|
+
const h = harness({
|
|
565
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
566
|
+
[JWKS_URL]: () => {
|
|
567
|
+
if (down) return new Response('down', { status: 503 });
|
|
568
|
+
return json({ keys: [k1.jwk] });
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
572
|
+
down = true;
|
|
573
|
+
h.clock.ms += 60 * 60_000;
|
|
574
|
+
await expectRefused(h.verifier, await mint(k1, h.clock), 'keys-unavailable');
|
|
575
|
+
// Still refused inside the retry interval: the dropped keys do not come back on their own.
|
|
576
|
+
h.clock.ms += 1_000;
|
|
577
|
+
await expectRefused(h.verifier, await mint(k1, h.clock), 'keys-unavailable');
|
|
578
|
+
|
|
579
|
+
down = false;
|
|
580
|
+
h.clock.ms += 30_000;
|
|
581
|
+
await expectAdmitted(h.verifier, await mint(k1, h.clock));
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('checks the key whose kid AND shape fit when two keys share a kid', async () => {
|
|
585
|
+
const rsa = await signer('RS256', 'shared');
|
|
586
|
+
const ec = await signer('ES256', 'shared');
|
|
587
|
+
const h = harness(standardRoutes(() => [rsa.jwk, ec.jwk]));
|
|
588
|
+
await expectAdmitted(h.verifier, await mint(ec, h.clock));
|
|
589
|
+
await expectAdmitted(h.verifier, await mint(rsa, h.clock));
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('treats a non-200 key-set response as an outage', async () => {
|
|
593
|
+
const k1 = await signer('ES256', 'k1');
|
|
594
|
+
const h = harness({
|
|
595
|
+
[METADATA_URL]: () => json({ issuer: ISSUER, jwks_uri: JWKS_URL }),
|
|
596
|
+
[JWKS_URL]: () => new Response('down', { status: 503 }),
|
|
597
|
+
});
|
|
598
|
+
await expectRefused(h.verifier, await mint(k1, h.clock), 'keys-unavailable');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('reaches a private issuer host, and no other private host', async () => {
|
|
602
|
+
const key = await signer('ES256', 'k1');
|
|
603
|
+
const issuer = 'https://auth.internal';
|
|
604
|
+
const internalMetadata = `${issuer}/.well-known/oauth-authorization-server`;
|
|
605
|
+
const addresses = { 'auth.internal': '10.0.0.5', 'keys.internal': '10.0.0.6' };
|
|
606
|
+
|
|
607
|
+
const sameHost = harness(
|
|
608
|
+
{
|
|
609
|
+
[internalMetadata]: () => json({ issuer, jwks_uri: `${issuer}/jwks` }),
|
|
610
|
+
[`${issuer}/jwks`]: () => json({ keys: [key.jwk] }),
|
|
611
|
+
},
|
|
612
|
+
{ issuer },
|
|
613
|
+
addresses,
|
|
614
|
+
);
|
|
615
|
+
await expectAdmitted(sameHost.verifier, await mint(key, sameHost.clock, { issuer }));
|
|
616
|
+
|
|
617
|
+
const otherHost = harness(
|
|
618
|
+
{
|
|
619
|
+
[internalMetadata]: () => json({ issuer, jwks_uri: 'https://keys.internal/jwks' }),
|
|
620
|
+
'https://keys.internal/jwks': () => json({ keys: [key.jwk] }),
|
|
621
|
+
},
|
|
622
|
+
{ issuer },
|
|
623
|
+
addresses,
|
|
624
|
+
);
|
|
625
|
+
await expectRefused(
|
|
626
|
+
otherHost.verifier,
|
|
627
|
+
await mint(key, otherHost.clock, { issuer }),
|
|
628
|
+
'keys-unavailable',
|
|
629
|
+
);
|
|
630
|
+
expect(otherHost.calls).not.toContain('https://keys.internal/jwks');
|
|
631
|
+
expect(otherHost.lookups).toContain('keys.internal');
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('never puts token text, a claim value or an error message in a refusal', async () => {
|
|
636
|
+
const key = await signer('ES256', 'k1');
|
|
637
|
+
const forger = await signer('ES256', 'k1');
|
|
638
|
+
const h = harness(
|
|
639
|
+
standardRoutes(() => [key.jwk]),
|
|
640
|
+
{ allowedSubjects: ['bob'] },
|
|
641
|
+
);
|
|
642
|
+
const down = harness({});
|
|
643
|
+
const cases: Array<[IAccessTokenVerifier, string]> = [
|
|
644
|
+
[h.verifier, await mint(key, h.clock, { header: { typ: 'JWT' } })],
|
|
645
|
+
[h.verifier, await mint(key, h.clock, { issuer: 'https://other.example.com' })],
|
|
646
|
+
[h.verifier, await mint(key, h.clock, { audience: 'https://other.example.com/mcp' })],
|
|
647
|
+
[h.verifier, await mint(key, h.clock, { claims: { scope: 'agent:read' } })],
|
|
648
|
+
[h.verifier, await mint(key, h.clock, { expOffset: -3600 })],
|
|
649
|
+
[h.verifier, await mint(key, h.clock)],
|
|
650
|
+
[h.verifier, await mint(forger, h.clock)],
|
|
651
|
+
[h.verifier, `${await mint(key, h.clock)}${'x'.repeat(9000)}`],
|
|
652
|
+
[h.verifier, 'garbage.alice.cli-1'],
|
|
653
|
+
[down.verifier, await mint(key, down.clock)],
|
|
654
|
+
];
|
|
655
|
+
const refusals = new Set<string>();
|
|
656
|
+
for (const [verifier, token] of cases) {
|
|
657
|
+
const result: TAccessTokenAdmission = await verifier.verify(token);
|
|
658
|
+
expect(result.admitted).toBe(false);
|
|
659
|
+
expect(Object.keys(result).sort()).toEqual(['admitted', 'refusal']);
|
|
660
|
+
if (!result.admitted) refusals.add(result.refusal);
|
|
661
|
+
const serialized = JSON.stringify(result);
|
|
662
|
+
for (const segment of token.split('.').filter((part) => part.length > 8)) {
|
|
663
|
+
expect(serialized).not.toContain(segment);
|
|
664
|
+
}
|
|
665
|
+
expect(serialized).not.toContain('alice');
|
|
666
|
+
expect(serialized).not.toContain('cli-1');
|
|
667
|
+
expect(serialized).not.toContain('example.com');
|
|
668
|
+
}
|
|
669
|
+
// Every case reached a different refusal, so the check above covered each reason's path.
|
|
670
|
+
expect(refusals.size).toBe(cases.length);
|
|
671
|
+
});
|
|
672
|
+
});
|