@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.
- 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 +393 -129
- package/dist/agent.js.map +1 -1
- package/dist/identity-local.d.ts.map +1 -1
- package/dist/identity-local.js +25 -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 +60 -14
- 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 +4 -1
- package/dist/vocab/types.js.map +1 -1
- package/package.json +3 -3
- 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,89 @@
|
|
|
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
|
+
};
|
|
36
80
|
}
|
|
37
81
|
function interactionFrom(res) {
|
|
38
|
-
const parsed =
|
|
82
|
+
const parsed = parseRequirement(res.headers.get('aauth-requirement'));
|
|
39
83
|
const pollUrl = res.headers.get('location') ?? '';
|
|
40
|
-
return parsed
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
517
|
+
return signWith(cfg, { kind: 'agent' })(`${l1.origin}${path}`, { method: 'DELETE' });
|
|
254
518
|
}
|
|
255
519
|
//# sourceMappingURL=agent.js.map
|