@aauth/proxy 0.5.0 → 2.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 (53) hide show
  1. package/dist/access-mode.d.ts +21 -0
  2. package/dist/access-mode.d.ts.map +1 -0
  3. package/dist/access-mode.js +64 -0
  4. package/dist/access-mode.js.map +1 -0
  5. package/dist/agent.d.ts +85 -6
  6. package/dist/agent.d.ts.map +1 -1
  7. package/dist/agent.js +408 -129
  8. package/dist/agent.js.map +1 -1
  9. package/dist/identity-local.d.ts.map +1 -1
  10. package/dist/identity-local.js +12 -2
  11. package/dist/identity-local.js.map +1 -1
  12. package/dist/identity.d.ts +1 -0
  13. package/dist/identity.d.ts.map +1 -1
  14. package/dist/index.d.ts +7 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/jwt.d.ts +25 -0
  19. package/dist/jwt.d.ts.map +1 -0
  20. package/dist/jwt.js +77 -0
  21. package/dist/jwt.js.map +1 -0
  22. package/dist/resource.d.ts +8 -3
  23. package/dist/resource.d.ts.map +1 -1
  24. package/dist/resource.js +30 -10
  25. package/dist/resource.js.map +1 -1
  26. package/dist/store.d.ts +34 -2
  27. package/dist/store.d.ts.map +1 -1
  28. package/dist/store.js +0 -0
  29. package/dist/store.js.map +1 -1
  30. package/dist/tools.d.ts.map +1 -1
  31. package/dist/tools.js +62 -16
  32. package/dist/tools.js.map +1 -1
  33. package/dist/vocab/annotations.d.ts +30 -0
  34. package/dist/vocab/annotations.d.ts.map +1 -0
  35. package/dist/vocab/annotations.js +70 -0
  36. package/dist/vocab/annotations.js.map +1 -0
  37. package/dist/vocab/index.d.ts +1 -1
  38. package/dist/vocab/index.d.ts.map +1 -1
  39. package/dist/vocab/index.js +11 -5
  40. package/dist/vocab/index.js.map +1 -1
  41. package/dist/vocab/openapi.d.ts +4 -0
  42. package/dist/vocab/openapi.d.ts.map +1 -1
  43. package/dist/vocab/openapi.js +13 -0
  44. package/dist/vocab/openapi.js.map +1 -1
  45. package/dist/vocab/types.d.ts +18 -2
  46. package/dist/vocab/types.d.ts.map +1 -1
  47. package/dist/vocab/types.js +5 -2
  48. package/dist/vocab/types.js.map +1 -1
  49. package/package.json +2 -2
  50. package/dist/vocab/openapi-gateway.d.ts +0 -14
  51. package/dist/vocab/openapi-gateway.d.ts.map +0 -1
  52. package/dist/vocab/openapi-gateway.js +0 -70
  53. package/dist/vocab/openapi-gateway.js.map +0 -1
package/dist/agent.js CHANGED
@@ -1,43 +1,101 @@
1
- // agent proxy — the user's AAuth agent. v0 core: the authorize-first R3 invoke flow
2
- // against the resource proxy, exchanging at the PS.
1
+ // agent proxy — the user's AAuth agent. The invoke flow against an AAuth
2
+ // resource, obtaining person tokens and auth tokens at the PS.
3
3
  //
4
- // invoke() is non-blocking: when an interaction is required (PS consent or the
5
- // resource's OAuth bootstrap) it RETURNS the interaction (url + code + poll URL)
6
- // rather than completing it, so a caller the MCP server can surface the URL
7
- // to the user. invokeComplete() drives it to completion for programmatic use,
8
- // performing the interaction via a callback and polling.
4
+ // The shape of the flow is the one the protocol describes: pick an opening
5
+ // credential from what the resource declared, make the request, read any
6
+ // AAuth-Requirement, satisfy it, retry. The declaration only saves round trips
7
+ // the runtime requirement is authoritative and can escalate at any point.
9
8
  //
10
- // Built directly on @hellocoop/httpsig (the @aauth/mcp-agent package is
11
- // 401-challenge-driven and has no R3/authorize-first path, mirroring the
9
+ // invoke() is non-blocking: when an interaction is required (PS consent, a person
10
+ // token the PS wants the user to approve, or the resource's own OAuth bootstrap)
11
+ // it RETURNS the interaction (url + code + poll URL) rather than completing it,
12
+ // so a caller — the MCP server — can surface the URL to the user.
13
+ // invokeComplete() drives it to completion for programmatic use, performing the
14
+ // interaction via a callback and polling.
15
+ //
16
+ // Built directly on @hellocoop/httpsig (the @aauth/agent package is
17
+ // 401-challenge-driven and has no authorize-first path, mirroring the
12
18
  // resource-side finding).
13
19
  import { fetch as signedFetch } from '@hellocoop/httpsig';
20
+ import { planAccessMode } from './access-mode.js';
21
+ import { agentTokenPs, jwkThumbprint } from './jwt.js';
14
22
  import { routeOperation } from './resource.js';
15
- function signWith(cfg, token) {
16
- return (url, init = {}) => signedFetch(url, { ...init, signingKey: cfg.agentPrivateJwk, signatureKey: { type: 'jwt', jwt: token } });
23
+ import { createMemoryPersonTokenStore } from './store.js';
24
+ // The AAuth HTTP Message Signatures profile's base covered components. httpsig
25
+ // applies these itself when no list is passed; we pass an explicit list whenever
26
+ // something must be added to it.
27
+ const BASE_GET = ['@method', '@authority', '@path', 'signature-key'];
28
+ const BASE_BODY = ['@method', '@authority', '@path', 'content-type', 'signature-key'];
29
+ function components(opts) {
30
+ const base = opts.hasBody ? [...BASE_BODY] : [...BASE_GET];
31
+ let extended = false;
32
+ if (opts.hasBody && opts.psOrAs) {
33
+ base.splice(base.indexOf('content-type') + 1, 0, 'content-digest');
34
+ extended = true;
35
+ }
36
+ if (opts.authorization) {
37
+ base.splice(base.length - 1, 0, 'authorization');
38
+ extended = true;
39
+ }
40
+ return extended ? base : undefined;
41
+ }
42
+ function signWith(cfg, cred, opts = {}) {
43
+ return (url, init = {}) => {
44
+ const headers = { ...(init.headers ?? {}) };
45
+ if (cred.kind === 'session')
46
+ headers.authorization = `AAuth ${cred.token}`;
47
+ const jwt = cred.kind === 'person' || cred.kind === 'auth' ? cred.jwt : cfg.agentToken;
48
+ const list = components({
49
+ hasBody: init.body !== undefined,
50
+ authorization: cred.kind === 'session',
51
+ psOrAs: opts.psOrAs,
52
+ });
53
+ return signedFetch(url, {
54
+ ...init,
55
+ headers,
56
+ signingKey: cfg.agentPrivateJwk,
57
+ signatureKey: { type: 'jwt', jwt },
58
+ ...(list ? { components: list } : {}),
59
+ });
60
+ };
17
61
  }
18
62
  export function makeAgentPoll(cfg) {
19
- return (url) => signWith(cfg, cfg.agentToken)(url, { method: 'GET', headers: { Prefer: 'wait=20' } });
63
+ return (url) => signWith(cfg, { kind: 'agent' })(url, { method: 'GET', headers: { Prefer: 'wait=20' } });
20
64
  }
21
- function parseInteraction(requirement) {
22
- if (!requirement || !requirement.includes('requirement=interaction'))
65
+ // Parses the AAuth-Requirement header. Unrecognized `requirement=` values are
66
+ // returned as-is: the caller decides, and treats anything it cannot satisfy as a
67
+ // terminal response rather than guessing.
68
+ function parseRequirement(headerValue) {
69
+ if (!headerValue)
23
70
  return undefined;
24
- const url = /url="([^"]+)"/.exec(requirement)?.[1];
25
- const code = /code="([^"]+)"/.exec(requirement)?.[1];
26
- return url && code ? { url, code } : undefined;
27
- }
28
- // A per-call escalation challenge (W2): the resource returns 401 with
29
- // `requirement=auth-token; resource-token="…"` for a conditional/irreversible op.
30
- // The agent takes that resource token (which carries the call as r3_context) to
31
- // the PS for a per-call auth token, then retries.
32
- function parseAuthTokenChallenge(requirement) {
33
- if (!requirement || !requirement.includes('requirement=auth-token'))
71
+ const requirement = /requirement=([A-Za-z0-9_-]+)/.exec(headerValue)?.[1];
72
+ if (!requirement)
34
73
  return undefined;
35
- return /resource-token="([^"]+)"/.exec(requirement)?.[1];
74
+ return {
75
+ requirement,
76
+ resourceToken: /resource-token="([^"]+)"/.exec(headerValue)?.[1],
77
+ url: /url="([^"]+)"/.exec(headerValue)?.[1],
78
+ code: /code="([^"]+)"/.exec(headerValue)?.[1],
79
+ reason: /reason=([A-Za-z0-9_-]+)/.exec(headerValue)?.[1],
80
+ };
81
+ }
82
+ // A terminal challenge response, annotated with the challenge's `reason` when
83
+ // one was sent so the caller (ultimately the LLM) sees `budget-exhausted` /
84
+ // `insufficient-budget` instead of a bare status.
85
+ async function terminalChallenge(res, req) {
86
+ const body = await safeBody(res);
87
+ return {
88
+ kind: 'result',
89
+ status: res.status,
90
+ body: req.reason ? { error: req.reason, detail: body } : body,
91
+ };
36
92
  }
37
93
  function interactionFrom(res) {
38
- const parsed = parseInteraction(res.headers.get('aauth-requirement'));
94
+ const parsed = parseRequirement(res.headers.get('aauth-requirement'));
39
95
  const pollUrl = res.headers.get('location') ?? '';
40
- return parsed && pollUrl ? { ...parsed, pollUrl } : undefined;
96
+ return parsed?.requirement === 'interaction' && parsed.url && parsed.code && pollUrl
97
+ ? { url: parsed.url, code: parsed.code, pollUrl }
98
+ : undefined;
41
99
  }
42
100
  async function safeBody(res) {
43
101
  const text = await res.text();
@@ -51,11 +109,108 @@ async function safeBody(res) {
51
109
  async function psMetadata(psUrl) {
52
110
  return (await (await fetch(`${psUrl.replace(/\/$/, '')}/.well-known/aauth-person.json`)).json());
53
111
  }
112
+ // ── Per-config default stores ──
113
+ //
114
+ // Keyed on the ProxyConfig object, which the identity provider resolves
115
+ // per-principal. A process-global cache would leak person and session tokens
116
+ // across tenants in a multi-user host.
117
+ const defaultPersonTokens = new WeakMap();
118
+ const defaultSessionTokens = new WeakMap();
119
+ function personTokenStore(cfg) {
120
+ if (cfg.personTokens)
121
+ return cfg.personTokens;
122
+ let store = defaultPersonTokens.get(cfg);
123
+ if (!store) {
124
+ store = createMemoryPersonTokenStore();
125
+ defaultPersonTokens.set(cfg, store);
126
+ }
127
+ return store;
128
+ }
129
+ function sessionTokenStore(cfg) {
130
+ if (cfg.sessionTokens)
131
+ return cfg.sessionTokens;
132
+ let store = defaultSessionTokens.get(cfg);
133
+ if (!store) {
134
+ const m = new Map();
135
+ store = {
136
+ async get(resource) {
137
+ return m.get(resource);
138
+ },
139
+ async set(resource, token) {
140
+ m.set(resource, token);
141
+ },
142
+ };
143
+ defaultSessionTokens.set(cfg, store);
144
+ }
145
+ return store;
146
+ }
147
+ /**
148
+ * Obtain a person token for one resource, from cache or from the PS.
149
+ *
150
+ * A resource MUST have verified a person token before it issues a resource token,
151
+ * and the agent MUST present one on every authorization endpoint request
152
+ * (protocol §Person Token, §Authorization Endpoint Request) — so this sits in
153
+ * front of the whole authorize-first path, not only of `person-token` resources.
154
+ *
155
+ * The PS MAY require the user to approve the agent acting at this resource before
156
+ * issuing, and answers `202` with `requirement=interaction`. That is surfaced
157
+ * like any other interaction; the caller drives it and retries, and the second
158
+ * request gets a `200`.
159
+ */
160
+ export async function obtainPersonToken(cfg, ps, resource, missionS256) {
161
+ if (!ps.person_token_endpoint) {
162
+ return {
163
+ kind: 'result',
164
+ status: 0,
165
+ body: {
166
+ error: 'ps_missing_person_token_endpoint',
167
+ error_description: `${cfg.psUrl} publishes no person_token_endpoint; AAuth -11 requires one`,
168
+ },
169
+ };
170
+ }
171
+ const store = personTokenStore(cfg);
172
+ const jkt = await jwkThumbprint(cfg.agentPrivateJwk);
173
+ const key = { resource, ...(missionS256 ? { mission_s256: missionS256 } : {}) };
174
+ const cached = await store.get(key, jkt);
175
+ if (cached)
176
+ return { kind: 'token', personToken: cached };
177
+ const res = await signWith(cfg, { kind: 'agent' }, { psOrAs: true })(ps.person_token_endpoint, {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/json' },
180
+ body: JSON.stringify({
181
+ resource,
182
+ ...(missionS256 ? { mission_s256: missionS256 } : {}),
183
+ }),
184
+ });
185
+ if (res.status === 202) {
186
+ const interaction = interactionFrom(res);
187
+ if (interaction)
188
+ return { kind: 'interaction', interaction };
189
+ }
190
+ if (!res.ok)
191
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
192
+ const { person_token, expires_in } = (await res.json());
193
+ if (!person_token) {
194
+ return { kind: 'result', status: res.status, body: { error: 'ps_returned_no_person_token' } };
195
+ }
196
+ const expiresAt = Math.floor(Date.now() / 1000) + (expires_in ?? 3600);
197
+ await store.set(key, jkt, person_token, expiresAt);
198
+ return { kind: 'token', personToken: person_token };
199
+ }
200
+ /**
201
+ * Drop every cached person token. Call when the agent's signing key rotates —
202
+ * every person token binds the same key through `cnf`, so none of them survive.
203
+ * `obtainPersonToken` also detects rotation on its own via the key thumbprint;
204
+ * this is the explicit hook for a host that knows a rotation happened.
205
+ */
206
+ export async function flushPersonTokens(cfg) {
207
+ await personTokenStore(cfg).flush();
208
+ }
54
209
  // POST the interaction to the PS so it can try to reach the user (live web
55
210
  // session, registered mobile push). On 2xx the PS owns user-reach; the agent
56
- // blocks on the resource pollUrl until the user completes there. On any
57
- // non-2xx (including the spec-pending interaction_unavailable error — see
58
- // AAuth#34) the agent falls back to driving the URL itself.
211
+ // blocks on the pollUrl until the user completes there. On any non-2xx
212
+ // (including the spec-pending interaction_unavailable error — see AAuth#34) the
213
+ // agent falls back to driving the URL itself.
59
214
  async function relayInteractionToPS(signAgent, endpoint, interaction) {
60
215
  try {
61
216
  const res = await signAgent(endpoint, {
@@ -91,17 +246,22 @@ export async function pollUntilDone(poll, locationUrl, timeoutMs = 180_000, onPo
91
246
  }
92
247
  return res;
93
248
  }
94
- // Exchange a resource token at the PS for an auth token. capabilities tells the
249
+ // Exchange a resource token at the PS for an auth token. `capabilities` tells the
95
250
  // PS the agent can relay interactions to the user, so it returns a 202 consent
96
251
  // interaction (surfaced for the caller to drive + retry) rather than requiring a
97
- // registered mobile device. On PS endpoints this is a token-request parameter,
252
+ // registered mobile device. On PS endpoints this is a request-body parameter,
98
253
  // not a header (the AAuth-Capabilities header is for resource requests).
99
254
  //
100
- // cfg.psHints (if set) are spread into the body all §8.1 optional params.
101
- // cfg.onAuthToken (if set) is called with the auth_token before it is returned.
102
- async function exchangeAtPS(signAgent, tokenEndpoint, resourceToken, cfg) {
255
+ // The mission does not appear here: it travels in the person token's
256
+ // `mission_s256`, which the resource copies into the resource token and the PS
257
+ // into the auth token.
258
+ //
259
+ // cfg.psHints (if set) are spread into the body — all §Agent Token Request
260
+ // optional params. cfg.onAuthToken (if set) is called with the auth_token before
261
+ // it is returned.
262
+ async function exchangeAtPS(cfg, authTokenEndpoint, resourceToken) {
103
263
  const { capabilities, ...otherHints } = cfg.psHints ?? {};
104
- const res = await signAgent(tokenEndpoint, {
264
+ const res = await signWith(cfg, { kind: 'agent' }, { psOrAs: true })(authTokenEndpoint, {
105
265
  method: 'POST',
106
266
  headers: { 'content-type': 'application/json' },
107
267
  body: JSON.stringify({
@@ -122,10 +282,38 @@ async function exchangeAtPS(signAgent, tokenEndpoint, resourceToken, cfg) {
122
282
  await cfg.onAuthToken(auth_token);
123
283
  return { kind: 'token', authToken: auth_token };
124
284
  }
125
- // Resource-parameterized invoke. The new tool surface in server.ts calls into
126
- // this directly with an L1 entry from store.ts; the legacy invoke() below
127
- // wraps it for cfg.resourceProxyBase-based callers (tests, demo scripts).
128
- export async function invokeAtResource(cfg, l1, operationId, args = {}) {
285
+ // ── Authorize-first ──
286
+ /**
287
+ * POST the resource's authorization endpoint, declaring the operation, and take
288
+ * back a resource token. The request MUST present a person token via
289
+ * Signature-Key (protocol §Authorization Endpoint Request) — an agent token gets
290
+ * `requirement=person-token`.
291
+ */
292
+ async function authorizeAtResource(cfg, endpoint, personToken, vocabulary, operationId) {
293
+ const res = await signWith(cfg, { kind: 'person', jwt: personToken })(endpoint, {
294
+ method: 'POST',
295
+ headers: { 'content-type': 'application/json' },
296
+ body: JSON.stringify({
297
+ r3_operations: {
298
+ vocabulary,
299
+ // Bare identifiers, scoped to the one discovery endpoint the resource
300
+ // advertises for this vocabulary (R3 -02 §Operation Identifier Scope).
301
+ operations: [{ operationId }],
302
+ },
303
+ }),
304
+ });
305
+ if (!res.ok)
306
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
307
+ const { resource_token } = (await res.json());
308
+ if (!resource_token) {
309
+ // The resource handled authorization itself and issued no resource token.
310
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
311
+ }
312
+ return { kind: 'resourceToken', resourceToken: resource_token };
313
+ }
314
+ // ── invoke ──
315
+ const MAX_ROUNDS = 6;
316
+ export async function invokeAtResource(cfg, l1, operationId, args = {}, opts = {}) {
129
317
  const route = await routeOperation(l1, operationId, args);
130
318
  if (route.plan.kind !== 'sync.request') {
131
319
  return {
@@ -136,112 +324,203 @@ export async function invokeAtResource(cfg, l1, operationId, args = {}) {
136
324
  }
137
325
  const plan = route.plan;
138
326
  const apiUrl = `${l1.origin}${plan.path}${plan.query ? `?${plan.query}` : ''}`;
139
- const requestInit = {
327
+ // Fixed for the whole flow. A per-call retry MUST present exactly the
328
+ // parameters the proposal was approved for (R3 -02 §Per-Call Proposals step 3):
329
+ // the resource recovers the proposal by its hash and rejects any difference.
330
+ const init = {
140
331
  method: plan.method,
141
332
  ...(plan.headers ? { headers: plan.headers } : {}),
142
333
  ...(plan.body !== undefined ? { body: plan.body } : {}),
143
334
  };
144
- // agent-token access mode: sign with the agent token + go. No PS exchange.
145
- // If the resource returns a 401 auth-token challenge, escalate to the PS.
146
- if (l1.access_mode === 'agent-token') {
147
- const signAgent = signWith(cfg, cfg.agentToken);
148
- let res = await signAgent(apiUrl, requestInit);
149
- const challenge = res.status === 401 ? parseAuthTokenChallenge(res.headers.get('aauth-requirement')) : undefined;
150
- if (challenge) {
151
- const ps = await psMetadata(cfg.psUrl);
152
- const ex = await exchangeAtPS(signAgent, ps.token_endpoint, challenge, cfg);
153
- if (ex.kind !== 'token')
154
- return ex;
155
- res = await signWith(cfg, ex.authToken)(apiUrl, requestInit);
156
- if (res.status === 202) {
157
- const interaction = interactionFrom(res);
158
- if (interaction)
159
- return { kind: 'interaction', interaction };
335
+ const missionS256 = opts.missionS256 ?? cfg.missionS256;
336
+ // Three-way access_mode plan against the mode that applies to THIS operation:
337
+ // its own annotation when it carries one, the resource-wide access_mode
338
+ // otherwise (R3 -02 §Applying Annotations).
339
+ const accessPlan = planAccessMode(route.accessMode, {
340
+ hasPersonServer: agentTokenPs(cfg.agentToken) !== undefined,
341
+ });
342
+ if (accessPlan.kind === 'unsatisfiable') {
343
+ return {
344
+ kind: 'skipped',
345
+ resource: l1.resource,
346
+ opId: operationId,
347
+ mode: accessPlan.mode,
348
+ reason: accessPlan.reason,
349
+ };
350
+ }
351
+ let ps;
352
+ const needPS = async () => {
353
+ ps ??= await psMetadata(cfg.psUrl);
354
+ return ps;
355
+ };
356
+ const sessions = sessionTokenStore(cfg);
357
+ // ── Opening credential ──
358
+ //
359
+ // The plan only decides where to start. Everything after this point is the
360
+ // requirement loop, which is identical in every mode.
361
+ let cred = { kind: 'agent' };
362
+ if (accessPlan.kind === 'satisfiable') {
363
+ switch (accessPlan.mode) {
364
+ case 'agent-token':
365
+ break;
366
+ case 'session-token': {
367
+ // Resource-managed. Present the session token if we already hold one;
368
+ // otherwise call with the agent token and let the resource start its own
369
+ // consent flow with a 202 interaction.
370
+ const held = await sessions.get(l1.resource);
371
+ if (held)
372
+ cred = { kind: 'session', token: held };
373
+ break;
374
+ }
375
+ case 'person-token': {
376
+ const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256);
377
+ if (pt.kind !== 'token')
378
+ return pt;
379
+ cred = { kind: 'person', jwt: pt.personToken };
380
+ break;
381
+ }
382
+ case 'auth-token':
383
+ case 'per-call': {
384
+ // Authorize-first when the resource publishes an authorization_endpoint:
385
+ // declare the operation, take back a resource token, exchange it at the
386
+ // PS. Without one, the resource issues resource tokens via 401 instead
387
+ // (protocol §Resource Access and Resource Tokens) — start with the
388
+ // person token and let the requirement loop pick up the challenge.
389
+ const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256);
390
+ if (pt.kind !== 'token')
391
+ return pt;
392
+ cred = { kind: 'person', jwt: pt.personToken };
393
+ if (l1.authorization_endpoint) {
394
+ const authz = await authorizeAtResource(cfg, l1.authorization_endpoint, pt.personToken, route.adapter.vocabUri, operationId);
395
+ if (authz.kind !== 'resourceToken')
396
+ return authz;
397
+ const ex = await exchangeAtPS(cfg, (await needPS()).auth_token_endpoint, authz.resourceToken);
398
+ if (ex.kind !== 'token')
399
+ return ex;
400
+ cred = { kind: 'auth', jwt: ex.authToken };
401
+ }
402
+ break;
160
403
  }
161
404
  }
162
- return { kind: 'result', status: res.status, body: await safeBody(res) };
163
- }
164
- // aauth-access-token / auth-token: R3 flow.
165
- if (!l1.authorization_endpoint) {
166
- throw new Error(`resource ${l1.resource}: no authorization_endpoint (access_mode ${l1.access_mode} requires R3)`);
167
405
  }
168
- const signAgent = signWith(cfg, cfg.agentToken);
169
- const ps = await psMetadata(cfg.psUrl);
170
- // 1. authorize-first: declare the operation, get a resource token.
171
- const authzRes = await signAgent(l1.authorization_endpoint, {
172
- method: 'POST',
173
- headers: { 'content-type': 'application/json' },
174
- body: JSON.stringify({
175
- r3_operations: {
176
- vocabulary: route.adapter.vocabUri,
177
- // Entry shape is vocabulary-specific: {operationId} for plain openapi,
178
- // {service, operationId} for openapi-gateway.
179
- operations: [route.adapter.formatOperationEntry?.(operationId) ?? { operationId }],
180
- },
181
- }),
182
- });
183
- if (!authzRes.ok)
184
- return { kind: 'result', status: authzRes.status, body: await safeBody(authzRes) };
185
- const { resource_token } = (await authzRes.json());
186
- // 2. exchange at the PS for an auth token (surface a consent interaction if any).
187
- const ex = await exchangeAtPS(signAgent, ps.token_endpoint, resource_token, cfg);
188
- if (ex.kind !== 'token')
189
- return ex;
190
- // 3. call the resource. Writes carry the body; the agent signs over it so the
191
- // proxy's content-digest check (and the r3_context it packs on escalation)
192
- // match the actual call.
193
- const callWith = (token) => signWith(cfg, token)(apiUrl, requestInit);
194
- let apiRes = await callWith(ex.authToken);
195
- // First contact: a resource-issued interaction (e.g. OAuth bootstrap). Try
196
- // the PS's interaction_endpoint first so it can use its own user-reach
197
- // channels (live web session, mobile push). On any non-2xx — including the
198
- // spec-pending interaction_unavailable error (AAuth#34) and any PS that
199
- // hasn't implemented the endpoint yet — surface the interaction so the
200
- // caller can drive it (layer 2: local OS open / layer 3: text+QR).
201
- if (apiRes.status === 202) {
202
- const interaction = interactionFrom(apiRes);
203
- if (interaction) {
204
- const engaged = ps.interaction_endpoint
205
- ? await relayInteractionToPS(signAgent, ps.interaction_endpoint, interaction)
206
- : false;
207
- if (!engaged)
208
- return { kind: 'interaction', interaction };
209
- const poll = (url) => signWith(cfg, cfg.agentToken)(url, { method: 'GET', headers: { Prefer: 'wait=20' } });
210
- const completed = await pollUntilDone(poll, interaction.pollUrl, 180_000);
211
- if (completed.status === 202)
212
- return { kind: 'interaction', interaction };
213
- apiRes = await callWith(ex.authToken);
406
+ // ── Requirement loop ──
407
+ //
408
+ // Make the request, read any AAuth-Requirement, satisfy it, retry. `satisfied`
409
+ // stops the loop from chasing the same requirement twice with the same
410
+ // credential, which is what a resource that will never be satisfiable looks
411
+ // like from here.
412
+ const satisfied = new Set();
413
+ for (let round = 0; round < MAX_ROUNDS; round++) {
414
+ const res = await signWith(cfg, cred)(apiUrl, init);
415
+ // A resource MAY replace the agent's session token on any response.
416
+ const access = res.headers.get('aauth-access');
417
+ if (access) {
418
+ await sessions.set(l1.resource, access);
419
+ if (cred.kind !== 'auth' && cred.kind !== 'person')
420
+ cred = { kind: 'session', token: access };
214
421
  }
215
- }
216
- // Per-call escalation (W2): a conditional/irreversible op returns a 401
217
- // auth-token challenge. Take its resource token (which carries the call as
218
- // r3_context) to the PS for a per-call auth token, then retry the same call.
219
- // If the PS needs consent first, it returns a 202 interaction we surface.
220
- const challenge = apiRes.status === 401 ? parseAuthTokenChallenge(apiRes.headers.get('aauth-requirement')) : undefined;
221
- if (challenge) {
222
- const stepEx = await exchangeAtPS(signAgent, ps.token_endpoint, challenge, cfg);
223
- if (stepEx.kind !== 'token')
224
- return stepEx;
225
- apiRes = await callWith(stepEx.authToken);
226
- if (apiRes.status === 202) {
227
- const interaction = interactionFrom(apiRes);
228
- if (interaction)
229
- return { kind: 'interaction', interaction };
422
+ const req = parseRequirement(res.headers.get('aauth-requirement'));
423
+ if (!req)
424
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
425
+ const marker = `${req.requirement}:${cred.kind}`;
426
+ if (satisfied.has(marker)) {
427
+ // Same requirement, same credential kind, second time — e.g. the budget of
428
+ // the auth token we just re-acquired is exhausted again. Stop and surface
429
+ // the challenge (with its reason) rather than spin.
430
+ return terminalChallenge(res, req);
431
+ }
432
+ satisfied.add(marker);
433
+ switch (req.requirement) {
434
+ case 'agent-token': {
435
+ // The resource wants the agent's own identity token specifically.
436
+ if (cred.kind === 'agent') {
437
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
438
+ }
439
+ cred = { kind: 'agent' };
440
+ continue;
441
+ }
442
+ case 'person-token': {
443
+ const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256);
444
+ if (pt.kind !== 'token')
445
+ return pt;
446
+ cred = { kind: 'person', jwt: pt.personToken };
447
+ continue;
448
+ }
449
+ case 'auth-token': {
450
+ // Also the per-call path: for an `r3_per_call` operation the resource
451
+ // builds a proposal from this call's concrete parameters, persists it
452
+ // under its hash, and returns a resource token carrying only the
453
+ // `r3_uri`/`r3_s256` reference. The agent exchanges it and retries the
454
+ // identical call (R3 -02 §Per-Call Proposals).
455
+ if (!req.resourceToken) {
456
+ return terminalChallenge(res, req);
457
+ }
458
+ const ex = await exchangeAtPS(cfg, (await needPS()).auth_token_endpoint, req.resourceToken);
459
+ if (ex.kind !== 'token')
460
+ return ex;
461
+ cred = { kind: 'auth', jwt: ex.authToken };
462
+ continue;
463
+ }
464
+ case 'interaction': {
465
+ const interaction = interactionFrom(res);
466
+ if (!interaction) {
467
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
468
+ }
469
+ // Try the PS's interaction endpoint first so it can use its own
470
+ // user-reach channels (live web session, mobile push). On any non-2xx —
471
+ // including the spec-pending interaction_unavailable error (AAuth#34)
472
+ // and any PS that hasn't implemented the endpoint yet — surface the
473
+ // interaction so the caller can drive it (layer 2: local OS open;
474
+ // layer 3: text + QR).
475
+ const meta = await needPS().catch(() => undefined);
476
+ const engaged = meta?.interaction_endpoint
477
+ ? await relayInteractionToPS(signWith(cfg, { kind: 'agent' }), meta.interaction_endpoint, interaction)
478
+ : false;
479
+ if (!engaged)
480
+ return { kind: 'interaction', interaction };
481
+ const completed = await pollUntilDone(makeAgentPoll(cfg), interaction.pollUrl, 180_000);
482
+ if (completed.status === 202)
483
+ return { kind: 'interaction', interaction };
484
+ const settled = completed.headers.get('aauth-access');
485
+ if (settled) {
486
+ await sessions.set(l1.resource, settled);
487
+ cred = { kind: 'session', token: settled };
488
+ }
489
+ continue;
490
+ }
491
+ default:
492
+ // A requirement value this build does not know. The agent MUST NOT
493
+ // treat the response as satisfiable; surface it verbatim.
494
+ return {
495
+ kind: 'result',
496
+ status: res.status,
497
+ body: {
498
+ error: 'unsupported_requirement',
499
+ requirement: req.requirement,
500
+ detail: await safeBody(res),
501
+ },
502
+ };
230
503
  }
231
504
  }
232
- return { kind: 'result', status: apiRes.status, body: await safeBody(apiRes) };
505
+ return {
506
+ kind: 'result',
507
+ status: 429,
508
+ body: { error: 'requirement_loop', detail: `${l1.resource} kept challenging after ${MAX_ROUNDS} rounds` },
509
+ };
233
510
  }
234
511
  // Drive invokeAtResource to completion: perform each interaction via
235
512
  // `onInteraction`, poll until it resolves, and retry. For programmatic / test
236
513
  // use; the MCP tool surface is non-blocking by design and surfaces interaction
237
514
  // URLs to the LLM caller instead.
238
- export async function invokeAtResourceComplete(cfg, l1, operationId, onInteraction, args = {}, maxRounds = 5, pollTimeoutMs = 180_000, onPoll) {
239
- // Poll deferred URLs signed with the agent token (long-poll via Prefer: wait).
240
- const poll = (url) => signWith(cfg, cfg.agentToken)(url, { method: 'GET', headers: { Prefer: 'wait=20' } });
515
+ export async function invokeAtResourceComplete(cfg, l1, operationId, onInteraction, args = {}, maxRounds = 5, pollTimeoutMs = 180_000, onPoll, opts = {}) {
516
+ const poll = makeAgentPoll(cfg);
241
517
  for (let round = 0; round < maxRounds; round++) {
242
- const result = await invokeAtResource(cfg, l1, operationId, args);
518
+ const result = await invokeAtResource(cfg, l1, operationId, args, opts);
243
519
  if (result.kind === 'result')
244
520
  return { status: result.status, body: result.body };
521
+ if (result.kind === 'skipped') {
522
+ return { status: 0, body: { error: 'access_mode_unsatisfiable', ...result } };
523
+ }
245
524
  await onInteraction(result.interaction.url, result.interaction.code);
246
525
  await pollUntilDone(poll, result.interaction.pollUrl, pollTimeoutMs, onPoll);
247
526
  }
@@ -250,6 +529,6 @@ export async function invokeAtResourceComplete(cfg, l1, operationId, onInteracti
250
529
  // Signed DELETE to an admin endpoint on the resource (e.g. /admin/tokens).
251
530
  // Uses the agent token so the resource can verify the caller owns the key.
252
531
  export async function deleteAtAdmin(cfg, l1, path) {
253
- return signWith(cfg, cfg.agentToken)(`${l1.origin}${path}`, { method: 'DELETE' });
532
+ return signWith(cfg, { kind: 'agent' })(`${l1.origin}${path}`, { method: 'DELETE' });
254
533
  }
255
534
  //# sourceMappingURL=agent.js.map