@aauth/proxy 0.4.0 → 1.0.1

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 +393 -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 +25 -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 +60 -14
  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 +4 -1
  48. package/dist/vocab/types.js.map +1 -1
  49. package/package.json +3 -3
  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,89 @@
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
+ };
36
80
  }
37
81
  function interactionFrom(res) {
38
- const parsed = parseInteraction(res.headers.get('aauth-requirement'));
82
+ const parsed = parseRequirement(res.headers.get('aauth-requirement'));
39
83
  const pollUrl = res.headers.get('location') ?? '';
40
- return parsed && pollUrl ? { ...parsed, pollUrl } : undefined;
84
+ return parsed?.requirement === 'interaction' && parsed.url && parsed.code && pollUrl
85
+ ? { url: parsed.url, code: parsed.code, pollUrl }
86
+ : undefined;
41
87
  }
42
88
  async function safeBody(res) {
43
89
  const text = await res.text();
@@ -51,11 +97,108 @@ async function safeBody(res) {
51
97
  async function psMetadata(psUrl) {
52
98
  return (await (await fetch(`${psUrl.replace(/\/$/, '')}/.well-known/aauth-person.json`)).json());
53
99
  }
100
+ // ── Per-config default stores ──
101
+ //
102
+ // Keyed on the ProxyConfig object, which the identity provider resolves
103
+ // per-principal. A process-global cache would leak person and session tokens
104
+ // across tenants in a multi-user host.
105
+ const defaultPersonTokens = new WeakMap();
106
+ const defaultSessionTokens = new WeakMap();
107
+ function personTokenStore(cfg) {
108
+ if (cfg.personTokens)
109
+ return cfg.personTokens;
110
+ let store = defaultPersonTokens.get(cfg);
111
+ if (!store) {
112
+ store = createMemoryPersonTokenStore();
113
+ defaultPersonTokens.set(cfg, store);
114
+ }
115
+ return store;
116
+ }
117
+ function sessionTokenStore(cfg) {
118
+ if (cfg.sessionTokens)
119
+ return cfg.sessionTokens;
120
+ let store = defaultSessionTokens.get(cfg);
121
+ if (!store) {
122
+ const m = new Map();
123
+ store = {
124
+ async get(resource) {
125
+ return m.get(resource);
126
+ },
127
+ async set(resource, token) {
128
+ m.set(resource, token);
129
+ },
130
+ };
131
+ defaultSessionTokens.set(cfg, store);
132
+ }
133
+ return store;
134
+ }
135
+ /**
136
+ * Obtain a person token for one resource, from cache or from the PS.
137
+ *
138
+ * A resource MUST have verified a person token before it issues a resource token,
139
+ * and the agent MUST present one on every authorization endpoint request
140
+ * (protocol §Person Token, §Authorization Endpoint Request) — so this sits in
141
+ * front of the whole authorize-first path, not only of `person-token` resources.
142
+ *
143
+ * The PS MAY require the user to approve the agent acting at this resource before
144
+ * issuing, and answers `202` with `requirement=interaction`. That is surfaced
145
+ * like any other interaction; the caller drives it and retries, and the second
146
+ * request gets a `200`.
147
+ */
148
+ export async function obtainPersonToken(cfg, ps, resource, missionS256) {
149
+ if (!ps.person_token_endpoint) {
150
+ return {
151
+ kind: 'result',
152
+ status: 0,
153
+ body: {
154
+ error: 'ps_missing_person_token_endpoint',
155
+ error_description: `${cfg.psUrl} publishes no person_token_endpoint; AAuth -11 requires one`,
156
+ },
157
+ };
158
+ }
159
+ const store = personTokenStore(cfg);
160
+ const jkt = await jwkThumbprint(cfg.agentPrivateJwk);
161
+ const key = { resource, ...(missionS256 ? { mission_s256: missionS256 } : {}) };
162
+ const cached = await store.get(key, jkt);
163
+ if (cached)
164
+ return { kind: 'token', personToken: cached };
165
+ const res = await signWith(cfg, { kind: 'agent' }, { psOrAs: true })(ps.person_token_endpoint, {
166
+ method: 'POST',
167
+ headers: { 'content-type': 'application/json' },
168
+ body: JSON.stringify({
169
+ resource,
170
+ ...(missionS256 ? { mission_s256: missionS256 } : {}),
171
+ }),
172
+ });
173
+ if (res.status === 202) {
174
+ const interaction = interactionFrom(res);
175
+ if (interaction)
176
+ return { kind: 'interaction', interaction };
177
+ }
178
+ if (!res.ok)
179
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
180
+ const { person_token, expires_in } = (await res.json());
181
+ if (!person_token) {
182
+ return { kind: 'result', status: res.status, body: { error: 'ps_returned_no_person_token' } };
183
+ }
184
+ const expiresAt = Math.floor(Date.now() / 1000) + (expires_in ?? 3600);
185
+ await store.set(key, jkt, person_token, expiresAt);
186
+ return { kind: 'token', personToken: person_token };
187
+ }
188
+ /**
189
+ * Drop every cached person token. Call when the agent's signing key rotates —
190
+ * every person token binds the same key through `cnf`, so none of them survive.
191
+ * `obtainPersonToken` also detects rotation on its own via the key thumbprint;
192
+ * this is the explicit hook for a host that knows a rotation happened.
193
+ */
194
+ export async function flushPersonTokens(cfg) {
195
+ await personTokenStore(cfg).flush();
196
+ }
54
197
  // POST the interaction to the PS so it can try to reach the user (live web
55
198
  // 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.
199
+ // blocks on the pollUrl until the user completes there. On any non-2xx
200
+ // (including the spec-pending interaction_unavailable error — see AAuth#34) the
201
+ // agent falls back to driving the URL itself.
59
202
  async function relayInteractionToPS(signAgent, endpoint, interaction) {
60
203
  try {
61
204
  const res = await signAgent(endpoint, {
@@ -91,17 +234,22 @@ export async function pollUntilDone(poll, locationUrl, timeoutMs = 180_000, onPo
91
234
  }
92
235
  return res;
93
236
  }
94
- // Exchange a resource token at the PS for an auth token. capabilities tells the
237
+ // Exchange a resource token at the PS for an auth token. `capabilities` tells the
95
238
  // PS the agent can relay interactions to the user, so it returns a 202 consent
96
239
  // 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,
240
+ // registered mobile device. On PS endpoints this is a request-body parameter,
98
241
  // not a header (the AAuth-Capabilities header is for resource requests).
99
242
  //
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) {
243
+ // The mission does not appear here: it travels in the person token's
244
+ // `mission_s256`, which the resource copies into the resource token and the PS
245
+ // into the auth token.
246
+ //
247
+ // cfg.psHints (if set) are spread into the body — all §Agent Token Request
248
+ // optional params. cfg.onAuthToken (if set) is called with the auth_token before
249
+ // it is returned.
250
+ async function exchangeAtPS(cfg, authTokenEndpoint, resourceToken) {
103
251
  const { capabilities, ...otherHints } = cfg.psHints ?? {};
104
- const res = await signAgent(tokenEndpoint, {
252
+ const res = await signWith(cfg, { kind: 'agent' }, { psOrAs: true })(authTokenEndpoint, {
105
253
  method: 'POST',
106
254
  headers: { 'content-type': 'application/json' },
107
255
  body: JSON.stringify({
@@ -122,10 +270,38 @@ async function exchangeAtPS(signAgent, tokenEndpoint, resourceToken, cfg) {
122
270
  await cfg.onAuthToken(auth_token);
123
271
  return { kind: 'token', authToken: auth_token };
124
272
  }
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 = {}) {
273
+ // ── Authorize-first ──
274
+ /**
275
+ * POST the resource's authorization endpoint, declaring the operation, and take
276
+ * back a resource token. The request MUST present a person token via
277
+ * Signature-Key (protocol §Authorization Endpoint Request) — an agent token gets
278
+ * `requirement=person-token`.
279
+ */
280
+ async function authorizeAtResource(cfg, endpoint, personToken, vocabulary, operationId) {
281
+ const res = await signWith(cfg, { kind: 'person', jwt: personToken })(endpoint, {
282
+ method: 'POST',
283
+ headers: { 'content-type': 'application/json' },
284
+ body: JSON.stringify({
285
+ r3_operations: {
286
+ vocabulary,
287
+ // Bare identifiers, scoped to the one discovery endpoint the resource
288
+ // advertises for this vocabulary (R3 -02 §Operation Identifier Scope).
289
+ operations: [{ operationId }],
290
+ },
291
+ }),
292
+ });
293
+ if (!res.ok)
294
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
295
+ const { resource_token } = (await res.json());
296
+ if (!resource_token) {
297
+ // The resource handled authorization itself and issued no resource token.
298
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
299
+ }
300
+ return { kind: 'resourceToken', resourceToken: resource_token };
301
+ }
302
+ // ── invoke ──
303
+ const MAX_ROUNDS = 6;
304
+ export async function invokeAtResource(cfg, l1, operationId, args = {}, opts = {}) {
129
305
  const route = await routeOperation(l1, operationId, args);
130
306
  if (route.plan.kind !== 'sync.request') {
131
307
  return {
@@ -136,112 +312,200 @@ export async function invokeAtResource(cfg, l1, operationId, args = {}) {
136
312
  }
137
313
  const plan = route.plan;
138
314
  const apiUrl = `${l1.origin}${plan.path}${plan.query ? `?${plan.query}` : ''}`;
139
- const requestInit = {
315
+ // Fixed for the whole flow. A per-call retry MUST present exactly the
316
+ // parameters the proposal was approved for (R3 -02 §Per-Call Proposals step 3):
317
+ // the resource recovers the proposal by its hash and rejects any difference.
318
+ const init = {
140
319
  method: plan.method,
141
320
  ...(plan.headers ? { headers: plan.headers } : {}),
142
321
  ...(plan.body !== undefined ? { body: plan.body } : {}),
143
322
  };
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 };
323
+ const missionS256 = opts.missionS256 ?? cfg.missionS256;
324
+ // Three-way access_mode plan against the mode that applies to THIS operation:
325
+ // its own annotation when it carries one, the resource-wide access_mode
326
+ // otherwise (R3 -02 §Applying Annotations).
327
+ const accessPlan = planAccessMode(route.accessMode, {
328
+ hasPersonServer: agentTokenPs(cfg.agentToken) !== undefined,
329
+ });
330
+ if (accessPlan.kind === 'unsatisfiable') {
331
+ return {
332
+ kind: 'skipped',
333
+ resource: l1.resource,
334
+ opId: operationId,
335
+ mode: accessPlan.mode,
336
+ reason: accessPlan.reason,
337
+ };
338
+ }
339
+ let ps;
340
+ const needPS = async () => {
341
+ ps ??= await psMetadata(cfg.psUrl);
342
+ return ps;
343
+ };
344
+ const sessions = sessionTokenStore(cfg);
345
+ // ── Opening credential ──
346
+ //
347
+ // The plan only decides where to start. Everything after this point is the
348
+ // requirement loop, which is identical in every mode.
349
+ let cred = { kind: 'agent' };
350
+ if (accessPlan.kind === 'satisfiable') {
351
+ switch (accessPlan.mode) {
352
+ case 'agent-token':
353
+ break;
354
+ case 'session-token': {
355
+ // Resource-managed. Present the session token if we already hold one;
356
+ // otherwise call with the agent token and let the resource start its own
357
+ // consent flow with a 202 interaction.
358
+ const held = await sessions.get(l1.resource);
359
+ if (held)
360
+ cred = { kind: 'session', token: held };
361
+ break;
362
+ }
363
+ case 'person-token': {
364
+ const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256);
365
+ if (pt.kind !== 'token')
366
+ return pt;
367
+ cred = { kind: 'person', jwt: pt.personToken };
368
+ break;
369
+ }
370
+ case 'auth-token':
371
+ case 'per-call': {
372
+ // Authorize-first when the resource publishes an authorization_endpoint:
373
+ // declare the operation, take back a resource token, exchange it at the
374
+ // PS. Without one, the resource issues resource tokens via 401 instead
375
+ // (protocol §Resource Access and Resource Tokens) — start with the
376
+ // person token and let the requirement loop pick up the challenge.
377
+ const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256);
378
+ if (pt.kind !== 'token')
379
+ return pt;
380
+ cred = { kind: 'person', jwt: pt.personToken };
381
+ if (l1.authorization_endpoint) {
382
+ const authz = await authorizeAtResource(cfg, l1.authorization_endpoint, pt.personToken, route.adapter.vocabUri, operationId);
383
+ if (authz.kind !== 'resourceToken')
384
+ return authz;
385
+ const ex = await exchangeAtPS(cfg, (await needPS()).auth_token_endpoint, authz.resourceToken);
386
+ if (ex.kind !== 'token')
387
+ return ex;
388
+ cred = { kind: 'auth', jwt: ex.authToken };
389
+ }
390
+ break;
160
391
  }
161
392
  }
162
- return { kind: 'result', status: res.status, body: await safeBody(res) };
163
393
  }
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
- }
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);
394
+ // ── Requirement loop ──
395
+ //
396
+ // Make the request, read any AAuth-Requirement, satisfy it, retry. `satisfied`
397
+ // stops the loop from chasing the same requirement twice with the same
398
+ // credential, which is what a resource that will never be satisfiable looks
399
+ // like from here.
400
+ const satisfied = new Set();
401
+ for (let round = 0; round < MAX_ROUNDS; round++) {
402
+ const res = await signWith(cfg, cred)(apiUrl, init);
403
+ // A resource MAY replace the agent's session token on any response.
404
+ const access = res.headers.get('aauth-access');
405
+ if (access) {
406
+ await sessions.set(l1.resource, access);
407
+ if (cred.kind !== 'auth' && cred.kind !== 'person')
408
+ cred = { kind: 'session', token: access };
214
409
  }
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 };
410
+ const req = parseRequirement(res.headers.get('aauth-requirement'));
411
+ if (!req)
412
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
413
+ const marker = `${req.requirement}:${cred.kind}`;
414
+ if (satisfied.has(marker)) {
415
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
416
+ }
417
+ satisfied.add(marker);
418
+ switch (req.requirement) {
419
+ case 'agent-token': {
420
+ // The resource wants the agent's own identity token specifically.
421
+ if (cred.kind === 'agent') {
422
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
423
+ }
424
+ cred = { kind: 'agent' };
425
+ continue;
426
+ }
427
+ case 'person-token': {
428
+ const pt = await obtainPersonToken(cfg, await needPS(), l1.issuer, missionS256);
429
+ if (pt.kind !== 'token')
430
+ return pt;
431
+ cred = { kind: 'person', jwt: pt.personToken };
432
+ continue;
433
+ }
434
+ case 'auth-token': {
435
+ // Also the per-call path: for an `r3_per_call` operation the resource
436
+ // builds a proposal from this call's concrete parameters, persists it
437
+ // under its hash, and returns a resource token carrying only the
438
+ // `r3_uri`/`r3_s256` reference. The agent exchanges it and retries the
439
+ // identical call (R3 -02 §Per-Call Proposals).
440
+ if (!req.resourceToken) {
441
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
442
+ }
443
+ const ex = await exchangeAtPS(cfg, (await needPS()).auth_token_endpoint, req.resourceToken);
444
+ if (ex.kind !== 'token')
445
+ return ex;
446
+ cred = { kind: 'auth', jwt: ex.authToken };
447
+ continue;
448
+ }
449
+ case 'interaction': {
450
+ const interaction = interactionFrom(res);
451
+ if (!interaction) {
452
+ return { kind: 'result', status: res.status, body: await safeBody(res) };
453
+ }
454
+ // Try the PS's interaction endpoint first so it can use its own
455
+ // user-reach channels (live web session, mobile push). On any non-2xx —
456
+ // including the spec-pending interaction_unavailable error (AAuth#34)
457
+ // and any PS that hasn't implemented the endpoint yet — surface the
458
+ // interaction so the caller can drive it (layer 2: local OS open;
459
+ // layer 3: text + QR).
460
+ const meta = await needPS().catch(() => undefined);
461
+ const engaged = meta?.interaction_endpoint
462
+ ? await relayInteractionToPS(signWith(cfg, { kind: 'agent' }), meta.interaction_endpoint, interaction)
463
+ : false;
464
+ if (!engaged)
465
+ return { kind: 'interaction', interaction };
466
+ const completed = await pollUntilDone(makeAgentPoll(cfg), interaction.pollUrl, 180_000);
467
+ if (completed.status === 202)
468
+ return { kind: 'interaction', interaction };
469
+ const settled = completed.headers.get('aauth-access');
470
+ if (settled) {
471
+ await sessions.set(l1.resource, settled);
472
+ cred = { kind: 'session', token: settled };
473
+ }
474
+ continue;
475
+ }
476
+ default:
477
+ // A requirement value this build does not know. The agent MUST NOT
478
+ // treat the response as satisfiable; surface it verbatim.
479
+ return {
480
+ kind: 'result',
481
+ status: res.status,
482
+ body: {
483
+ error: 'unsupported_requirement',
484
+ requirement: req.requirement,
485
+ detail: await safeBody(res),
486
+ },
487
+ };
230
488
  }
231
489
  }
232
- return { kind: 'result', status: apiRes.status, body: await safeBody(apiRes) };
490
+ return {
491
+ kind: 'result',
492
+ status: 429,
493
+ body: { error: 'requirement_loop', detail: `${l1.resource} kept challenging after ${MAX_ROUNDS} rounds` },
494
+ };
233
495
  }
234
496
  // Drive invokeAtResource to completion: perform each interaction via
235
497
  // `onInteraction`, poll until it resolves, and retry. For programmatic / test
236
498
  // use; the MCP tool surface is non-blocking by design and surfaces interaction
237
499
  // 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' } });
500
+ export async function invokeAtResourceComplete(cfg, l1, operationId, onInteraction, args = {}, maxRounds = 5, pollTimeoutMs = 180_000, onPoll, opts = {}) {
501
+ const poll = makeAgentPoll(cfg);
241
502
  for (let round = 0; round < maxRounds; round++) {
242
- const result = await invokeAtResource(cfg, l1, operationId, args);
503
+ const result = await invokeAtResource(cfg, l1, operationId, args, opts);
243
504
  if (result.kind === 'result')
244
505
  return { status: result.status, body: result.body };
506
+ if (result.kind === 'skipped') {
507
+ return { status: 0, body: { error: 'access_mode_unsatisfiable', ...result } };
508
+ }
245
509
  await onInteraction(result.interaction.url, result.interaction.code);
246
510
  await pollUntilDone(poll, result.interaction.pollUrl, pollTimeoutMs, onPoll);
247
511
  }
@@ -250,6 +514,6 @@ export async function invokeAtResourceComplete(cfg, l1, operationId, onInteracti
250
514
  // Signed DELETE to an admin endpoint on the resource (e.g. /admin/tokens).
251
515
  // Uses the agent token so the resource can verify the caller owns the key.
252
516
  export async function deleteAtAdmin(cfg, l1, path) {
253
- return signWith(cfg, cfg.agentToken)(`${l1.origin}${path}`, { method: 'DELETE' });
517
+ return signWith(cfg, { kind: 'agent' })(`${l1.origin}${path}`, { method: 'DELETE' });
254
518
  }
255
519
  //# sourceMappingURL=agent.js.map