@poppykit/client 0.4.1 → 0.6.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/API.md +12 -3
- package/AUTH.md +97 -65
- package/CHANGELOG.md +18 -0
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +13 -10
- package/DELIVERY.md +30 -1
- package/INTEGRATIONS.md +63 -21
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +24 -14
- package/README.md +7 -13
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +24 -5
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +31 -1
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +29 -13
- package/dist/auth/client-auth.js +498 -318
- package/dist/auth/sqlite-store.js +27 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +28 -14
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -1
- package/dist/integrations.d.ts +16 -4
- package/dist/integrations.js +30 -38
- package/dist/network/discovery.d.ts +51 -22
- package/dist/network/discovery.js +159 -69
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/operations.d.ts +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +52 -2
- package/dist/protocol/operations.d.ts +51 -0
- package/dist/protocol/operations.js +98 -0
- package/dist/protocol/session.d.ts +28 -0
- package/dist/protocol/session.js +93 -0
- package/dist/protocol/sse.d.ts +1 -0
- package/dist/protocol/sse.js +1 -1
- package/dist/protocol/wire.d.ts +20 -15
- package/dist/protocol/wire.js +70 -22
- package/dist/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -10
- package/dist/provider.js +103 -32
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +2 -1
- package/package.json +5 -2
package/dist/auth/client-auth.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { supportsOperations } from '../protocol/operations.js';
|
|
2
|
+
import { parseAlternative } from '../network/tasks.js';
|
|
3
|
+
import { registeredRedirect } from '../network/redirect.js';
|
|
1
4
|
import { acquireOwner, releaseOwner, localOwnerAlive } from './ownership.js';
|
|
2
5
|
import { createHash } from 'node:crypto';
|
|
3
6
|
import { calculateJwkThumbprint } from 'jose';
|
|
4
7
|
import { parseJson, parseProtocolError, parseTokenResponse, retryAfterMilliseconds } from '../protocol/wire.js';
|
|
5
|
-
import { validateClientId } from '../network/discovery.js';
|
|
8
|
+
import { validateClientId, signInMethods, companyIdentifier, mediatedSignIn, MEDIATED_GRANT } from '../network/discovery.js';
|
|
6
9
|
import { dpopChallenge, schemeChallenge } from './challenge.js';
|
|
7
|
-
import { resolveApiTarget,
|
|
10
|
+
import { resolveApiTarget, serviceAuthorization, browserDestination, validateFeedback } from '../integrations.js';
|
|
8
11
|
export class AuthFailure extends Error {
|
|
9
12
|
code;
|
|
10
13
|
connectionId;
|
|
@@ -19,6 +22,13 @@ export class AuthFailure extends Error {
|
|
|
19
22
|
this.name = 'AuthFailure';
|
|
20
23
|
}
|
|
21
24
|
}
|
|
25
|
+
class MediatedCodeRequired extends AuthFailure {
|
|
26
|
+
challenge;
|
|
27
|
+
constructor(challenge) {
|
|
28
|
+
super('code_required');
|
|
29
|
+
this.challenge = challenge;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
22
32
|
const EC = { name: 'ECDSA', namedCurve: 'P-256' };
|
|
23
33
|
const ES256 = { name: 'ECDSA', hash: 'SHA-256' };
|
|
24
34
|
const SKEW_MS = 30_000;
|
|
@@ -33,7 +43,7 @@ function providerFailure(error, connectionId) {
|
|
|
33
43
|
: ['invalid_response', 'binding_conflict', 'expired', 'consumed', 'canceled'].includes(String(code)) ? 'provider_invalid_response' : 'provider_unavailable', connectionId);
|
|
34
44
|
}
|
|
35
45
|
// OAuth codes the token endpoint may return (spec 4.2, RFC 6749/8707); anything else is reported as token_error.
|
|
36
|
-
const SAFE_ERRORS = new Set(['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'invalid_target', '
|
|
46
|
+
const SAFE_ERRORS = new Set(['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'invalid_target', 'account_mismatch', 'invalid_dpop_proof', 'use_dpop_nonce', 'extension_required', 'rate_limited']);
|
|
37
47
|
const b64 = (data) => Buffer.from(data).toString('base64url');
|
|
38
48
|
const randomId = (prefix = '') => prefix + b64(crypto.getRandomValues(new Uint8Array(16)));
|
|
39
49
|
const own = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
|
|
@@ -84,7 +94,31 @@ function parseCallback(callbackUrl) {
|
|
|
84
94
|
return { params, base };
|
|
85
95
|
}
|
|
86
96
|
export function initialClientAuthState() {
|
|
87
|
-
return { version:
|
|
97
|
+
return { version: 3, epoch: randomId(), owner: null, identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
|
|
98
|
+
}
|
|
99
|
+
/** Upgrade a complete namespace under its writer lock; dispatched/staged work still goes through recover(). */
|
|
100
|
+
export function upgradeClientAuthState(state) {
|
|
101
|
+
if (state.version === 3)
|
|
102
|
+
return;
|
|
103
|
+
if (state.version !== 1 && state.version !== 2)
|
|
104
|
+
throw new Error('unsupported auth state version');
|
|
105
|
+
for (const connection of Object.values(state.connections)) {
|
|
106
|
+
connection.needsSessionKey = true;
|
|
107
|
+
connection.slots = {};
|
|
108
|
+
}
|
|
109
|
+
for (const operation of Object.values(state.operations))
|
|
110
|
+
if (operation.phase === 'prepared') {
|
|
111
|
+
operation.phase = 'terminal';
|
|
112
|
+
operation.status = 'cancelled';
|
|
113
|
+
const connection = own(state.connections, operation.connectionId);
|
|
114
|
+
if (connection)
|
|
115
|
+
(connection.signingCleanup ??= []).push(...(operation.direct ? [operation.direct.verifierRef] : []), ...(operation.device ? [operation.device.codeRef] : []));
|
|
116
|
+
operation.result = null;
|
|
117
|
+
const grant = operation.grantId ? own(state.grants, operation.grantId) : undefined;
|
|
118
|
+
if (grant?.operationId === operation.id)
|
|
119
|
+
grant.operationId = null;
|
|
120
|
+
}
|
|
121
|
+
state.version = 3;
|
|
88
122
|
}
|
|
89
123
|
function publicEcJwk(value, code) {
|
|
90
124
|
const jwk = (value ?? {});
|
|
@@ -125,9 +159,9 @@ export function createClientAuth(options) {
|
|
|
125
159
|
if (state.owner && (state.owner.namespace !== namespace || state.owner.clientId !== clientId || state.owner.localUserId !== localUserId))
|
|
126
160
|
throw new AuthFailure('owner_mismatch');
|
|
127
161
|
}
|
|
128
|
-
async function load(selection) { const state = await store.read(selection); checkOwner(state); return state; }
|
|
162
|
+
async function load(selection) { const state = await store.read(selection); checkOwner(state); upgradeClientAuthState(state); return state; }
|
|
129
163
|
function transaction(selection, change) {
|
|
130
|
-
return store.transaction(selection, state => { checkOwner(state); state.owner ??= owner; return change(state); });
|
|
164
|
+
return store.transaction(selection, state => { checkOwner(state); upgradeClientAuthState(state); state.owner ??= owner; return change(state); });
|
|
131
165
|
}
|
|
132
166
|
async function record(connectionId) {
|
|
133
167
|
const found = typeof connectionId === 'string' ? own((await load({ connectionId })).connections, connectionId) : undefined;
|
|
@@ -138,12 +172,12 @@ export function createClientAuth(options) {
|
|
|
138
172
|
function destinations(metadata) {
|
|
139
173
|
const auth = metadata.authorization;
|
|
140
174
|
return JSON.stringify([auth?.issuer, auth?.token_endpoint, auth?.revocation_endpoint, auth?.authorization_endpoint, auth?.device_authorization_endpoint,
|
|
141
|
-
metadata.document.
|
|
175
|
+
metadata.document.extensions?.['mediated-sign-in'], metadata.document.agent?.protocols, metadata.document.apis, metadata.document.web, metadata.document.feedback]);
|
|
142
176
|
}
|
|
143
177
|
async function verified(metadata) {
|
|
144
178
|
let fresh;
|
|
145
179
|
try {
|
|
146
|
-
fresh = await options.metadata.resolveCompany(new URL(metadata.url).
|
|
180
|
+
fresh = await options.metadata.resolveCompany(new URL(metadata.url).host);
|
|
147
181
|
}
|
|
148
182
|
catch {
|
|
149
183
|
throw new AuthFailure('discovery_failed');
|
|
@@ -211,18 +245,112 @@ export function createClientAuth(options) {
|
|
|
211
245
|
return signature;
|
|
212
246
|
});
|
|
213
247
|
}
|
|
214
|
-
async function proof(key, method, url, token) {
|
|
248
|
+
async function proof(key, method, url, token, browserCode) {
|
|
215
249
|
const htu = new URL(url);
|
|
216
250
|
htu.search = '';
|
|
217
251
|
htu.hash = '';
|
|
218
252
|
const payload = { jti: randomId(), htm: method, htu: htu.href, iat: Math.floor(now() / 1000) };
|
|
219
253
|
if (token !== undefined)
|
|
220
254
|
payload.ath = b64(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))));
|
|
221
|
-
const nonce = nonces.get(htu.origin);
|
|
255
|
+
const nonce = browserCode ?? nonces.get(htu.origin);
|
|
222
256
|
if (nonce !== undefined)
|
|
223
257
|
payload.nonce = nonce;
|
|
224
258
|
return compact({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk }, payload, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
|
|
225
259
|
}
|
|
260
|
+
const signing = new Map();
|
|
261
|
+
async function cleanupSigning(connection) {
|
|
262
|
+
for (const ref of connection.signingCleanup ?? []) {
|
|
263
|
+
try {
|
|
264
|
+
await secrets.remove(ref);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
await transaction({ connectionId: connection.id }, state => {
|
|
270
|
+
const live = own(state.connections, connection.id);
|
|
271
|
+
live.signingCleanup = (live.signingCleanup ?? []).filter(value => value !== ref);
|
|
272
|
+
if (!live.signingCleanup.length)
|
|
273
|
+
delete live.signingCleanup;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function signingConnection(connection) {
|
|
278
|
+
await cleanupSigning(connection);
|
|
279
|
+
connection = await record(connection.id);
|
|
280
|
+
if ((connection.signingCleanup?.length ?? 0) >= 128)
|
|
281
|
+
throw new AuthFailure('storage_cleanup_pending');
|
|
282
|
+
if (connection.sessionSigning && connection.sessionSigning.expiresAt > now() + SKEW_MS)
|
|
283
|
+
return connection;
|
|
284
|
+
let pending = signing.get(connection.id);
|
|
285
|
+
if (!pending) {
|
|
286
|
+
pending = (async () => {
|
|
287
|
+
const keyRef = await newSessionKey(), key = await loadDpopKey(keyRef);
|
|
288
|
+
const at = Math.floor(now() / 1800000) * 1800;
|
|
289
|
+
let expiresAt = (at + 3600) * 1000;
|
|
290
|
+
let delegation, committing = false;
|
|
291
|
+
try {
|
|
292
|
+
if (options.assertions && !connection.anonymous) {
|
|
293
|
+
if (!options.assertions.delegate)
|
|
294
|
+
throw new AuthFailure('provider_delegation_unavailable');
|
|
295
|
+
let result;
|
|
296
|
+
try {
|
|
297
|
+
result = await options.assertions.delegate({ operationId: randomId('dlg_'), jwk: key.jwk });
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
throw providerFailure(error);
|
|
301
|
+
}
|
|
302
|
+
if (!result || typeof result.delegation !== 'string' || !result.delegation || !Number.isFinite(result.expiresAt) || result.expiresAt * 1000 <= now() || result.expiresAt * 1000 > now() + 3600000 + SKEW_MS)
|
|
303
|
+
throw new AuthFailure('provider_invalid_response');
|
|
304
|
+
delegation = result.delegation;
|
|
305
|
+
expiresAt = result.expiresAt * 1000;
|
|
306
|
+
}
|
|
307
|
+
else
|
|
308
|
+
delegation = await signIdentity({ typ: 'pap-delegation+jwt' }, { iss: clientId, cnf: { jwk: key.jwk }, iat: at, exp: at + 3600, jti: randomId() }, key.thumbprint);
|
|
309
|
+
const delegationRef = await secrets.stage(delegation);
|
|
310
|
+
committing = true;
|
|
311
|
+
const current = await commit({ connectionId: connection.id }, [keyRef, delegationRef], (state, drop) => {
|
|
312
|
+
const live = own(state.connections, connection.id);
|
|
313
|
+
if (live.generation !== connection.generation || live.userId !== connection.userId || live.sessionId !== connection.sessionId)
|
|
314
|
+
throw new AuthFailure('state_changed');
|
|
315
|
+
if (live.sessionSigning && live.sessionSigning.expiresAt > now() + SKEW_MS)
|
|
316
|
+
drop.push(keyRef, delegationRef);
|
|
317
|
+
else {
|
|
318
|
+
if (live.sessionSigning)
|
|
319
|
+
(live.signingCleanup ??= []).push(live.sessionSigning.keyRef, live.sessionSigning.delegationRef);
|
|
320
|
+
live.sessionSigning = { keyRef, delegationRef, expiresAt };
|
|
321
|
+
}
|
|
322
|
+
return live;
|
|
323
|
+
});
|
|
324
|
+
await cleanupSigning(current);
|
|
325
|
+
return current;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (!committing)
|
|
329
|
+
await secrets.remove(keyRef);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
})().finally(() => signing.delete(connection.id));
|
|
333
|
+
signing.set(connection.id, pending);
|
|
334
|
+
}
|
|
335
|
+
return pending;
|
|
336
|
+
}
|
|
337
|
+
async function sessionHeader(connection, request, token) {
|
|
338
|
+
if (connection.anonymous && token !== undefined)
|
|
339
|
+
throw new AuthFailure('anonymous_account_access');
|
|
340
|
+
connection = await signingConnection(connection);
|
|
341
|
+
const material = connection.sessionSigning;
|
|
342
|
+
if (!connection.sessionId || !connection.userId || material.expiresAt <= now())
|
|
343
|
+
throw new AuthFailure('invalid_session');
|
|
344
|
+
const delegation = await secrets.read(material.delegationRef), key = await loadDpopKey(material.keyRef);
|
|
345
|
+
if (!delegation)
|
|
346
|
+
throw new AuthFailure('missing_key');
|
|
347
|
+
const url = new URL(request.url);
|
|
348
|
+
url.search = '';
|
|
349
|
+
url.hash = '';
|
|
350
|
+
const claims = { iss: clientId, aud: connection.issuer, sid: connection.sessionId, sub: connection.userId,
|
|
351
|
+
htm: request.method, htu: url.href, iat: Math.floor(now() / 1000), jti: randomId(), ...(token === undefined ? {} : { ath: sha256(token) }) };
|
|
352
|
+
return compact({ typ: 'poppy-session+jwt', alg: 'ES256', kid: key.thumbprint, pap_dlg: delegation }, claims, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
|
|
353
|
+
}
|
|
226
354
|
function rememberNonce(url, response) {
|
|
227
355
|
const nonce = response.headers.get('dpop-nonce');
|
|
228
356
|
if (nonce)
|
|
@@ -234,7 +362,7 @@ export function createClientAuth(options) {
|
|
|
234
362
|
}
|
|
235
363
|
async function assertion(purpose, issuer, key, sub = clientId, operationId = randomId('op_')) {
|
|
236
364
|
if (!options.assertions)
|
|
237
|
-
return signIdentity({ typ:
|
|
365
|
+
return signIdentity({ typ: 'client-authentication+jwt' }, assertionClaims(sub, issuer), key.thumbprint);
|
|
238
366
|
let result;
|
|
239
367
|
try {
|
|
240
368
|
result = await options.assertions.assert({ operationId, issuer, purpose });
|
|
@@ -262,21 +390,25 @@ export function createClientAuth(options) {
|
|
|
262
390
|
: delay === undefined ? undefined : String(Math.floor(now() + delay));
|
|
263
391
|
return new AuthFailure(code, undefined, delay, deadline);
|
|
264
392
|
}
|
|
265
|
-
//
|
|
393
|
+
// OAuth requests use client authentication and optional DPoP, independently of Sessions.
|
|
266
394
|
// Throws transport_error only when the request may have reached the issuer without a readable answer.
|
|
267
|
-
async function tokenRequest(metadata, dpopKeyRef, fields,
|
|
395
|
+
async function tokenRequest(metadata, dpopKeyRef, fields, operationId) {
|
|
268
396
|
const authorization = (await verified(metadata)).authorization;
|
|
269
397
|
const endpoint = authorization.token_endpoint;
|
|
270
398
|
const key = await loadDpopKey(dpopKeyRef);
|
|
271
399
|
const scheme = await resourceScheme(metadata, fields.resource ?? null);
|
|
272
400
|
for (let attempt = 0;; attempt++) {
|
|
401
|
+
if (attempt > 0 && operationId && own((await load({ operationId })).operations, operationId)?.cancelRequested)
|
|
402
|
+
throw new AuthFailure('cancelled');
|
|
403
|
+
const issuedAt = now();
|
|
273
404
|
const body = new URLSearchParams(fields);
|
|
274
|
-
if (sessionSub !== null)
|
|
275
|
-
body.set('assertion', localIdentity ? await signIdentity({ typ: 'JWT' }, assertionClaims(sessionSub, authorization.issuer), key.thumbprint) : await assertion('session', authorization.issuer, key, sessionSub));
|
|
276
405
|
body.set('client_id', clientId);
|
|
277
406
|
body.set('client_assertion_type', CLIENT_ASSERTION);
|
|
278
|
-
body.set('client_assertion',
|
|
279
|
-
const
|
|
407
|
+
body.set('client_assertion', await assertion('client-authentication', authorization.issuer, key));
|
|
408
|
+
const headers = new Headers({ accept: 'application/json' });
|
|
409
|
+
if (scheme === 'DPoP')
|
|
410
|
+
headers.set('dpop', await proof(key, 'POST', endpoint));
|
|
411
|
+
const request = new Request(endpoint, { method: 'POST', body, headers });
|
|
280
412
|
let response, text;
|
|
281
413
|
try {
|
|
282
414
|
response = await transport.fetch(request);
|
|
@@ -298,12 +430,21 @@ export function createClientAuth(options) {
|
|
|
298
430
|
catch { /* reported below */ }
|
|
299
431
|
if (response.status === 200) {
|
|
300
432
|
try {
|
|
301
|
-
|
|
433
|
+
const token = parseTokenResponse(json);
|
|
434
|
+
if (token.token_type === 'DPoP' && !headers.has('dpop'))
|
|
435
|
+
throw 0;
|
|
436
|
+
return { token, scheme: token.token_type };
|
|
302
437
|
}
|
|
303
438
|
catch {
|
|
304
439
|
throw new AuthFailure('invalid_response');
|
|
305
440
|
}
|
|
306
441
|
}
|
|
442
|
+
if (response.status === 400 && json && typeof json === 'object' && 'error' in json && json.error === 'code_required') {
|
|
443
|
+
const value = json;
|
|
444
|
+
if (typeof value.sign_in_id !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(value.sign_in_id) || typeof value.sent_to !== 'string' || value.sent_to.length > 512 || !Number.isSafeInteger(value.expires_in) || value.expires_in <= 0 || !Number.isSafeInteger(issuedAt + value.expires_in * 1000))
|
|
445
|
+
throw new AuthFailure('invalid_response');
|
|
446
|
+
throw new MediatedCodeRequired({ id: value.sign_in_id, sentTo: value.sent_to, expiresAt: issuedAt + value.expires_in * 1000 });
|
|
447
|
+
}
|
|
307
448
|
let error = response.status === 429 ? 'rate_limited' : 'token_error';
|
|
308
449
|
try {
|
|
309
450
|
if (response.status !== 429)
|
|
@@ -319,33 +460,19 @@ export function createClientAuth(options) {
|
|
|
319
460
|
async function resourceScheme(metadata, resource) {
|
|
320
461
|
const mcp = metadata.document.apis?.find(api => api.type === 'mcp' && api.url === resource);
|
|
321
462
|
if (!mcp)
|
|
322
|
-
return 'DPoP';
|
|
323
|
-
if (mcp.resource !== undefined && mcp.resource !== mcp.url)
|
|
324
|
-
throw new AuthFailure('invalid_target');
|
|
463
|
+
return metadata.authorization?.dpop_signing_alg_values_supported?.includes('ES256') ? 'DPoP' : 'Bearer';
|
|
325
464
|
try {
|
|
326
|
-
|
|
327
|
-
// Bearer MCP tokens cannot authorize a conversation, even when discovery reuses their resource identifier.
|
|
328
|
-
if (scheme === 'Bearer' && metadata.document.agent?.protocols.some(protocol => protocol.resource === resource))
|
|
329
|
-
throw 0;
|
|
330
|
-
return scheme;
|
|
465
|
+
return (await serviceAuthorization(metadata, options.metadata, transport, mcp.url, { mcp: true })).scheme;
|
|
331
466
|
}
|
|
332
467
|
catch {
|
|
333
468
|
throw new AuthFailure('invalid_target');
|
|
334
469
|
}
|
|
335
470
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (resource !== null)
|
|
342
|
-
fields.resource = resource;
|
|
343
|
-
const { token, scheme } = await tokenRequest(metadata, dpopKeyRef, fields, userId, anonymous);
|
|
344
|
-
if (token.token_type !== scheme)
|
|
345
|
-
throw new AuthFailure('unsupported_token_type');
|
|
346
|
-
if (token.signed_in !== false || token.scope !== '' || (sessionId !== null && token.session_id !== sessionId))
|
|
347
|
-
throw new AuthFailure('invalid_response');
|
|
348
|
-
return token;
|
|
471
|
+
async function newSessionKey() {
|
|
472
|
+
// shortcut: SecretStore stores a private JWK; use a host signer when non-extractable key persistence is available.
|
|
473
|
+
const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']);
|
|
474
|
+
const { kty, crv, x, y, d } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
475
|
+
return secrets.stage(JSON.stringify({ kty, crv, x, y, d }));
|
|
349
476
|
}
|
|
350
477
|
async function identityFor(issuer, anonymous = false) {
|
|
351
478
|
if (anonymous && !identity)
|
|
@@ -353,10 +480,6 @@ export function createClientAuth(options) {
|
|
|
353
480
|
const existing = anonymous ? undefined : own((await load({ issuer })).identities, issuer);
|
|
354
481
|
if (existing)
|
|
355
482
|
return existing;
|
|
356
|
-
// shortcut: extractable key, because the SecretStore holds the private JWK as a string and Node cannot persist a non-extractable CryptoKey; move to a KMS/OS-keystore signer when the host platform offers one.
|
|
357
|
-
const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']);
|
|
358
|
-
const { kty, crv, x, y, d } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
359
|
-
const dpopKeyRef = await secrets.stage(JSON.stringify({ kty, crv, x, y, d }));
|
|
360
483
|
let userId = randomId('usr_');
|
|
361
484
|
if (options.providerIdentity && !anonymous) {
|
|
362
485
|
let bound;
|
|
@@ -364,22 +487,35 @@ export function createClientAuth(options) {
|
|
|
364
487
|
bound = await options.providerIdentity.identity(issuer);
|
|
365
488
|
}
|
|
366
489
|
catch (error) {
|
|
367
|
-
await secrets.remove(dpopKeyRef);
|
|
368
490
|
throw providerFailure(error);
|
|
369
491
|
}
|
|
370
|
-
if (!bound || bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId)
|
|
371
|
-
await secrets.remove(dpopKeyRef);
|
|
492
|
+
if (!bound || bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId)
|
|
372
493
|
throw new AuthFailure('provider_invalid_response');
|
|
373
|
-
}
|
|
374
494
|
userId = bound.userId;
|
|
375
495
|
}
|
|
376
496
|
if (anonymous)
|
|
377
|
-
return { userId
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
497
|
+
return { userId };
|
|
498
|
+
return transaction({ issuer }, state => own(state.identities, issuer) ?? (state.identities[issuer] = { userId }));
|
|
499
|
+
}
|
|
500
|
+
async function freshSessionKey(connection, operationId) {
|
|
501
|
+
const ref = await newSessionKey();
|
|
502
|
+
const current = await commit({ connectionId: connection.id }, [ref], (state, drop) => {
|
|
503
|
+
const live = own(state.connections, connection.id);
|
|
504
|
+
if (live.generation !== connection.generation || live.dpopKeyRef !== connection.dpopKeyRef) {
|
|
505
|
+
drop.push(ref);
|
|
506
|
+
return live;
|
|
507
|
+
}
|
|
508
|
+
// Legacy keys may still be referenced by another old connection, so never delete them here.
|
|
509
|
+
if (!live.needsSessionKey)
|
|
510
|
+
drop.push(live.dpopKeyRef);
|
|
511
|
+
live.dpopKeyRef = ref;
|
|
512
|
+
delete live.needsSessionKey;
|
|
513
|
+
live.generation++;
|
|
514
|
+
if (operationId)
|
|
515
|
+
own(state.operations, operationId).connectionGeneration = live.generation;
|
|
516
|
+
return live;
|
|
517
|
+
});
|
|
518
|
+
return current;
|
|
383
519
|
}
|
|
384
520
|
// Commits a change that may stage or release secrets. When our own fence check rejects it, nothing references the
|
|
385
521
|
// staged secrets, so they are removed; references the committed change pushed to `drop` are removed after commit.
|
|
@@ -436,26 +572,9 @@ export function createClientAuth(options) {
|
|
|
436
572
|
};
|
|
437
573
|
}
|
|
438
574
|
async function signedOutRenewal(connection, resource) {
|
|
439
|
-
|
|
440
|
-
const issuedAt = now(), operationId = randomId('op_');
|
|
441
|
-
let token;
|
|
442
|
-
try {
|
|
443
|
-
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, connection.sessionId, resource, connection.anonymous);
|
|
444
|
-
}
|
|
445
|
-
catch (error) {
|
|
446
|
-
// The Session ended (spec 4.2): start a new signed-out one. Signed-out renewal never touches an account.
|
|
447
|
-
if (!(error instanceof AuthFailure && error.code === 'invalid_session'))
|
|
448
|
-
throw error;
|
|
449
|
-
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, null, resource, connection.anonymous);
|
|
450
|
-
}
|
|
451
|
-
const secretRef = await stageSessionToken(token.access_token);
|
|
452
|
-
return commit({ connectionId: connection.id }, [secretRef], (state, drop) => {
|
|
575
|
+
return transaction({ connectionId: connection.id }, state => {
|
|
453
576
|
const current = own(state.connections, connection.id);
|
|
454
|
-
|
|
455
|
-
throw new AuthFailure('state_changed', connection.id);
|
|
456
|
-
switchSession(current, token.session_id, drop);
|
|
457
|
-
replaceSlot(current, resource, { secretRef, tokenType: token.token_type, dpopKeyRef: current.dpopKeyRef, scopes: [], expiresAt: issuedAt + token.expires_in * 1000, generation: current.generation }, drop);
|
|
458
|
-
return finish(state, renewOperation(state, operationId, current, null, issuedAt, resource, []), 'completed');
|
|
577
|
+
return finish(state, renewOperation(state, randomId('op_'), current, null, now(), resource, []), 'completed');
|
|
459
578
|
});
|
|
460
579
|
}
|
|
461
580
|
// Account Token refresh (spec 4.8). The grant claim allows one rotation in flight per grant across processes,
|
|
@@ -492,8 +611,9 @@ export function createClientAuth(options) {
|
|
|
492
611
|
}
|
|
493
612
|
if (refreshToken === null)
|
|
494
613
|
return await endRefresh(operationId, 'failed', 'missing_credential');
|
|
495
|
-
|
|
496
|
-
|
|
614
|
+
if (claim.connection.needsSessionKey)
|
|
615
|
+
claim.connection = await freshSessionKey(claim.connection, operationId);
|
|
616
|
+
const fields = { grant_type: 'refresh_token', refresh_token: refreshToken };
|
|
497
617
|
if (resource !== null)
|
|
498
618
|
fields.resource = resource;
|
|
499
619
|
if (scopes)
|
|
@@ -510,21 +630,10 @@ export function createClientAuth(options) {
|
|
|
510
630
|
if (!dispatched)
|
|
511
631
|
return await endRefresh(operationId, 'failed', 'state_changed');
|
|
512
632
|
const issuedAt = now();
|
|
513
|
-
const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields
|
|
514
|
-
let acquired
|
|
633
|
+
const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields);
|
|
634
|
+
let acquired;
|
|
515
635
|
try {
|
|
516
|
-
|
|
517
|
-
acquired = await send();
|
|
518
|
-
}
|
|
519
|
-
catch (error) {
|
|
520
|
-
// A definite invalid_session (a 4xx OAuth code) left the Account Token unused, so this is a new request, not a resend:
|
|
521
|
-
// without session_id the Company starts a new Session signed in to the same account (spec 4.8).
|
|
522
|
-
if (!(error instanceof AuthFailure && error.code === 'invalid_session'))
|
|
523
|
-
throw error;
|
|
524
|
-
delete fields.session_id;
|
|
525
|
-
newSession = true;
|
|
526
|
-
acquired = await send();
|
|
527
|
-
}
|
|
636
|
+
acquired = await send();
|
|
528
637
|
}
|
|
529
638
|
catch (error) {
|
|
530
639
|
if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
|
|
@@ -536,11 +645,11 @@ export function createClientAuth(options) {
|
|
|
536
645
|
return await endRefresh(operationId, 'failed', code);
|
|
537
646
|
}
|
|
538
647
|
const { token, scheme } = acquired;
|
|
539
|
-
const granted = token.scope.split(' ').filter(Boolean);
|
|
648
|
+
const granted = (token.scope ?? claim.requested.join(' ')).split(' ').filter(Boolean);
|
|
540
649
|
let problem = null;
|
|
541
650
|
if (token.token_type !== scheme)
|
|
542
651
|
problem = 'unsupported_token_type';
|
|
543
|
-
else if (
|
|
652
|
+
else if (granted.some(scope => !claim.requested.includes(scope)))
|
|
544
653
|
problem = 'invalid_response';
|
|
545
654
|
let refreshRef = null, accessRef = null;
|
|
546
655
|
try {
|
|
@@ -560,7 +669,7 @@ export function createClientAuth(options) {
|
|
|
560
669
|
if (problem || accessRef === null)
|
|
561
670
|
return await endRefresh(operationId, 'failed', problem ?? 'storage_error', refreshRef);
|
|
562
671
|
const staged = {
|
|
563
|
-
accessRef, refreshRef, tokenType: token.token_type, sessionId:
|
|
672
|
+
accessRef, refreshRef, tokenType: token.token_type, sessionId: claim.connection.sessionId, scopes: granted, signedIn: true, expiresAt: issuedAt + (token.expires_in ?? 60) * 1000,
|
|
564
673
|
refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000,
|
|
565
674
|
};
|
|
566
675
|
// Its own commit, so a crash before activation leaves the new Account Token referenced for recover (interface 4).
|
|
@@ -603,19 +712,28 @@ export function createClientAuth(options) {
|
|
|
603
712
|
const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
|
|
604
713
|
if (grant?.operationId === operation.id) {
|
|
605
714
|
grant.operationId = null;
|
|
606
|
-
if (
|
|
715
|
+
if (Object.values(state.connections).some(peer => peer.grantId === grant.id)) {
|
|
607
716
|
if (error === 'invalid_grant') {
|
|
608
717
|
// Expired or revoked: the Session continues signed out and the User signs in again only on request (spec 4.8, 4.9).
|
|
609
|
-
drop.push(grant.secretRef
|
|
718
|
+
drop.push(grant.secretRef);
|
|
610
719
|
delete state.grants[grant.id];
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
720
|
+
for (const peer of Object.values(state.connections))
|
|
721
|
+
if (peer.grantId === grant.id) {
|
|
722
|
+
drop.push(...Object.values(peer.slots).map(slot => slot.secretRef));
|
|
723
|
+
peer.grantId = null;
|
|
724
|
+
peer.status = 'signed_out';
|
|
725
|
+
peer.slots = {};
|
|
726
|
+
peer.generation++;
|
|
727
|
+
}
|
|
614
728
|
}
|
|
615
729
|
else if (status === 'uncertain' || rotated) {
|
|
616
730
|
// The Account Token we hold may already be replaced; only a fresh sign-in is safe.
|
|
617
731
|
grant.status = 'uncertain';
|
|
618
|
-
|
|
732
|
+
for (const peer of Object.values(state.connections))
|
|
733
|
+
if (peer.grantId === grant.id) {
|
|
734
|
+
peer.status = 'uncertain';
|
|
735
|
+
peer.generation++;
|
|
736
|
+
}
|
|
619
737
|
}
|
|
620
738
|
}
|
|
621
739
|
}
|
|
@@ -625,24 +743,25 @@ export function createClientAuth(options) {
|
|
|
625
743
|
const staged = operation.staged;
|
|
626
744
|
const connection = own(state.connections, operation.connectionId);
|
|
627
745
|
const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
|
|
628
|
-
if (!grant || grant.operationId !== operation.id || grant.generation !== operation.grantGeneration
|
|
629
|
-
// A sign-out or replacement won the race; the late Account Token cannot activate and awaits revocation.
|
|
746
|
+
if (!grant || grant.operationId !== operation.id || grant.generation !== operation.grantGeneration) {
|
|
630
747
|
if (staged.refreshRef !== null)
|
|
631
748
|
retire(state, `cln_${operation.id}`, connection, staged.refreshRef);
|
|
632
749
|
drop.push(staged.accessRef);
|
|
633
|
-
if (grant?.operationId === operation.id)
|
|
634
|
-
grant.operationId = null;
|
|
635
750
|
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
636
751
|
}
|
|
637
752
|
grant.operationId = null;
|
|
638
753
|
grant.generation++;
|
|
639
754
|
if (staged.refreshRef !== null) {
|
|
640
|
-
// The Company rotated the Account Token; the old one is dead and is never sent again (spec 4.8).
|
|
641
755
|
drop.push(grant.secretRef);
|
|
642
756
|
grant.secretRef = staged.refreshRef;
|
|
643
757
|
if (staged.refreshExpiresAt !== null)
|
|
644
758
|
grant.expiresAt = staged.refreshExpiresAt;
|
|
645
759
|
}
|
|
760
|
+
// Rotation belongs to the grant even if its initiating Session was replaced meanwhile.
|
|
761
|
+
if (connection.grantId !== grant.id || connection.generation !== operation.connectionGeneration) {
|
|
762
|
+
drop.push(staged.accessRef);
|
|
763
|
+
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
764
|
+
}
|
|
646
765
|
switchSession(connection, staged.sessionId, drop);
|
|
647
766
|
// Only this resource's credential changes; other resources' credentials survive (IMPLEMENTATION 3).
|
|
648
767
|
replaceSlot(connection, operation.resource, { secretRef: staged.accessRef, tokenType: staged.tokenType ?? 'DPoP', dpopKeyRef: connection.dpopKeyRef, scopes: staged.scopes, expiresAt: staged.expiresAt, generation: connection.generation }, drop);
|
|
@@ -707,6 +826,8 @@ export function createClientAuth(options) {
|
|
|
707
826
|
const connection = await record(connectionId);
|
|
708
827
|
if (connection.status === 'disconnected')
|
|
709
828
|
throw new AuthFailure('disconnected', connectionId);
|
|
829
|
+
if (connection.status === 'uncertain')
|
|
830
|
+
throw new AuthFailure('grant_uncertain', connectionId);
|
|
710
831
|
const slot = own(connection.slots, slotKey(connection.sessionId, resource));
|
|
711
832
|
return slot && slot.secretRef !== stale && slot.expiresAt - SKEW_MS > now() ? slot : renewShared(connectionId, resource);
|
|
712
833
|
}
|
|
@@ -716,7 +837,7 @@ export function createClientAuth(options) {
|
|
|
716
837
|
// A lost exchange or rotation may have issued an Account Token we never saw, so it can never be claimed revoked.
|
|
717
838
|
if (operations.some(op => op.status === 'uncertain') || items.some(item => item.status === 'uncertain'))
|
|
718
839
|
return 'uncertain';
|
|
719
|
-
return items.length || operations.some(op => op.phase
|
|
840
|
+
return items.length || operations.some(op => op.phase !== 'terminal' && op.phase !== 'prepared') || (own(state.connections, connectionId)?.signingCleanup?.length ?? 0) ? 'pending' : 'complete';
|
|
720
841
|
}
|
|
721
842
|
// RFC 7009 revocation with a fresh client assertion (spec 4.9). Replay is safe, so anything but 200 stays pending.
|
|
722
843
|
async function revoke(connection, item, token) {
|
|
@@ -744,14 +865,17 @@ export function createClientAuth(options) {
|
|
|
744
865
|
}
|
|
745
866
|
}
|
|
746
867
|
async function cleanupNow(connectionId) {
|
|
747
|
-
const connection = await record(connectionId);
|
|
748
|
-
const
|
|
868
|
+
const connection = await record(connectionId), selection = connection.anonymous ? { connectionId } : { account: { issuer: connection.issuer, userId: connection.userId } };
|
|
869
|
+
const snapshot = await load(selection);
|
|
870
|
+
for (const peer of Object.values(snapshot.connections))
|
|
871
|
+
await cleanupSigning(peer);
|
|
872
|
+
const items = Object.values(snapshot.cleanup).filter(item => item.status === 'pending');
|
|
749
873
|
for (const item of items) {
|
|
750
874
|
const token = await secrets.read(item.secretRef);
|
|
751
|
-
const revoked = token !== null && await revoke(
|
|
875
|
+
const revoked = token !== null && await revoke(own(snapshot.connections, item.connectionId), item, token);
|
|
752
876
|
if (token !== null && !revoked)
|
|
753
877
|
continue;
|
|
754
|
-
await commit({ connectionId }, [], (state, drop) => {
|
|
878
|
+
await commit({ connectionId: item.connectionId }, [], (state, drop) => {
|
|
755
879
|
const current = own(state.cleanup, item.id);
|
|
756
880
|
if (current?.secretRef !== item.secretRef)
|
|
757
881
|
return;
|
|
@@ -763,17 +887,17 @@ export function createClientAuth(options) {
|
|
|
763
887
|
current.status = 'uncertain';
|
|
764
888
|
});
|
|
765
889
|
}
|
|
766
|
-
const state = await load(
|
|
767
|
-
return { connection: publicConnection(state, own(state.connections, connectionId)), cleanup:
|
|
890
|
+
const state = await load(selection), statuses = Object.keys(state.connections).map(id => cleanupStatus(state, id));
|
|
891
|
+
return { connection: publicConnection(state, own(state.connections, connectionId)), cleanup: statuses.includes('uncertain') ? 'uncertain' : statuses.includes('pending') ? 'pending' : 'complete' };
|
|
768
892
|
}
|
|
769
893
|
function targetOf(connection) {
|
|
770
894
|
const entry = connection.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy');
|
|
771
895
|
if (!entry)
|
|
772
896
|
throw new AuthFailure('no_conversation', connection.id);
|
|
773
|
-
return { issuer: connection.issuer, endpoint: entry.endpoint
|
|
897
|
+
return { issuer: connection.issuer, endpoint: entry.endpoint };
|
|
774
898
|
}
|
|
775
899
|
function outcomeOf(state, operation) {
|
|
776
|
-
return operation.result ?? { operationId: operation.id, status:
|
|
900
|
+
return operation.result ?? { operationId: operation.id, status: operation.status, connection: publicConnection(state, own(state.connections, operation.connectionId)), ...(operation.phase === 'terminal' ? {} : { nextAction: 'wait' }) };
|
|
777
901
|
}
|
|
778
902
|
// The terminal result is recorded once and returned verbatim to duplicate callbacks.
|
|
779
903
|
function finish(state, operation, status, extra = {}) {
|
|
@@ -826,24 +950,25 @@ export function createClientAuth(options) {
|
|
|
826
950
|
drop.push(staged.accessRef);
|
|
827
951
|
return finish(state, operation, problem === 'cancelled' ? 'cancelled' : 'failed', { error: problem, grantedScopes: [...granted], missingScopes });
|
|
828
952
|
}
|
|
829
|
-
if (previous) {
|
|
953
|
+
if (previous && !Object.values(state.connections).some(row => row.id !== target.id && row.grantId === previous.id)) {
|
|
830
954
|
retire(state, `cln_${previous.id}`, target, previous.secretRef);
|
|
831
955
|
delete state.grants[previous.id];
|
|
832
956
|
}
|
|
833
957
|
const grantId = randomId('grt_');
|
|
834
958
|
state.grants[grantId] = { id: grantId, issuer: target.issuer, userId: target.userId, secretRef: staged.refreshRef, scopes: granted,
|
|
835
959
|
expiresAt: staged.refreshExpiresAt, generation: 1, status: 'active', operationId: null };
|
|
960
|
+
switchSession(target, staged.sessionId, drop);
|
|
836
961
|
target.grantId = grantId;
|
|
837
962
|
target.status = 'signed_in';
|
|
838
963
|
target.generation++;
|
|
839
964
|
drop.push(...Object.values(target.slots).map(slot => slot.secretRef));
|
|
840
|
-
target.slots = { [slotKey(staged.sessionId, operation.resource)]: { secretRef: staged.accessRef, tokenType: 'DPoP', dpopKeyRef: target.dpopKeyRef,
|
|
965
|
+
target.slots = { [slotKey(staged.sessionId, operation.resource)]: { secretRef: staged.accessRef, tokenType: staged.tokenType ?? 'DPoP', dpopKeyRef: target.dpopKeyRef,
|
|
841
966
|
scopes: granted, expiresAt: staged.expiresAt, generation: target.generation } };
|
|
842
967
|
return finish(state, operation, 'completed', { grantedScopes: [...granted], missingScopes });
|
|
843
968
|
}
|
|
844
969
|
async function acceptSignIn(operationId, token, issuedAt) {
|
|
845
970
|
const snapshot = await load({ operationId }), operation = own(snapshot.operations, operationId);
|
|
846
|
-
const granted = token.scope.split(' ').filter(Boolean);
|
|
971
|
+
const granted = (token.scope ?? operation.scopes.join(' ')).split(' ').filter(Boolean);
|
|
847
972
|
let refreshRef = null;
|
|
848
973
|
try {
|
|
849
974
|
if (token.refresh_token !== undefined)
|
|
@@ -852,19 +977,19 @@ export function createClientAuth(options) {
|
|
|
852
977
|
catch {
|
|
853
978
|
return await terminate(operationId, 'uncertain', 'storage_error');
|
|
854
979
|
}
|
|
855
|
-
if (
|
|
980
|
+
if (!refreshRef ||
|
|
856
981
|
!granted.length || new Set(granted).size !== granted.length || granted.some(scope => !operation.scopes.includes(scope))) {
|
|
857
982
|
return commit({ operationId }, [], (state, drop) => {
|
|
858
983
|
const current = own(state.operations, operationId);
|
|
859
984
|
if (refreshRef)
|
|
860
985
|
retire(state, `cln_${operationId}`, own(state.connections, current.connectionId), refreshRef);
|
|
861
986
|
drop.push(...privateRefs(current));
|
|
862
|
-
return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error:
|
|
987
|
+
return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error: 'invalid_response' });
|
|
863
988
|
});
|
|
864
989
|
}
|
|
865
990
|
const accessRef = await stageSessionToken(token.access_token);
|
|
866
|
-
const staged = { accessRef, refreshRef,
|
|
867
|
-
expiresAt: issuedAt + token.expires_in * 1000, refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000 };
|
|
991
|
+
const staged = { accessRef, refreshRef, tokenType: token.token_type, sessionId: randomId('ses_'), scopes: granted, signedIn: true,
|
|
992
|
+
expiresAt: issuedAt + (token.expires_in ?? 60) * 1000, refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000 };
|
|
868
993
|
// Stage before activation: recovery can finish locally without exchanging a consumed credential again.
|
|
869
994
|
const recorded = await transaction({ operationId }, state => {
|
|
870
995
|
const current = own(state.operations, operationId);
|
|
@@ -887,6 +1012,8 @@ export function createClientAuth(options) {
|
|
|
887
1012
|
}
|
|
888
1013
|
async function claimSignIn(operationId, executionOwner, preSend = false) {
|
|
889
1014
|
const snapshot = await load({ operationId }), operation = own(snapshot.operations, operationId);
|
|
1015
|
+
if (operation.phase === 'prepared' && !operation.signInSessionKey)
|
|
1016
|
+
return terminate(operationId, 'failed', 'restart_required');
|
|
890
1017
|
await currentConnection(operation.connectionId);
|
|
891
1018
|
return await transaction({ operationId }, state => {
|
|
892
1019
|
const current = own(state.operations, operationId);
|
|
@@ -943,7 +1070,7 @@ export function createClientAuth(options) {
|
|
|
943
1070
|
throw new AuthFailure('invalid_response');
|
|
944
1071
|
}
|
|
945
1072
|
const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval = 5 } = value ?? {};
|
|
946
|
-
const hosts = [new URL(connection.issuer).hostname, ...metadata.authorization.
|
|
1073
|
+
const hosts = [new URL(connection.issuer).hostname, ...metadata.authorization.protected_resources.map(resource => new URL(resource).hostname)];
|
|
947
1074
|
if (typeof device_code !== 'string' || !device_code || typeof user_code !== 'string' || !user_code || typeof verification_uri !== 'string' || !allowedHost(verification_uri, hosts) ||
|
|
948
1075
|
(verification_uri_complete !== undefined && (typeof verification_uri_complete !== 'string' || !allowedHost(verification_uri_complete, hosts))) ||
|
|
949
1076
|
typeof expires_in !== 'number' || !Number.isSafeInteger(expires_in) || expires_in <= 0 || expires_in > 86400 || typeof interval !== 'number' || !Number.isSafeInteger(interval) || interval < 1 || interval > 86400)
|
|
@@ -994,12 +1121,12 @@ export function createClientAuth(options) {
|
|
|
994
1121
|
const { operation, connection } = claim, code = await secrets.read(operation.device.codeRef);
|
|
995
1122
|
if (!code)
|
|
996
1123
|
return await terminate(operationId, 'failed', 'missing_credential');
|
|
997
|
-
const issuedAt = now(), fields = { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code
|
|
1124
|
+
const issuedAt = now(), fields = { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code };
|
|
998
1125
|
if (operation.resource)
|
|
999
1126
|
fields.resource = operation.resource;
|
|
1000
1127
|
let token;
|
|
1001
1128
|
try {
|
|
1002
|
-
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields
|
|
1129
|
+
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields)).token;
|
|
1003
1130
|
}
|
|
1004
1131
|
catch (error) {
|
|
1005
1132
|
if (error instanceof AuthFailure && ['authorization_pending', 'slow_down', 'rate_limited'].includes(error.code)) {
|
|
@@ -1035,41 +1162,26 @@ export function createClientAuth(options) {
|
|
|
1035
1162
|
const claim = await claimSignIn(operationId, executionOwner, true);
|
|
1036
1163
|
if (!('operation' in claim))
|
|
1037
1164
|
return claim;
|
|
1038
|
-
const { operation, connection } = claim, mediated = connection.metadata.document
|
|
1039
|
-
if (!options.trustedInput)
|
|
1040
|
-
return
|
|
1165
|
+
const { operation, connection } = claim, mediated = mediatedSignIn(connection.metadata.document);
|
|
1166
|
+
if (!mediated || !options.trustedInput)
|
|
1167
|
+
return terminate(operationId, 'failed', 'trusted_input_required');
|
|
1041
1168
|
const fields = operation.mediated ? [{ name: 'code', label: 'One-time code', secret: true }] : mediated.fields;
|
|
1042
|
-
let values;
|
|
1043
1169
|
try {
|
|
1044
|
-
|
|
1045
|
-
...(operation.mediated ? { challenge: { id: operation.mediated.challengeId, sentTo: operation.mediated.sentTo, expiresAt: operation.expiresAt } } : {}) });
|
|
1170
|
+
sensitive = await options.trustedInput.collect({ operationId, issuer: connection.issuer, organization: { ...connection.metadata.document.organization }, fields: structuredClone(fields), ...(operation.mediated ? { challenge: { id: operation.mediated.challengeId, sentTo: operation.mediated.sentTo, expiresAt: operation.expiresAt } } : {}) });
|
|
1046
1171
|
}
|
|
1047
1172
|
catch {
|
|
1048
|
-
return
|
|
1173
|
+
return terminate(operationId, 'failed', 'input_unavailable');
|
|
1049
1174
|
}
|
|
1050
|
-
|
|
1175
|
+
const values = sensitive;
|
|
1051
1176
|
if (values === null)
|
|
1052
|
-
return
|
|
1053
|
-
if (
|
|
1054
|
-
return
|
|
1055
|
-
const endpoint = operation.mediated ? `${mediated.endpoint.replace(/\/$/, '')}/${encodeURIComponent(operation.mediated.challengeId)}` : mediated.endpoint;
|
|
1056
|
-
let credential = await credentialFor(connection.id, operation.resource, null), token = await secrets.read(credential.secretRef);
|
|
1057
|
-
if (!token) {
|
|
1058
|
-
credential = await credentialFor(connection.id, operation.resource, credential.secretRef);
|
|
1059
|
-
token = await secrets.read(credential.secretRef);
|
|
1060
|
-
}
|
|
1061
|
-
if (!token)
|
|
1062
|
-
return await terminate(operationId, 'failed', 'missing_credential');
|
|
1063
|
-
if (connection.sessionId !== (await record(connection.id)).sessionId || now() >= operation.expiresAt)
|
|
1064
|
-
return await terminate(operationId, 'expired');
|
|
1065
|
-
let response, json;
|
|
1066
|
-
const issuedAt = now(), key = await loadDpopKey(connection.dpopKeyRef);
|
|
1177
|
+
return terminate(operationId, 'cancelled');
|
|
1178
|
+
if (Object.keys(values).length !== fields.length || !fields.every(field => Object.hasOwn(values, field.name) && typeof values[field.name] === 'string' && values[field.name].length > 0 && values[field.name].length <= 4096))
|
|
1179
|
+
return terminate(operationId, 'failed', 'invalid_input');
|
|
1067
1180
|
const dispatch = await transaction({ operationId }, state => {
|
|
1068
|
-
const current = own(state.operations, operationId);
|
|
1181
|
+
const current = own(state.operations, operationId), live = own(state.connections, current.connectionId);
|
|
1069
1182
|
if (current.phase !== 'prepared' || current.owner?.id !== executionOwner.id || current.cancelRequested)
|
|
1070
1183
|
return false;
|
|
1071
|
-
|
|
1072
|
-
if (live.generation !== current.connectionGeneration || live.sessionId !== current.signInSessionId || current.expiresAt <= now()) {
|
|
1184
|
+
if (live.generation !== current.connectionGeneration || current.expiresAt <= now()) {
|
|
1073
1185
|
finish(state, current, 'cancelled');
|
|
1074
1186
|
return false;
|
|
1075
1187
|
}
|
|
@@ -1078,69 +1190,48 @@ export function createClientAuth(options) {
|
|
|
1078
1190
|
});
|
|
1079
1191
|
if (!dispatch)
|
|
1080
1192
|
return (await api.operation(operationId));
|
|
1193
|
+
const params = { grant_type: MEDIATED_GRANT, ...(operation.mediated ? { sign_in_id: operation.mediated.challengeId, one_time_code: values.code } : { scope: operation.scopes.join(' '), ...values }) };
|
|
1194
|
+
if (operation.resource)
|
|
1195
|
+
params.resource = operation.resource;
|
|
1196
|
+
const issuedAt = now();
|
|
1081
1197
|
try {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
if (attempt > 0 && own((await load({ operationId })).operations, operationId)?.cancelRequested)
|
|
1085
|
-
return await terminate(operationId, 'cancelled');
|
|
1086
|
-
response = await transport.fetch(new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `DPoP ${token}`, dpop: await proof(key, 'POST', endpoint, token) },
|
|
1087
|
-
body: JSON.stringify(operation.mediated ? { code: values.code } : { scope: operation.scopes.join(' '), credentials: values }) }));
|
|
1088
|
-
rememberNonce(endpoint, response);
|
|
1089
|
-
if (response.status === 401 && attempt === 0 && dpopChallenge(response.headers.get('www-authenticate'))?.error === 'use_dpop_nonce' && response.headers.has('dpop-nonce')) {
|
|
1090
|
-
await response.body?.cancel();
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
break;
|
|
1094
|
-
}
|
|
1095
|
-
if (response.status === 429 || response.status === 503)
|
|
1096
|
-
await holdOff(connection.id, responseFailure(response, 'rate_limited'));
|
|
1097
|
-
json = parseJson(await response.text());
|
|
1098
|
-
}
|
|
1099
|
-
catch {
|
|
1100
|
-
return await terminate(operationId, response?.status === 429 ? 'failed' : 'uncertain', response?.status === 429 ? 'rate_limited' : 'lost_response');
|
|
1101
|
-
}
|
|
1102
|
-
const echoed = Object.values(values);
|
|
1103
|
-
for (const name of Object.keys(values))
|
|
1104
|
-
delete values[name];
|
|
1105
|
-
values = null;
|
|
1106
|
-
if (response.status !== 200) {
|
|
1107
|
-
const error = responseFailure(response, response.status === 429 ? 'rate_limited' : response.status >= 400 && response.status < 500 && typeof json?.error === 'string' && SAFE_ERRORS.has(json.error) ? json.error : 'token_error');
|
|
1108
|
-
return await remoteFailure(operationId, error);
|
|
1198
|
+
const { token } = await tokenRequest(connection.metadata, connection.dpopKeyRef, params, operationId);
|
|
1199
|
+
return await acceptSignIn(operationId, token, issuedAt);
|
|
1109
1200
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
return
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
if (error instanceof MediatedCodeRequired) {
|
|
1203
|
+
const challenge = error.challenge;
|
|
1204
|
+
if (operation.mediated && operation.mediated.challengeId !== challenge.id)
|
|
1205
|
+
return terminate(operationId, 'failed', 'invalid_response');
|
|
1206
|
+
const sentTo = Object.values(values).some(value => challenge.sentTo.includes(value)) ? 'The company sent a one-time code.' : challenge.sentTo;
|
|
1207
|
+
return transaction({ operationId }, state => {
|
|
1208
|
+
const current = own(state.operations, operationId);
|
|
1209
|
+
if (current.phase !== 'dispatched')
|
|
1210
|
+
return outcomeOf(state, current);
|
|
1211
|
+
if (current.cancelRequested)
|
|
1212
|
+
return finish(state, current, 'cancelled');
|
|
1213
|
+
current.phase = 'prepared';
|
|
1214
|
+
delete current.owner;
|
|
1215
|
+
current.expiresAt = Math.min(current.expiresAt, challenge.expiresAt);
|
|
1216
|
+
current.mediated = { challengeId: challenge.id, sentTo };
|
|
1217
|
+
return outcomeOf(state, current);
|
|
1218
|
+
});
|
|
1117
1219
|
}
|
|
1118
|
-
|
|
1220
|
+
if (!(error instanceof AuthFailure))
|
|
1221
|
+
throw error;
|
|
1222
|
+
if (['access_denied', 'invalid_grant', 'expired_token', 'cancelled'].includes(error.code))
|
|
1223
|
+
return terminate(operationId, error.code === 'expired_token' ? 'expired' : error.code === 'cancelled' ? 'cancelled' : 'denied', error.code);
|
|
1224
|
+
return remoteFailure(operationId, error);
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
for (const name of Object.keys(params))
|
|
1228
|
+
delete params[name];
|
|
1119
1229
|
}
|
|
1120
|
-
if (json?.status === 'failed' || json?.status === 'expired')
|
|
1121
|
-
return await terminate(operationId, json.status === 'expired' ? 'expired' : 'denied');
|
|
1122
|
-
const code = json?.code, expiresAt = typeof json?.expires_at === 'string' ? Date.parse(json.expires_at) : NaN;
|
|
1123
|
-
if (json?.status !== 'code_required' || typeof json.sign_in_id !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(json.sign_in_id) ||
|
|
1124
|
-
(operation.mediated && operation.mediated.challengeId !== json.sign_in_id) || typeof code?.sent_to !== 'string' || code.sent_to.length > 512 || !Number.isFinite(expiresAt) || expiresAt <= now())
|
|
1125
|
-
return await terminate(operationId, 'failed', 'invalid_response');
|
|
1126
|
-
const sentTo = echoed.some(value => value.length > 2 && code.sent_to.includes(value)) ? 'The company sent a one-time code.' : code.sent_to;
|
|
1127
|
-
return await transaction({ operationId }, state => {
|
|
1128
|
-
const current = own(state.operations, operationId);
|
|
1129
|
-
if (current.phase !== 'dispatched')
|
|
1130
|
-
return outcomeOf(state, current);
|
|
1131
|
-
if (current.cancelRequested)
|
|
1132
|
-
return finish(state, current, 'cancelled');
|
|
1133
|
-
current.phase = 'prepared';
|
|
1134
|
-
delete current.owner;
|
|
1135
|
-
current.expiresAt = Math.min(current.expiresAt, expiresAt);
|
|
1136
|
-
current.mediated = { challengeId: json.sign_in_id, sentTo };
|
|
1137
|
-
return outcomeOf(state, current);
|
|
1138
|
-
});
|
|
1139
1230
|
}
|
|
1140
1231
|
catch (error) {
|
|
1141
|
-
if (
|
|
1232
|
+
if (own((await load({ operationId })).operations, operationId)?.phase === 'staged')
|
|
1142
1233
|
throw error;
|
|
1143
|
-
return
|
|
1234
|
+
return remoteFailure(operationId, error);
|
|
1144
1235
|
}
|
|
1145
1236
|
finally {
|
|
1146
1237
|
if (sensitive)
|
|
@@ -1149,61 +1240,120 @@ export function createClientAuth(options) {
|
|
|
1149
1240
|
releaseOwner(executionOwner);
|
|
1150
1241
|
}
|
|
1151
1242
|
}
|
|
1152
|
-
async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true) {
|
|
1153
|
-
const connection = await record(connectionId);
|
|
1243
|
+
async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true, service, expectedContext) {
|
|
1154
1244
|
const url = new URL(request.url), endpoint = new URL(target.endpoint), base = endpoint.pathname.replace(/\/$/, '');
|
|
1155
1245
|
if (!safeHttps(request.url) || url.origin !== endpoint.origin || (url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${base}/`)))
|
|
1156
1246
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
checkCooldown(connection);
|
|
1160
|
-
const resource = target.resource ?? null, wait = url.searchParams.get('wait');
|
|
1247
|
+
let resource = target.resource ?? null;
|
|
1248
|
+
const wait = url.searchParams.get('wait');
|
|
1161
1249
|
const waitMs = wait !== null && /^\d+$/.test(wait) ? Math.min(Number(wait), MAX_WAIT_SECONDS) * 1000 : 0;
|
|
1162
|
-
let
|
|
1250
|
+
let restarted = false, nonceRetried = false, renewed = false, discovered = false;
|
|
1163
1251
|
for (;;) {
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1252
|
+
const connection = await record(connectionId);
|
|
1253
|
+
if (expectedContext && (expectedContext.companyId !== connection.issuer || expectedContext.clientId !== clientId || expectedContext.userId !== connection.userId || expectedContext.authority !== (connection.status === 'signed_in' && connection.grantId ? 'grant:' + connection.grantId : 'session:' + connection.sessionId)))
|
|
1254
|
+
throw new AuthFailure('business_owner_changed', connectionId);
|
|
1255
|
+
if (connection.status === 'disconnected')
|
|
1256
|
+
throw new AuthFailure('disconnected', connectionId);
|
|
1257
|
+
if (connection.status === 'uncertain')
|
|
1258
|
+
throw new AuthFailure('grant_uncertain', connectionId);
|
|
1259
|
+
checkCooldown(connection);
|
|
1260
|
+
let credential, token;
|
|
1261
|
+
if (connection.status === 'signed_in') {
|
|
1262
|
+
if (service && !discovered) {
|
|
1263
|
+
try {
|
|
1264
|
+
resource = (await serviceAuthorization(connection.metadata, options.metadata, transport, target.endpoint, { mcp: service === 'mcp' })).resource ?? null;
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1268
|
+
}
|
|
1269
|
+
discovered = true;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
credential = await credentialFor(connectionId, resource, null);
|
|
1273
|
+
}
|
|
1274
|
+
catch (error) {
|
|
1275
|
+
if ((await record(connectionId)).status === 'signed_out')
|
|
1276
|
+
continue;
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
1279
|
+
const value = await secrets.read(credential.secretRef);
|
|
1280
|
+
if (value === null) {
|
|
1281
|
+
if (renewed)
|
|
1282
|
+
throw new AuthFailure('missing_credential', connectionId);
|
|
1283
|
+
renewed = true;
|
|
1284
|
+
await credentialFor(connectionId, resource, credential.secretRef);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
token = value;
|
|
1288
|
+
}
|
|
1289
|
+
if (!requiredScopes.every(scope => credential?.scopes.includes(scope))) {
|
|
1167
1290
|
const requirement = await api.authorization(connectionId, requiredScopes);
|
|
1168
1291
|
if (!renewed && requirement.sufficient) {
|
|
1169
1292
|
renewed = true;
|
|
1170
|
-
|
|
1293
|
+
await renewShared(connectionId, resource);
|
|
1171
1294
|
continue;
|
|
1172
1295
|
}
|
|
1173
1296
|
throw new AuthFailure('insufficient_scope', connectionId);
|
|
1174
1297
|
}
|
|
1175
|
-
const token = await secrets.read(credential.secretRef);
|
|
1176
|
-
if (token === null) {
|
|
1177
|
-
if (renewed)
|
|
1178
|
-
throw new AuthFailure('missing_credential', connectionId);
|
|
1179
|
-
renewed = true;
|
|
1180
|
-
credential = await credentialFor(connectionId, resource, credential.secretRef);
|
|
1181
|
-
continue;
|
|
1182
|
-
}
|
|
1183
1298
|
const headers = new Headers(request.headers);
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1299
|
+
for (const name of ['cookie', 'authorization', 'dpop', 'poppy-session'])
|
|
1300
|
+
headers.delete(name);
|
|
1301
|
+
if (token !== undefined) {
|
|
1302
|
+
headers.set('authorization', `${credential.tokenType} ${token}`);
|
|
1303
|
+
if (credential.tokenType === 'DPoP')
|
|
1304
|
+
headers.set('dpop', await proof(await loadDpopKey(credential.dpopKeyRef), request.method, request.url, token));
|
|
1305
|
+
}
|
|
1306
|
+
headers.set('poppy-session', await sessionHeader(connection, request, token));
|
|
1307
|
+
if ((await record(connectionId)).generation !== connection.generation)
|
|
1308
|
+
continue;
|
|
1189
1309
|
const authorized = new Request(request.clone(), { headers, credentials: 'omit', redirect: 'error' });
|
|
1190
1310
|
const streaming = request.headers.get('accept')?.split(',').some(value => value.trim().split(';')[0] === 'text/event-stream');
|
|
1191
1311
|
const response = await (streaming && transport.stream ? transport.stream(authorized) : transport.fetch(authorized, { waitMs }));
|
|
1192
1312
|
rememberNonce(request.url, response);
|
|
1193
1313
|
if (response.status === 429 || response.status === 503)
|
|
1194
1314
|
await holdOff(connectionId, responseFailure(response, 'rate_limited'));
|
|
1315
|
+
if (response.status === 400) {
|
|
1316
|
+
let invalid = false;
|
|
1317
|
+
try {
|
|
1318
|
+
invalid = parseProtocolError(parseJson(await response.clone().text())).error === 'invalid_session';
|
|
1319
|
+
}
|
|
1320
|
+
catch { /* Not a Session refusal. */ }
|
|
1321
|
+
if (invalid && !restarted) {
|
|
1322
|
+
await response.body?.cancel().catch(() => { });
|
|
1323
|
+
restarted = true;
|
|
1324
|
+
await transaction({ connectionId }, state => {
|
|
1325
|
+
const live = own(state.connections, connectionId);
|
|
1326
|
+
if (live.generation !== connection.generation)
|
|
1327
|
+
return;
|
|
1328
|
+
live.sessionId = randomId('ses_');
|
|
1329
|
+
live.generation++;
|
|
1330
|
+
live.slots = Object.fromEntries(Object.entries(live.slots).map(([key, slot]) => [slotKey(live.sessionId, JSON.parse(key)[1]), slot]));
|
|
1331
|
+
});
|
|
1332
|
+
if (!retryRejected)
|
|
1333
|
+
return response;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1195
1337
|
if (response.status !== 401 || !retryRejected)
|
|
1196
1338
|
return response;
|
|
1197
|
-
const
|
|
1339
|
+
const challenge = schemeChallenge(response.headers.get('www-authenticate'), credential?.tokenType ?? scheme), error = challenge?.error;
|
|
1198
1340
|
if (error === 'use_dpop_nonce' && !nonceRetried && response.headers.has('dpop-nonce')) {
|
|
1199
1341
|
await response.body?.cancel().catch(() => { });
|
|
1200
1342
|
nonceRetried = true;
|
|
1201
1343
|
continue;
|
|
1202
1344
|
}
|
|
1203
|
-
if (error === 'invalid_token' && !renewed) {
|
|
1345
|
+
if (error === 'invalid_token' && credential && !renewed) {
|
|
1204
1346
|
await response.body?.cancel().catch(() => { });
|
|
1205
1347
|
renewed = true;
|
|
1206
|
-
|
|
1348
|
+
if (service && challenge?.resource_metadata) {
|
|
1349
|
+
try {
|
|
1350
|
+
resource = (await serviceAuthorization(connection.metadata, options.metadata, transport, target.endpoint, { mcp: service === 'mcp', metadataUrl: challenge.resource_metadata, refresh: true })).resource ?? null;
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
await credentialFor(connectionId, resource, credential.secretRef);
|
|
1207
1357
|
continue;
|
|
1208
1358
|
}
|
|
1209
1359
|
return response;
|
|
@@ -1218,24 +1368,31 @@ export function createClientAuth(options) {
|
|
|
1218
1368
|
catch {
|
|
1219
1369
|
throw new AuthFailure('discovery_failed');
|
|
1220
1370
|
}
|
|
1221
|
-
|
|
1222
|
-
throw new AuthFailure('no_auth');
|
|
1223
|
-
const issuer = metadata.authorization.issuer;
|
|
1371
|
+
const issuer = companyIdentifier(metadata);
|
|
1224
1372
|
const anonymous = input?.anonymous === true;
|
|
1225
|
-
|
|
1373
|
+
if (input?.accountFrom !== undefined && (anonymous || typeof input.accountFrom !== 'string'))
|
|
1374
|
+
throw new AuthFailure('invalid_account_source');
|
|
1375
|
+
const { userId } = await identityFor(issuer, anonymous);
|
|
1376
|
+
const epoch = (await load({ issuer })).epoch;
|
|
1377
|
+
const dpopKeyRef = await newSessionKey();
|
|
1226
1378
|
const createdAt = now();
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
const id = randomId('con_'), operationId = randomId('op_');
|
|
1230
|
-
return commit({ issuer, history: true }, [secretRef], state => {
|
|
1379
|
+
const id = randomId('con_'), operationId = randomId('op_'), sessionId = randomId('ses_');
|
|
1380
|
+
return commit(input?.accountFrom ? { account: { issuer, userId }, history: true } : { issuer, history: true }, [dpopKeyRef], state => {
|
|
1231
1381
|
// A reset or competing identity since we signed means this session belongs to no current identity.
|
|
1232
|
-
if (!anonymous && own(state.identities, issuer)?.
|
|
1382
|
+
if (state.epoch !== epoch || (!anonymous && own(state.identities, issuer)?.userId !== userId))
|
|
1233
1383
|
throw new AuthFailure('state_changed');
|
|
1234
1384
|
const connection = {
|
|
1235
|
-
id, domain, issuer, metadata, userId, dpopKeyRef, sessionId
|
|
1236
|
-
slots: {
|
|
1385
|
+
id, domain, issuer, metadata, userId, dpopKeyRef, sessionId, generation: 1, status: 'signed_out', grantId: null,
|
|
1386
|
+
slots: {},
|
|
1237
1387
|
...(anonymous ? { anonymous: true } : {}),
|
|
1238
1388
|
};
|
|
1389
|
+
if (input?.accountFrom) {
|
|
1390
|
+
const source = own(state.connections, input.accountFrom), grant = source?.grantId ? own(state.grants, source.grantId) : undefined;
|
|
1391
|
+
if (!source || source.anonymous || source.issuer !== issuer || source.userId !== userId || source.status !== 'signed_in' || !grant || grant.status !== 'active' || (grant.expiresAt !== null && grant.expiresAt <= now()))
|
|
1392
|
+
throw new AuthFailure('state_changed');
|
|
1393
|
+
connection.grantId = grant.id;
|
|
1394
|
+
connection.status = 'signed_in';
|
|
1395
|
+
}
|
|
1239
1396
|
state.connections[id] = connection;
|
|
1240
1397
|
pruneHistory(state);
|
|
1241
1398
|
const result = { operationId, status: 'completed', connection: publicConnection(state, connection) };
|
|
@@ -1263,13 +1420,10 @@ export function createClientAuth(options) {
|
|
|
1263
1420
|
const connection = await currentConnection(connectionId), state = await load({ connectionId });
|
|
1264
1421
|
const grantedScopes = publicConnection(state, connection).scopes;
|
|
1265
1422
|
const grant = connection.grantId ? own(state.grants, connection.grantId) : undefined;
|
|
1266
|
-
const methods =
|
|
1267
|
-
const supported = connection.metadata.document.auth?.[method];
|
|
1268
|
-
return supported ? [{ method, scopes: [...supported.scopes] }] : [];
|
|
1269
|
-
});
|
|
1423
|
+
const methods = signInMethods(connection.metadata);
|
|
1270
1424
|
const required = [...new Set(requiredScopes)], missingScopes = required.filter(scope => !grantedScopes.includes(scope));
|
|
1271
1425
|
return { requiredScopes: required, grantedScopes, missingScopes, sufficient: connection.status === 'signed_in' && grant?.status === 'active' && (grant.expiresAt === null || grant.expiresAt > now()) && !missingScopes.length,
|
|
1272
|
-
methods, organization: { ...connection.metadata.document.organization } };
|
|
1426
|
+
methods, scopeDescriptions: { ...connection.metadata.document.auth?.scopes }, organization: { ...connection.metadata.document.organization } };
|
|
1273
1427
|
},
|
|
1274
1428
|
async beginSignIn(connectionId, input) {
|
|
1275
1429
|
let found = await currentConnection(connectionId);
|
|
@@ -1282,12 +1436,12 @@ export function createClientAuth(options) {
|
|
|
1282
1436
|
const method = input?.method ?? 'direct';
|
|
1283
1437
|
if (!['direct', 'device', 'mediated'].includes(method))
|
|
1284
1438
|
throw new AuthFailure('unsupported_method', connectionId);
|
|
1285
|
-
const supported = found.metadata.
|
|
1439
|
+
const supported = signInMethods(found.metadata).find(item => item.method === method);
|
|
1286
1440
|
const authorizationEndpoint = found.metadata.authorization?.authorization_endpoint;
|
|
1287
1441
|
if (!supported || (method === 'direct' && !authorizationEndpoint) || (method === 'device' && !found.metadata.authorization?.device_authorization_endpoint))
|
|
1288
1442
|
throw new AuthFailure(`${method}_unavailable`, connectionId);
|
|
1289
1443
|
const { scopes, redirectUri, browserBinding, requiredExistingScopes } = input ?? {};
|
|
1290
|
-
if (!Array.isArray(scopes) || !scopes.length || new Set(scopes).size !== scopes.length || !scopes.every(scope => typeof scope === 'string' && supported.scopes.includes(scope)))
|
|
1444
|
+
if (!Array.isArray(scopes) || !scopes.length || new Set(scopes).size !== scopes.length || !scopes.every(scope => typeof scope === 'string' && (/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope) && (method !== 'mediated' || supported.scopes.includes(scope)))))
|
|
1291
1445
|
throw new AuthFailure('invalid_scope', connectionId);
|
|
1292
1446
|
const requirement = await api.authorization(connectionId, scopes);
|
|
1293
1447
|
if (requiredExistingScopes !== undefined && (!Array.isArray(requiredExistingScopes) || !requiredExistingScopes.every(scope => requirement.grantedScopes.includes(scope))))
|
|
@@ -1297,32 +1451,22 @@ export function createClientAuth(options) {
|
|
|
1297
1451
|
checkCooldown(found);
|
|
1298
1452
|
if (method === 'mediated' && !options.trustedInput)
|
|
1299
1453
|
throw new AuthFailure('trusted_input_required', connectionId);
|
|
1300
|
-
if (method === 'direct' && (typeof redirectUri !== 'string' || !
|
|
1454
|
+
if (method === 'direct' && (typeof redirectUri !== 'string' || !registeredRedirect(redirectUri, options.redirectUris, clientId) || CALLBACK_PARAMS.some(name => new URL(redirectUri).searchParams.has(name))))
|
|
1301
1455
|
throw new AuthFailure('invalid_redirect', connectionId);
|
|
1302
1456
|
// The host derives this from its authenticated browser session; a short value would be guessable.
|
|
1303
1457
|
if (method === 'direct' && (typeof browserBinding !== 'string' || browserBinding.length < 22))
|
|
1304
1458
|
throw new AuthFailure('invalid_binding', connectionId);
|
|
1305
|
-
//
|
|
1306
|
-
const
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
// The code exchange names this Session (spec 4.5). A stale credential means it may have ended, so renew first,
|
|
1310
|
-
// which replaces an ended Session; a failed renewal leaves the Session as it was for the issuer to judge.
|
|
1311
|
-
const renew = () => credentialFor(connectionId, resource, null).catch(error => { if (!(error instanceof AuthFailure))
|
|
1312
|
-
throw error; });
|
|
1313
|
-
if (found.status === 'signed_out' || found.status === 'signed_in')
|
|
1314
|
-
await renew();
|
|
1315
|
-
let connection = await record(connectionId);
|
|
1316
|
-
// An expired Account Token ends the refresh as invalid_grant, leaving the connection signed out on a possibly ended Session.
|
|
1317
|
-
if (found.status === 'signed_in' && connection.status === 'signed_out') {
|
|
1318
|
-
await renew();
|
|
1319
|
-
connection = await record(connectionId);
|
|
1320
|
-
}
|
|
1459
|
+
// Service metadata, rather than an obsolete discovery field, selects narrower tokens.
|
|
1460
|
+
const conversation = found.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy');
|
|
1461
|
+
const resource = conversation ? (await serviceAuthorization(found.metadata, options.metadata, transport, conversation.endpoint)).resource ?? null : null;
|
|
1462
|
+
const connection = await record(connectionId);
|
|
1321
1463
|
// An uncertain grant recovers only through a fresh sign-in; a disconnected connection never signs in again (spec 4.9).
|
|
1322
1464
|
if (connection.status === 'disconnected' || connection.sessionId === null)
|
|
1323
1465
|
throw new AuthFailure('sign_in_unavailable', connectionId);
|
|
1324
1466
|
const state = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1325
1467
|
const verifier = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1468
|
+
const sessionKey = (await loadDpopKey(connection.dpopKeyRef)).thumbprint;
|
|
1469
|
+
const proofBound = method === 'direct' && await resourceScheme(found.metadata, resource) === 'DPoP';
|
|
1326
1470
|
const verifierRef = method === 'direct' ? await secrets.stage(verifier) : null;
|
|
1327
1471
|
const createdAt = now(), operationId = randomId('op_');
|
|
1328
1472
|
const outcome = await commit({ connectionId, history: true }, [verifierRef], draft => {
|
|
@@ -1333,7 +1477,7 @@ export function createClientAuth(options) {
|
|
|
1333
1477
|
const operation = draft.operations[operationId] = {
|
|
1334
1478
|
epoch: draft.epoch, id: operationId, kind: 'sign_in', connectionId, connectionGeneration: current.generation, grantId: current.grantId, grantGeneration: null,
|
|
1335
1479
|
phase: 'prepared', status: 'pending', createdAt, expiresAt: createdAt + SIGN_IN_MS, resource, scopes: [...scopes],
|
|
1336
|
-
method, signInSessionId: connection.sessionId, requiredExistingScopes: requiredExistingScopes ?? requirement.grantedScopes,
|
|
1480
|
+
method, signInSessionId: connection.sessionId, signInSessionKey: sessionKey, requiredExistingScopes: requiredExistingScopes ?? requirement.grantedScopes,
|
|
1337
1481
|
direct: method === 'direct' ? { stateHash: sha256(state), verifierRef: verifierRef, browserHash: sha256(browserBinding), redirectUri: redirectUri, issuer: current.issuer, clientId, sessionId: connection.sessionId } : null,
|
|
1338
1482
|
staged: null, result: null,
|
|
1339
1483
|
};
|
|
@@ -1347,6 +1491,8 @@ export function createClientAuth(options) {
|
|
|
1347
1491
|
const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256' };
|
|
1348
1492
|
for (const [name, value] of Object.entries(query))
|
|
1349
1493
|
url.searchParams.set(name, value);
|
|
1494
|
+
if (proofBound)
|
|
1495
|
+
url.searchParams.set('dpop_jkt', sessionKey);
|
|
1350
1496
|
if (resource !== null)
|
|
1351
1497
|
url.searchParams.set('resource', resource);
|
|
1352
1498
|
return { outcome, authorizationUrl: url.href };
|
|
@@ -1366,6 +1512,8 @@ export function createClientAuth(options) {
|
|
|
1366
1512
|
if (!found || !direct || direct.browserHash !== browserHash || !redirectMatches(base, new URL(direct.redirectUri)) || params.iss !== direct.issuer)
|
|
1367
1513
|
throw new AuthFailure('invalid_callback');
|
|
1368
1514
|
checkCooldown(await record(found.connectionId));
|
|
1515
|
+
if (found.phase === 'prepared' && !found.signInSessionKey)
|
|
1516
|
+
return terminate(found.id, 'failed', 'restart_required');
|
|
1369
1517
|
const operationId = found.id;
|
|
1370
1518
|
const verifier = found.phase === 'prepared' && params.code !== undefined ? await secrets.read(direct.verifierRef) : null;
|
|
1371
1519
|
const at = now();
|
|
@@ -1377,7 +1525,7 @@ export function createClientAuth(options) {
|
|
|
1377
1525
|
if (operation.phase !== 'prepared')
|
|
1378
1526
|
return outcomeOf(state, operation);
|
|
1379
1527
|
const connection = own(state.connections, operation.connectionId);
|
|
1380
|
-
if (connection.generation !== operation.connectionGeneration)
|
|
1528
|
+
if (connection.generation !== operation.connectionGeneration || connection.sessionId !== operation.signInSessionId)
|
|
1381
1529
|
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
1382
1530
|
if (at >= operation.expiresAt)
|
|
1383
1531
|
return finish(state, operation, 'expired');
|
|
@@ -1395,13 +1543,13 @@ export function createClientAuth(options) {
|
|
|
1395
1543
|
return claim;
|
|
1396
1544
|
}
|
|
1397
1545
|
const { connection, operation } = claim;
|
|
1398
|
-
const fields = { grant_type: 'authorization_code', code: params.code, redirect_uri: direct.redirectUri, code_verifier: verifier
|
|
1546
|
+
const fields = { grant_type: 'authorization_code', code: params.code, redirect_uri: direct.redirectUri, code_verifier: verifier };
|
|
1399
1547
|
if (operation.resource !== null)
|
|
1400
1548
|
fields.resource = operation.resource;
|
|
1401
1549
|
const issuedAt = now();
|
|
1402
1550
|
let token;
|
|
1403
1551
|
try {
|
|
1404
|
-
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields
|
|
1552
|
+
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields)).token;
|
|
1405
1553
|
}
|
|
1406
1554
|
catch (error) {
|
|
1407
1555
|
if (error instanceof AuthFailure)
|
|
@@ -1477,6 +1625,8 @@ export function createClientAuth(options) {
|
|
|
1477
1625
|
result = activateSignIn(state, operation, drop);
|
|
1478
1626
|
else if (operation.phase !== 'prepared')
|
|
1479
1627
|
result = finish(state, operation, 'uncertain', { error: 'lost_response' });
|
|
1628
|
+
else if (operation.kind === 'sign_in' && !operation.signInSessionKey)
|
|
1629
|
+
result = finish(state, operation, 'failed', { error: 'restart_required' });
|
|
1480
1630
|
else if (at >= operation.expiresAt)
|
|
1481
1631
|
result = finish(state, operation, 'expired');
|
|
1482
1632
|
else
|
|
@@ -1499,38 +1649,52 @@ export function createClientAuth(options) {
|
|
|
1499
1649
|
throw new AuthFailure('invalid_scope', connectionId);
|
|
1500
1650
|
return renewal(await currentConnection(connectionId), resource, scopes ?? null);
|
|
1501
1651
|
},
|
|
1502
|
-
//
|
|
1652
|
+
// Opaque grants have no standard account identifier; revoke all grants for this local User and Company.
|
|
1503
1653
|
async signOut(connectionId) {
|
|
1504
|
-
await record(connectionId);
|
|
1505
|
-
await commit(
|
|
1506
|
-
const connection
|
|
1507
|
-
|
|
1508
|
-
return;
|
|
1509
|
-
connection.generation++;
|
|
1510
|
-
connection.status = 'disconnected';
|
|
1511
|
-
drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
1512
|
-
connection.slots = {};
|
|
1513
|
-
if (connection.anonymous) {
|
|
1514
|
-
drop.push(connection.dpopKeyRef);
|
|
1515
|
-
connection.userId = '';
|
|
1516
|
-
connection.sessionId = null;
|
|
1517
|
-
}
|
|
1518
|
-
const grant = connection.grantId === null ? undefined : own(state.grants, connection.grantId);
|
|
1519
|
-
if (grant) {
|
|
1520
|
-
retire(state, `cln_${grant.id}`, connection, grant.secretRef);
|
|
1521
|
-
delete state.grants[grant.id];
|
|
1522
|
-
}
|
|
1523
|
-
connection.grantId = null;
|
|
1524
|
-
for (const operation of Object.values(state.operations)) {
|
|
1525
|
-
if (operation.connectionId !== connectionId || operation.epoch !== state.epoch || operation.phase !== 'prepared')
|
|
1654
|
+
const selected = await record(connectionId), selection = selected.anonymous ? { connectionId } : { account: { issuer: selected.issuer, userId: selected.userId } };
|
|
1655
|
+
await commit(selection, [], (state, drop) => {
|
|
1656
|
+
for (const connection of Object.values(state.connections)) {
|
|
1657
|
+
if (connection.status === 'disconnected')
|
|
1526
1658
|
continue;
|
|
1527
|
-
|
|
1528
|
-
|
|
1659
|
+
connection.generation++;
|
|
1660
|
+
connection.status = connection.anonymous ? 'disconnected' : 'signed_out';
|
|
1661
|
+
drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
1662
|
+
connection.slots = {};
|
|
1663
|
+
if (connection.anonymous) {
|
|
1664
|
+
if (connection.sessionSigning)
|
|
1665
|
+
(connection.signingCleanup ??= []).push(connection.sessionSigning.keyRef, connection.sessionSigning.delegationRef);
|
|
1666
|
+
delete connection.sessionSigning;
|
|
1667
|
+
(connection.signingCleanup ??= []).push(connection.dpopKeyRef);
|
|
1668
|
+
connection.userId = '';
|
|
1669
|
+
connection.sessionId = null;
|
|
1670
|
+
}
|
|
1671
|
+
const grant = connection.grantId === null ? undefined : own(state.grants, connection.grantId);
|
|
1672
|
+
if (grant) {
|
|
1673
|
+
retire(state, `cln_${grant.id}`, connection, grant.secretRef);
|
|
1674
|
+
delete state.grants[grant.id];
|
|
1675
|
+
}
|
|
1676
|
+
connection.grantId = null;
|
|
1677
|
+
for (const operation of Object.values(state.operations)) {
|
|
1678
|
+
if (operation.connectionId !== connection.id || operation.epoch !== state.epoch || operation.phase !== 'prepared')
|
|
1679
|
+
continue;
|
|
1680
|
+
finish(state, operation, 'cancelled');
|
|
1681
|
+
drop.push(...privateRefs(operation));
|
|
1682
|
+
}
|
|
1529
1683
|
}
|
|
1530
1684
|
});
|
|
1531
1685
|
return cleanupNow(connectionId);
|
|
1532
1686
|
},
|
|
1533
1687
|
retryCleanup: cleanupNow,
|
|
1688
|
+
async businessContext(connectionId) {
|
|
1689
|
+
const connection = await currentConnection(connectionId);
|
|
1690
|
+
if (!connection.sessionId || !['signed_out', 'signed_in'].includes(connection.status))
|
|
1691
|
+
throw new AuthFailure('invalid_state', connectionId);
|
|
1692
|
+
const client = await options.metadata.resolveClient(options.clientId);
|
|
1693
|
+
return { companyId: connection.issuer, companyName: connection.metadata.document.organization.name,
|
|
1694
|
+
clientId: options.clientId, userId: connection.userId,
|
|
1695
|
+
authority: connection.status === 'signed_in' && connection.grantId ? 'grant:' + connection.grantId : 'session:' + connection.sessionId,
|
|
1696
|
+
operations: supportsOperations(client) && supportsOperations(connection.metadata.document) };
|
|
1697
|
+
},
|
|
1534
1698
|
async apiTarget(connectionId, apiUrl, destination) {
|
|
1535
1699
|
const connection = await currentConnection(connectionId);
|
|
1536
1700
|
try {
|
|
@@ -1540,17 +1704,17 @@ export function createClientAuth(options) {
|
|
|
1540
1704
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1541
1705
|
}
|
|
1542
1706
|
},
|
|
1543
|
-
async apiFetch(connectionId, request, target, requiredScopes = []) {
|
|
1707
|
+
async apiFetch(connectionId, request, target, requiredScopes = [], expectedContext) {
|
|
1544
1708
|
const expected = await api.apiTarget(connectionId, target.apiUrl, target.endpoint);
|
|
1545
1709
|
if (expected.kind !== target.kind || expected.issuer !== target.issuer || expected.endpoint !== target.endpoint || expected.resource !== target.resource || expected.scheme !== target.scheme)
|
|
1546
1710
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1547
1711
|
if (expected.kind === 'mcp' && request.url !== expected.endpoint)
|
|
1548
1712
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1549
|
-
return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme);
|
|
1713
|
+
return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme, true, expected.kind, expectedContext);
|
|
1550
1714
|
},
|
|
1551
1715
|
async joinBrowser(connectionId, returnTo, controller) {
|
|
1552
1716
|
const connection = await currentConnection(connectionId), endpoint = connection.metadata.document.web?.browser_session_endpoint;
|
|
1553
|
-
if (!endpoint || !connection.sessionId || connection.status === 'disconnected' || connection.status === 'uncertain' || controller?.kind !== 'agent-controlled' || typeof controller.postForm !== 'function')
|
|
1717
|
+
if (!endpoint || !connection.sessionId || connection.status === 'disconnected' || connection.status === 'uncertain' || controller?.kind !== 'agent-controlled' || typeof controller.loadJoinForm !== 'function' || typeof controller.postForm !== 'function' || typeof controller.navigate !== 'function')
|
|
1554
1718
|
throw new AuthFailure('invalid_browser', connectionId);
|
|
1555
1719
|
try {
|
|
1556
1720
|
browserDestination(returnTo, connection.metadata.document.organization.domain);
|
|
@@ -1558,26 +1722,27 @@ export function createClientAuth(options) {
|
|
|
1558
1722
|
catch {
|
|
1559
1723
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1560
1724
|
}
|
|
1561
|
-
const key = await loadDpopKey(connection.dpopKeyRef);
|
|
1562
|
-
let value;
|
|
1563
|
-
if (options.assertions && !connection.anonymous) {
|
|
1564
|
-
let result;
|
|
1565
|
-
try {
|
|
1566
|
-
result = await options.assertions.assert({ purpose: 'browser', operationId: randomId('op_'), issuer: connection.issuer, sessionId: connection.sessionId, returnTo });
|
|
1567
|
-
}
|
|
1568
|
-
catch (error) {
|
|
1569
|
-
throw providerFailure(error, connectionId);
|
|
1570
|
-
}
|
|
1571
|
-
value = providerAssertion(result, connectionId);
|
|
1572
|
-
}
|
|
1573
|
-
else
|
|
1574
|
-
value = await signIdentity({ typ: 'poppy-browser+jwt' }, { ...assertionClaims(connection.userId, endpoint), session_id: connection.sessionId, return_to: returnTo }, key.thumbprint);
|
|
1575
1725
|
try {
|
|
1576
|
-
|
|
1577
|
-
|
|
1726
|
+
const { code } = await controller.loadJoinForm(endpoint);
|
|
1727
|
+
if (typeof code !== 'string' || !code || code.length > 256)
|
|
1728
|
+
throw new Error('code');
|
|
1729
|
+
const live = await currentConnection(connectionId);
|
|
1730
|
+
if (live.generation !== connection.generation || live.dpopKeyRef !== connection.dpopKeyRef || live.status !== connection.status)
|
|
1731
|
+
throw new Error('state changed');
|
|
1732
|
+
const authorize = new URL(endpoint);
|
|
1733
|
+
authorize.pathname = authorize.pathname.replace(/\/$/, '') + '/authorize';
|
|
1734
|
+
const response = await sendAuthorized(connectionId, new Request(authorize, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }) }), { issuer: connection.issuer, endpoint }, []);
|
|
1735
|
+
if (response.status !== 204)
|
|
1736
|
+
throw new AuthFailure('browser_authorization_failed', connectionId);
|
|
1737
|
+
await response.body?.cancel();
|
|
1738
|
+
await controller.postForm({ url: endpoint, fields: { code }, validateRedirect(status, location) {
|
|
1739
|
+
if (status !== 303)
|
|
1578
1740
|
throw new AuthFailure('invalid_browser_redirect', connectionId);
|
|
1579
|
-
|
|
1741
|
+
const destination = new URL(location, endpoint).href;
|
|
1742
|
+
browserDestination(destination, connection.metadata.document.organization.domain);
|
|
1743
|
+
return destination;
|
|
1580
1744
|
} });
|
|
1745
|
+
await controller.navigate(returnTo);
|
|
1581
1746
|
}
|
|
1582
1747
|
catch {
|
|
1583
1748
|
throw new AuthFailure('browser_failed', connectionId);
|
|
@@ -1589,14 +1754,29 @@ export function createClientAuth(options) {
|
|
|
1589
1754
|
if (!endpoint)
|
|
1590
1755
|
throw new AuthFailure('feedback_unavailable', connectionId);
|
|
1591
1756
|
const response = await sendAuthorized(connectionId, new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) }), { issuer: connection.issuer, endpoint }, [], 'DPoP', false);
|
|
1592
|
-
|
|
1593
|
-
|
|
1757
|
+
try {
|
|
1758
|
+
const text = await response.text();
|
|
1759
|
+
if (response.status === 202 && text === '')
|
|
1760
|
+
return;
|
|
1761
|
+
if (response.status === 200)
|
|
1762
|
+
return parseAlternative(parseJson(text), connection.metadata.document);
|
|
1763
|
+
}
|
|
1764
|
+
catch { /* Do not expose company response/parser details. */ }
|
|
1765
|
+
throw new AuthFailure('feedback_failed', connectionId);
|
|
1766
|
+
},
|
|
1767
|
+
async excludedTasks(connectionId) {
|
|
1768
|
+
const connection = await currentConnection(connectionId);
|
|
1769
|
+
if (!connection.metadata.document.excluded_tasks)
|
|
1770
|
+
return [];
|
|
1771
|
+
if (!options.metadata.resolveExcludedTasks)
|
|
1772
|
+
throw new AuthFailure('excluded_tasks_unavailable', connectionId);
|
|
1773
|
+
return options.metadata.resolveExcludedTasks(connection.metadata.document);
|
|
1594
1774
|
},
|
|
1595
|
-
async authorizedFetch(connectionId, request, target, requiredScopes = []) {
|
|
1775
|
+
async authorizedFetch(connectionId, request, target, requiredScopes = [], expectedContext) {
|
|
1596
1776
|
const connection = await currentConnection(connectionId), expected = targetOf(connection);
|
|
1597
1777
|
if (target.issuer !== expected.issuer || target.endpoint !== expected.endpoint || target.resource !== expected.resource)
|
|
1598
1778
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1599
|
-
return sendAuthorized(connectionId, request, expected, requiredScopes);
|
|
1779
|
+
return sendAuthorized(connectionId, request, expected, requiredScopes, 'DPoP', true, 'conversation', expectedContext);
|
|
1600
1780
|
},
|
|
1601
1781
|
};
|
|
1602
1782
|
return api;
|