@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.
- package/dist/access-mode.d.ts +21 -0
- package/dist/access-mode.d.ts.map +1 -0
- package/dist/access-mode.js +64 -0
- package/dist/access-mode.js.map +1 -0
- package/dist/agent.d.ts +85 -6
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +408 -129
- package/dist/agent.js.map +1 -1
- package/dist/identity-local.d.ts.map +1 -1
- package/dist/identity-local.js +12 -2
- package/dist/identity-local.js.map +1 -1
- package/dist/identity.d.ts +1 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +25 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +77 -0
- package/dist/jwt.js.map +1 -0
- package/dist/resource.d.ts +8 -3
- package/dist/resource.d.ts.map +1 -1
- package/dist/resource.js +30 -10
- package/dist/resource.js.map +1 -1
- package/dist/store.d.ts +34 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -0
- package/dist/store.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +62 -16
- package/dist/tools.js.map +1 -1
- package/dist/vocab/annotations.d.ts +30 -0
- package/dist/vocab/annotations.d.ts.map +1 -0
- package/dist/vocab/annotations.js +70 -0
- package/dist/vocab/annotations.js.map +1 -0
- package/dist/vocab/index.d.ts +1 -1
- package/dist/vocab/index.d.ts.map +1 -1
- package/dist/vocab/index.js +11 -5
- package/dist/vocab/index.js.map +1 -1
- package/dist/vocab/openapi.d.ts +4 -0
- package/dist/vocab/openapi.d.ts.map +1 -1
- package/dist/vocab/openapi.js +13 -0
- package/dist/vocab/openapi.js.map +1 -1
- package/dist/vocab/types.d.ts +18 -2
- package/dist/vocab/types.d.ts.map +1 -1
- package/dist/vocab/types.js +5 -2
- package/dist/vocab/types.js.map +1 -1
- package/package.json +2 -2
- package/dist/vocab/openapi-gateway.d.ts +0 -14
- package/dist/vocab/openapi-gateway.d.ts.map +0 -1
- package/dist/vocab/openapi-gateway.js +0 -70
- 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.
|
|
2
|
-
//
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
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,
|
|
63
|
+
return (url) => signWith(cfg, { kind: 'agent' })(url, { method: 'GET', headers: { Prefer: 'wait=20' } });
|
|
20
64
|
}
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
25
|
-
|
|
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
|
|
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 =
|
|
94
|
+
const parsed = parseRequirement(res.headers.get('aauth-requirement'));
|
|
39
95
|
const pollUrl = res.headers.get('location') ?? '';
|
|
40
|
-
return parsed
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
532
|
+
return signWith(cfg, { kind: 'agent' })(`${l1.origin}${path}`, { method: 'DELETE' });
|
|
254
533
|
}
|
|
255
534
|
//# sourceMappingURL=agent.js.map
|