@poppykit/client 0.4.1 → 0.5.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 +3 -2
- package/AUTH.md +27 -2
- package/CHANGELOG.md +6 -0
- package/CONTRIBUTING.md +5 -3
- package/DELIVERY.md +14 -1
- package/INTEGRATIONS.md +36 -6
- package/PROVIDER.md +8 -6
- package/README.md +8 -3
- package/STORAGE.md +12 -0
- package/dist/adapter-contract.js +1 -1
- package/dist/auth/client-auth.d.ts +10 -7
- package/dist/auth/client-auth.js +211 -83
- package/dist/auth/sqlite-store.js +16 -2
- package/dist/conversation.js +12 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -1
- package/dist/integrations.d.ts +6 -1
- package/dist/network/discovery.d.ts +20 -9
- package/dist/network/discovery.js +74 -21
- 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/postgres.js +14 -1
- package/dist/protocol/wire.d.ts +12 -11
- package/dist/protocol/wire.js +33 -7
- package/dist/provider.d.ts +2 -9
- package/dist/provider.js +15 -25
- package/examples/quickstart.mjs +1 -1
- package/package.json +1 -1
package/dist/auth/client-auth.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { parseAlternative } from '../network/tasks.js';
|
|
2
|
+
import { registeredRedirect } from '../network/redirect.js';
|
|
1
3
|
import { acquireOwner, releaseOwner, localOwnerAlive } from './ownership.js';
|
|
2
4
|
import { createHash } from 'node:crypto';
|
|
3
5
|
import { calculateJwkThumbprint } from 'jose';
|
|
4
6
|
import { parseJson, parseProtocolError, parseTokenResponse, retryAfterMilliseconds } from '../protocol/wire.js';
|
|
5
|
-
import { validateClientId } from '../network/discovery.js';
|
|
7
|
+
import { validateClientId, signInMethods } from '../network/discovery.js';
|
|
6
8
|
import { dpopChallenge, schemeChallenge } from './challenge.js';
|
|
7
9
|
import { resolveApiTarget, mcpScheme, browserDestination, validateFeedback } from '../integrations.js';
|
|
8
10
|
export class AuthFailure extends Error {
|
|
@@ -33,7 +35,7 @@ function providerFailure(error, connectionId) {
|
|
|
33
35
|
: ['invalid_response', 'binding_conflict', 'expired', 'consumed', 'canceled'].includes(String(code)) ? 'provider_invalid_response' : 'provider_unavailable', connectionId);
|
|
34
36
|
}
|
|
35
37
|
// 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', '
|
|
38
|
+
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
39
|
const b64 = (data) => Buffer.from(data).toString('base64url');
|
|
38
40
|
const randomId = (prefix = '') => prefix + b64(crypto.getRandomValues(new Uint8Array(16)));
|
|
39
41
|
const own = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
|
|
@@ -84,7 +86,28 @@ function parseCallback(callbackUrl) {
|
|
|
84
86
|
return { params, base };
|
|
85
87
|
}
|
|
86
88
|
export function initialClientAuthState() {
|
|
87
|
-
return { version:
|
|
89
|
+
return { version: 2, epoch: randomId(), owner: null, identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
|
|
90
|
+
}
|
|
91
|
+
/** Upgrade a complete namespace under its writer lock; dispatched/staged work still goes through recover(). */
|
|
92
|
+
export function upgradeClientAuthState(state) {
|
|
93
|
+
if (state.version === 2)
|
|
94
|
+
return;
|
|
95
|
+
if (state.version !== 1)
|
|
96
|
+
throw new Error('unsupported auth state version');
|
|
97
|
+
for (const connection of Object.values(state.connections)) {
|
|
98
|
+
connection.needsSessionKey = true;
|
|
99
|
+
connection.slots = {};
|
|
100
|
+
}
|
|
101
|
+
for (const operation of Object.values(state.operations))
|
|
102
|
+
if (operation.phase === 'prepared') {
|
|
103
|
+
operation.phase = 'terminal';
|
|
104
|
+
operation.status = 'cancelled';
|
|
105
|
+
operation.result = null;
|
|
106
|
+
const grant = operation.grantId ? own(state.grants, operation.grantId) : undefined;
|
|
107
|
+
if (grant?.operationId === operation.id)
|
|
108
|
+
grant.operationId = null;
|
|
109
|
+
}
|
|
110
|
+
state.version = 2;
|
|
88
111
|
}
|
|
89
112
|
function publicEcJwk(value, code) {
|
|
90
113
|
const jwk = (value ?? {});
|
|
@@ -125,9 +148,9 @@ export function createClientAuth(options) {
|
|
|
125
148
|
if (state.owner && (state.owner.namespace !== namespace || state.owner.clientId !== clientId || state.owner.localUserId !== localUserId))
|
|
126
149
|
throw new AuthFailure('owner_mismatch');
|
|
127
150
|
}
|
|
128
|
-
async function load(selection) { const state = await store.read(selection); checkOwner(state); return state; }
|
|
151
|
+
async function load(selection) { const state = await store.read(selection); checkOwner(state); upgradeClientAuthState(state); return state; }
|
|
129
152
|
function transaction(selection, change) {
|
|
130
|
-
return store.transaction(selection, state => { checkOwner(state); state.owner ??= owner; return change(state); });
|
|
153
|
+
return store.transaction(selection, state => { checkOwner(state); upgradeClientAuthState(state); state.owner ??= owner; return change(state); });
|
|
131
154
|
}
|
|
132
155
|
async function record(connectionId) {
|
|
133
156
|
const found = typeof connectionId === 'string' ? own((await load({ connectionId })).connections, connectionId) : undefined;
|
|
@@ -143,7 +166,7 @@ export function createClientAuth(options) {
|
|
|
143
166
|
async function verified(metadata) {
|
|
144
167
|
let fresh;
|
|
145
168
|
try {
|
|
146
|
-
fresh = await options.metadata.resolveCompany(new URL(metadata.url).
|
|
169
|
+
fresh = await options.metadata.resolveCompany(new URL(metadata.url).host);
|
|
147
170
|
}
|
|
148
171
|
catch {
|
|
149
172
|
throw new AuthFailure('discovery_failed');
|
|
@@ -211,14 +234,14 @@ export function createClientAuth(options) {
|
|
|
211
234
|
return signature;
|
|
212
235
|
});
|
|
213
236
|
}
|
|
214
|
-
async function proof(key, method, url, token) {
|
|
237
|
+
async function proof(key, method, url, token, browserCode) {
|
|
215
238
|
const htu = new URL(url);
|
|
216
239
|
htu.search = '';
|
|
217
240
|
htu.hash = '';
|
|
218
241
|
const payload = { jti: randomId(), htm: method, htu: htu.href, iat: Math.floor(now() / 1000) };
|
|
219
242
|
if (token !== undefined)
|
|
220
243
|
payload.ath = b64(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))));
|
|
221
|
-
const nonce = nonces.get(htu.origin);
|
|
244
|
+
const nonce = browserCode ?? nonces.get(htu.origin);
|
|
222
245
|
if (nonce !== undefined)
|
|
223
246
|
payload.nonce = nonce;
|
|
224
247
|
return compact({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk }, payload, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
|
|
@@ -298,6 +321,8 @@ export function createClientAuth(options) {
|
|
|
298
321
|
catch { /* reported below */ }
|
|
299
322
|
if (response.status === 200) {
|
|
300
323
|
try {
|
|
324
|
+
if (fields.grant_type === 'urn:ietf:params:oauth:grant-type:token-exchange' && (typeof json !== 'object' || json === null || !('issued_token_type' in json) || json.issued_token_type !== 'urn:ietf:params:oauth:token-type:access_token'))
|
|
325
|
+
throw 0;
|
|
301
326
|
return { token: parseTokenResponse(json), scheme };
|
|
302
327
|
}
|
|
303
328
|
catch {
|
|
@@ -333,30 +358,30 @@ export function createClientAuth(options) {
|
|
|
333
358
|
throw new AuthFailure('invalid_target');
|
|
334
359
|
}
|
|
335
360
|
}
|
|
336
|
-
//
|
|
361
|
+
// The proof key names the Session; signed-out tokens have no account scopes.
|
|
337
362
|
async function signedOutToken(metadata, userId, dpopKeyRef, sessionId, resource, anonymous = false) {
|
|
338
363
|
const fields = { grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' };
|
|
339
|
-
if (sessionId !== null)
|
|
340
|
-
fields.session_id = sessionId;
|
|
341
364
|
if (resource !== null)
|
|
342
365
|
fields.resource = resource;
|
|
343
366
|
const { token, scheme } = await tokenRequest(metadata, dpopKeyRef, fields, userId, anonymous);
|
|
344
367
|
if (token.token_type !== scheme)
|
|
345
368
|
throw new AuthFailure('unsupported_token_type');
|
|
346
|
-
if (token.
|
|
369
|
+
if ((token.scope ?? '') !== '' || token.refresh_token !== undefined)
|
|
347
370
|
throw new AuthFailure('invalid_response');
|
|
348
371
|
return token;
|
|
349
372
|
}
|
|
373
|
+
async function newSessionKey() {
|
|
374
|
+
// shortcut: SecretStore stores a private JWK; use a host signer when non-extractable key persistence is available.
|
|
375
|
+
const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']);
|
|
376
|
+
const { kty, crv, x, y, d } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
377
|
+
return secrets.stage(JSON.stringify({ kty, crv, x, y, d }));
|
|
378
|
+
}
|
|
350
379
|
async function identityFor(issuer, anonymous = false) {
|
|
351
380
|
if (anonymous && !identity)
|
|
352
381
|
throw new AuthFailure('anonymous_unavailable');
|
|
353
382
|
const existing = anonymous ? undefined : own((await load({ issuer })).identities, issuer);
|
|
354
383
|
if (existing)
|
|
355
384
|
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
385
|
let userId = randomId('usr_');
|
|
361
386
|
if (options.providerIdentity && !anonymous) {
|
|
362
387
|
let bound;
|
|
@@ -364,22 +389,36 @@ export function createClientAuth(options) {
|
|
|
364
389
|
bound = await options.providerIdentity.identity(issuer);
|
|
365
390
|
}
|
|
366
391
|
catch (error) {
|
|
367
|
-
await secrets.remove(dpopKeyRef);
|
|
368
392
|
throw providerFailure(error);
|
|
369
393
|
}
|
|
370
|
-
if (!bound || bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId)
|
|
371
|
-
await secrets.remove(dpopKeyRef);
|
|
394
|
+
if (!bound || bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId)
|
|
372
395
|
throw new AuthFailure('provider_invalid_response');
|
|
373
|
-
}
|
|
374
396
|
userId = bound.userId;
|
|
375
397
|
}
|
|
376
398
|
if (anonymous)
|
|
377
|
-
return { userId
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
399
|
+
return { userId };
|
|
400
|
+
return transaction({ issuer }, state => own(state.identities, issuer) ?? (state.identities[issuer] = { userId }));
|
|
401
|
+
}
|
|
402
|
+
async function freshSessionKey(connection, operationId) {
|
|
403
|
+
const ref = await newSessionKey();
|
|
404
|
+
const current = await commit({ connectionId: connection.id }, [ref], (state, drop) => {
|
|
405
|
+
const live = own(state.connections, connection.id);
|
|
406
|
+
if (live.generation !== connection.generation || live.dpopKeyRef !== connection.dpopKeyRef) {
|
|
407
|
+
drop.push(ref);
|
|
408
|
+
return live;
|
|
409
|
+
}
|
|
410
|
+
// Legacy keys may still be referenced by another old connection, so never delete them here.
|
|
411
|
+
if (!live.needsSessionKey)
|
|
412
|
+
drop.push(live.dpopKeyRef);
|
|
413
|
+
live.dpopKeyRef = ref;
|
|
414
|
+
delete live.needsSessionKey;
|
|
415
|
+
live.generation++;
|
|
416
|
+
switchSession(live, randomId('ses_'), drop);
|
|
417
|
+
if (operationId)
|
|
418
|
+
own(state.operations, operationId).connectionGeneration = live.generation;
|
|
419
|
+
return live;
|
|
420
|
+
});
|
|
421
|
+
return current;
|
|
383
422
|
}
|
|
384
423
|
// Commits a change that may stage or release secrets. When our own fence check rejects it, nothing references the
|
|
385
424
|
// staged secrets, so they are removed; references the committed change pushed to `drop` are removed after commit.
|
|
@@ -438,14 +477,19 @@ export function createClientAuth(options) {
|
|
|
438
477
|
async function signedOutRenewal(connection, resource) {
|
|
439
478
|
// Expiry counts from before dispatch so a slow response never extends the token's believed lifetime.
|
|
440
479
|
const issuedAt = now(), operationId = randomId('op_');
|
|
480
|
+
if (connection.needsSessionKey)
|
|
481
|
+
connection = await freshSessionKey(connection);
|
|
441
482
|
let token;
|
|
442
483
|
try {
|
|
443
484
|
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, connection.sessionId, resource, connection.anonymous);
|
|
444
485
|
}
|
|
445
486
|
catch (error) {
|
|
446
487
|
// 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 === '
|
|
488
|
+
if (!(error instanceof AuthFailure && error.code === 'invalid_grant'))
|
|
448
489
|
throw error;
|
|
490
|
+
await transaction({ connectionId: connection.id }, state => { const live = own(state.connections, connection.id); if (live.dpopKeyRef === connection.dpopKeyRef)
|
|
491
|
+
live.needsSessionKey = true; });
|
|
492
|
+
connection = await freshSessionKey(connection);
|
|
449
493
|
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, null, resource, connection.anonymous);
|
|
450
494
|
}
|
|
451
495
|
const secretRef = await stageSessionToken(token.access_token);
|
|
@@ -453,7 +497,6 @@ export function createClientAuth(options) {
|
|
|
453
497
|
const current = own(state.connections, connection.id);
|
|
454
498
|
if (!current || current.generation !== connection.generation || current.status !== 'signed_out' || current.sessionId !== connection.sessionId)
|
|
455
499
|
throw new AuthFailure('state_changed', connection.id);
|
|
456
|
-
switchSession(current, token.session_id, drop);
|
|
457
500
|
replaceSlot(current, resource, { secretRef, tokenType: token.token_type, dpopKeyRef: current.dpopKeyRef, scopes: [], expiresAt: issuedAt + token.expires_in * 1000, generation: current.generation }, drop);
|
|
458
501
|
return finish(state, renewOperation(state, operationId, current, null, issuedAt, resource, []), 'completed');
|
|
459
502
|
});
|
|
@@ -492,8 +535,9 @@ export function createClientAuth(options) {
|
|
|
492
535
|
}
|
|
493
536
|
if (refreshToken === null)
|
|
494
537
|
return await endRefresh(operationId, 'failed', 'missing_credential');
|
|
495
|
-
|
|
496
|
-
|
|
538
|
+
if (claim.connection.needsSessionKey)
|
|
539
|
+
claim.connection = await freshSessionKey(claim.connection, operationId);
|
|
540
|
+
const fields = { grant_type: 'refresh_token', refresh_token: refreshToken };
|
|
497
541
|
if (resource !== null)
|
|
498
542
|
fields.resource = resource;
|
|
499
543
|
if (scopes)
|
|
@@ -511,19 +555,29 @@ export function createClientAuth(options) {
|
|
|
511
555
|
return await endRefresh(operationId, 'failed', 'state_changed');
|
|
512
556
|
const issuedAt = now();
|
|
513
557
|
const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields, null);
|
|
514
|
-
let acquired
|
|
558
|
+
let acquired;
|
|
515
559
|
try {
|
|
516
560
|
try {
|
|
517
561
|
acquired = await send();
|
|
518
562
|
}
|
|
519
563
|
catch (error) {
|
|
520
|
-
|
|
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'))
|
|
564
|
+
if (!(error instanceof AuthFailure && error.code === 'invalid_grant'))
|
|
523
565
|
throw error;
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
566
|
+
// Diagnose with an assertion, never by resending a potentially consumed Account Token.
|
|
567
|
+
try {
|
|
568
|
+
await signedOutToken(claim.connection.metadata, claim.connection.userId, claim.connection.dpopKeyRef, claim.connection.sessionId, resource, claim.connection.anonymous);
|
|
569
|
+
}
|
|
570
|
+
catch (diagnosis) {
|
|
571
|
+
if (!(diagnosis instanceof AuthFailure && diagnosis.code === 'invalid_grant')) {
|
|
572
|
+
return await endRefresh(operationId, 'failed', diagnosis instanceof AuthFailure ? diagnosis.code : 'local_error');
|
|
573
|
+
}
|
|
574
|
+
await transaction({ connectionId: connection.id }, state => { const live = own(state.connections, connection.id); if (live.dpopKeyRef === claim.connection.dpopKeyRef)
|
|
575
|
+
live.needsSessionKey = true; });
|
|
576
|
+
claim.connection = await freshSessionKey(claim.connection, operationId);
|
|
577
|
+
acquired = await send();
|
|
578
|
+
}
|
|
579
|
+
if (!acquired)
|
|
580
|
+
return await endRefresh(operationId, 'failed', 'invalid_grant');
|
|
527
581
|
}
|
|
528
582
|
}
|
|
529
583
|
catch (error) {
|
|
@@ -536,11 +590,11 @@ export function createClientAuth(options) {
|
|
|
536
590
|
return await endRefresh(operationId, 'failed', code);
|
|
537
591
|
}
|
|
538
592
|
const { token, scheme } = acquired;
|
|
539
|
-
const granted = token.scope.split(' ').filter(Boolean);
|
|
593
|
+
const granted = (token.scope ?? claim.requested.join(' ')).split(' ').filter(Boolean);
|
|
540
594
|
let problem = null;
|
|
541
595
|
if (token.token_type !== scheme)
|
|
542
596
|
problem = 'unsupported_token_type';
|
|
543
|
-
else if (
|
|
597
|
+
else if (granted.some(scope => !claim.requested.includes(scope)))
|
|
544
598
|
problem = 'invalid_response';
|
|
545
599
|
let refreshRef = null, accessRef = null;
|
|
546
600
|
try {
|
|
@@ -560,7 +614,7 @@ export function createClientAuth(options) {
|
|
|
560
614
|
if (problem || accessRef === null)
|
|
561
615
|
return await endRefresh(operationId, 'failed', problem ?? 'storage_error', refreshRef);
|
|
562
616
|
const staged = {
|
|
563
|
-
accessRef, refreshRef, tokenType: token.token_type, sessionId:
|
|
617
|
+
accessRef, refreshRef, tokenType: token.token_type, sessionId: claim.connection.sessionId, scopes: granted, signedIn: true, expiresAt: issuedAt + token.expires_in * 1000,
|
|
564
618
|
refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000,
|
|
565
619
|
};
|
|
566
620
|
// Its own commit, so a crash before activation leaves the new Account Token referenced for recover (interface 4).
|
|
@@ -663,6 +717,56 @@ export function createClientAuth(options) {
|
|
|
663
717
|
if (wait > 0n)
|
|
664
718
|
throw new AuthFailure('rate_limited', connection.id, Number(wait), connection.notBefore);
|
|
665
719
|
}
|
|
720
|
+
async function exchangeBearer(connection, resource, scopes) {
|
|
721
|
+
if (!connection.metadata.authorization?.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:token-exchange'))
|
|
722
|
+
throw new AuthFailure('invalid_target', connection.id);
|
|
723
|
+
let subject = await credentialFor(connection.id, null, null);
|
|
724
|
+
connection = await currentConnection(connection.id);
|
|
725
|
+
const granted = publicConnection(await load({ connectionId: connection.id }), connection).scopes;
|
|
726
|
+
const requested = scopes ?? granted;
|
|
727
|
+
if (!requested.every(scope => granted.includes(scope)))
|
|
728
|
+
throw new AuthFailure('invalid_scope', connection.id);
|
|
729
|
+
if (!requested.every(scope => subject.scopes.includes(scope)))
|
|
730
|
+
subject = await renewShared(connection.id, null);
|
|
731
|
+
for (let attempt = 0;; attempt++) {
|
|
732
|
+
let value = await secrets.read(subject.secretRef);
|
|
733
|
+
if (!value) {
|
|
734
|
+
subject = await credentialFor(connection.id, null, subject.secretRef);
|
|
735
|
+
value = await secrets.read(subject.secretRef);
|
|
736
|
+
}
|
|
737
|
+
if (!value || subject.tokenType !== 'DPoP')
|
|
738
|
+
throw new AuthFailure('invalid_response', connection.id);
|
|
739
|
+
connection = await currentConnection(connection.id);
|
|
740
|
+
if (!requested.every(scope => subject.scopes.includes(scope)))
|
|
741
|
+
throw new AuthFailure('invalid_scope', connection.id);
|
|
742
|
+
const fields = { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: value, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', resource };
|
|
743
|
+
if (scopes !== null)
|
|
744
|
+
fields.scope = scopes.join(' ');
|
|
745
|
+
const issuedAt = now();
|
|
746
|
+
let token;
|
|
747
|
+
try {
|
|
748
|
+
({ token } = await tokenRequest(connection.metadata, connection.dpopKeyRef, fields, null, connection.anonymous));
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
if (!(error instanceof AuthFailure && error.code === 'invalid_grant') || attempt !== 0)
|
|
752
|
+
throw error;
|
|
753
|
+
// A definite subject rejection uses normal renewal/diagnosis; ambiguous exchanges never replay credentials.
|
|
754
|
+
subject = await credentialFor(connection.id, null, subject.secretRef);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const allowed = token.scope === undefined ? requested : token.scope.split(' ').filter(Boolean);
|
|
758
|
+
if (token.token_type !== 'Bearer' || token.refresh_token !== undefined || !allowed.every(scope => requested.includes(scope)))
|
|
759
|
+
throw new AuthFailure('invalid_response', connection.id);
|
|
760
|
+
const secretRef = await stageSessionToken(token.access_token);
|
|
761
|
+
return commit({ connectionId: connection.id }, [secretRef], (state, drop) => {
|
|
762
|
+
const live = own(state.connections, connection.id);
|
|
763
|
+
if (!live || live.generation !== connection.generation || live.status !== connection.status || live.sessionId !== connection.sessionId || live.dpopKeyRef !== connection.dpopKeyRef || own(live.slots, slotKey(live.sessionId, null))?.secretRef !== subject.secretRef)
|
|
764
|
+
throw new AuthFailure('state_changed', connection.id);
|
|
765
|
+
replaceSlot(live, resource, { secretRef, tokenType: 'Bearer', dpopKeyRef: live.dpopKeyRef, scopes: allowed, expiresAt: issuedAt + token.expires_in * 1000, generation: live.generation }, drop);
|
|
766
|
+
return finish(state, renewOperation(state, randomId('op_'), live, null, issuedAt, resource, allowed), 'completed');
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
666
770
|
// Every token-request path (internal refresh and public renew) goes through here, so all honour Retry-After.
|
|
667
771
|
async function renewal(connection, resource, scopes) {
|
|
668
772
|
// Local failures first: a wait is only meaningful for a connection that can still send a token request.
|
|
@@ -673,7 +777,10 @@ export function createClientAuth(options) {
|
|
|
673
777
|
checkCooldown(await record(connection.id));
|
|
674
778
|
let outcome;
|
|
675
779
|
try {
|
|
676
|
-
|
|
780
|
+
if (resource !== null && await resourceScheme(connection.metadata, resource) === 'Bearer')
|
|
781
|
+
outcome = await exchangeBearer(connection, resource, scopes);
|
|
782
|
+
else
|
|
783
|
+
outcome = connection.status === 'signed_out' ? await signedOutRenewal(connection, resource) : await refresh(connection, resource, scopes);
|
|
677
784
|
}
|
|
678
785
|
catch (error) {
|
|
679
786
|
if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
|
|
@@ -773,7 +880,7 @@ export function createClientAuth(options) {
|
|
|
773
880
|
return { issuer: connection.issuer, endpoint: entry.endpoint, ...(entry.resource === undefined ? {} : { resource: entry.resource }) };
|
|
774
881
|
}
|
|
775
882
|
function outcomeOf(state, operation) {
|
|
776
|
-
return operation.result ?? { operationId: operation.id, status:
|
|
883
|
+
return operation.result ?? { operationId: operation.id, status: operation.status, connection: publicConnection(state, own(state.connections, operation.connectionId)), ...(operation.phase === 'terminal' ? {} : { nextAction: 'wait' }) };
|
|
777
884
|
}
|
|
778
885
|
// The terminal result is recorded once and returned verbatim to duplicate callbacks.
|
|
779
886
|
function finish(state, operation, status, extra = {}) {
|
|
@@ -843,7 +950,7 @@ export function createClientAuth(options) {
|
|
|
843
950
|
}
|
|
844
951
|
async function acceptSignIn(operationId, token, issuedAt) {
|
|
845
952
|
const snapshot = await load({ operationId }), operation = own(snapshot.operations, operationId);
|
|
846
|
-
const granted = token.scope.split(' ').filter(Boolean);
|
|
953
|
+
const granted = (token.scope ?? operation.scopes.join(' ')).split(' ').filter(Boolean);
|
|
847
954
|
let refreshRef = null;
|
|
848
955
|
try {
|
|
849
956
|
if (token.refresh_token !== undefined)
|
|
@@ -852,7 +959,7 @@ export function createClientAuth(options) {
|
|
|
852
959
|
catch {
|
|
853
960
|
return await terminate(operationId, 'uncertain', 'storage_error');
|
|
854
961
|
}
|
|
855
|
-
if (token.token_type !== 'DPoP' ||
|
|
962
|
+
if (token.token_type !== 'DPoP' || !refreshRef ||
|
|
856
963
|
!granted.length || new Set(granted).size !== granted.length || granted.some(scope => !operation.scopes.includes(scope))) {
|
|
857
964
|
return commit({ operationId }, [], (state, drop) => {
|
|
858
965
|
const current = own(state.operations, operationId);
|
|
@@ -863,7 +970,7 @@ export function createClientAuth(options) {
|
|
|
863
970
|
});
|
|
864
971
|
}
|
|
865
972
|
const accessRef = await stageSessionToken(token.access_token);
|
|
866
|
-
const staged = { accessRef, refreshRef, sessionId:
|
|
973
|
+
const staged = { accessRef, refreshRef, sessionId: operation.signInSessionId, scopes: granted, signedIn: true,
|
|
867
974
|
expiresAt: issuedAt + token.expires_in * 1000, refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000 };
|
|
868
975
|
// Stage before activation: recovery can finish locally without exchanging a consumed credential again.
|
|
869
976
|
const recorded = await transaction({ operationId }, state => {
|
|
@@ -887,6 +994,8 @@ export function createClientAuth(options) {
|
|
|
887
994
|
}
|
|
888
995
|
async function claimSignIn(operationId, executionOwner, preSend = false) {
|
|
889
996
|
const snapshot = await load({ operationId }), operation = own(snapshot.operations, operationId);
|
|
997
|
+
if (operation.phase === 'prepared' && !operation.signInSessionKey)
|
|
998
|
+
return terminate(operationId, 'failed', 'restart_required');
|
|
890
999
|
await currentConnection(operation.connectionId);
|
|
891
1000
|
return await transaction({ operationId }, state => {
|
|
892
1001
|
const current = own(state.operations, operationId);
|
|
@@ -943,7 +1052,7 @@ export function createClientAuth(options) {
|
|
|
943
1052
|
throw new AuthFailure('invalid_response');
|
|
944
1053
|
}
|
|
945
1054
|
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.
|
|
1055
|
+
const hosts = [new URL(connection.issuer).hostname, ...metadata.authorization.protected_resources.map(resource => new URL(resource).hostname)];
|
|
947
1056
|
if (typeof device_code !== 'string' || !device_code || typeof user_code !== 'string' || !user_code || typeof verification_uri !== 'string' || !allowedHost(verification_uri, hosts) ||
|
|
948
1057
|
(verification_uri_complete !== undefined && (typeof verification_uri_complete !== 'string' || !allowedHost(verification_uri_complete, hosts))) ||
|
|
949
1058
|
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,7 +1103,7 @@ export function createClientAuth(options) {
|
|
|
994
1103
|
const { operation, connection } = claim, code = await secrets.read(operation.device.codeRef);
|
|
995
1104
|
if (!code)
|
|
996
1105
|
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
|
|
1106
|
+
const issuedAt = now(), fields = { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code };
|
|
998
1107
|
if (operation.resource)
|
|
999
1108
|
fields.resource = operation.resource;
|
|
1000
1109
|
let token;
|
|
@@ -1222,18 +1331,20 @@ export function createClientAuth(options) {
|
|
|
1222
1331
|
throw new AuthFailure('no_auth');
|
|
1223
1332
|
const issuer = metadata.authorization.issuer;
|
|
1224
1333
|
const anonymous = input?.anonymous === true;
|
|
1225
|
-
const { userId
|
|
1334
|
+
const { userId } = await identityFor(issuer, anonymous);
|
|
1335
|
+
const epoch = (await load({ issuer })).epoch;
|
|
1336
|
+
const dpopKeyRef = await newSessionKey();
|
|
1226
1337
|
const createdAt = now();
|
|
1227
1338
|
const token = await signedOutToken(metadata, userId, dpopKeyRef, null, null, anonymous);
|
|
1228
1339
|
const secretRef = await stageSessionToken(token.access_token);
|
|
1229
|
-
const id = randomId('con_'), operationId = randomId('op_');
|
|
1230
|
-
return commit({ issuer, history: true }, [secretRef], state => {
|
|
1340
|
+
const id = randomId('con_'), operationId = randomId('op_'), sessionId = randomId('ses_');
|
|
1341
|
+
return commit({ issuer, history: true }, [secretRef, dpopKeyRef], state => {
|
|
1231
1342
|
// A reset or competing identity since we signed means this session belongs to no current identity.
|
|
1232
|
-
if (!anonymous && own(state.identities, issuer)?.
|
|
1343
|
+
if (state.epoch !== epoch || (!anonymous && own(state.identities, issuer)?.userId !== userId))
|
|
1233
1344
|
throw new AuthFailure('state_changed');
|
|
1234
1345
|
const connection = {
|
|
1235
|
-
id, domain, issuer, metadata, userId, dpopKeyRef, sessionId
|
|
1236
|
-
slots: { [slotKey(
|
|
1346
|
+
id, domain, issuer, metadata, userId, dpopKeyRef, sessionId, generation: 1, status: 'signed_out', grantId: null,
|
|
1347
|
+
slots: { [slotKey(sessionId, null)]: { secretRef, tokenType: 'DPoP', dpopKeyRef, scopes: [], expiresAt: createdAt + token.expires_in * 1000, generation: 1 } },
|
|
1237
1348
|
...(anonymous ? { anonymous: true } : {}),
|
|
1238
1349
|
};
|
|
1239
1350
|
state.connections[id] = connection;
|
|
@@ -1263,13 +1374,10 @@ export function createClientAuth(options) {
|
|
|
1263
1374
|
const connection = await currentConnection(connectionId), state = await load({ connectionId });
|
|
1264
1375
|
const grantedScopes = publicConnection(state, connection).scopes;
|
|
1265
1376
|
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
|
-
});
|
|
1377
|
+
const methods = signInMethods(connection.metadata);
|
|
1270
1378
|
const required = [...new Set(requiredScopes)], missingScopes = required.filter(scope => !grantedScopes.includes(scope));
|
|
1271
1379
|
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 } };
|
|
1380
|
+
methods, scopeDescriptions: { ...connection.metadata.document.auth?.scopes }, organization: { ...connection.metadata.document.organization } };
|
|
1273
1381
|
},
|
|
1274
1382
|
async beginSignIn(connectionId, input) {
|
|
1275
1383
|
let found = await currentConnection(connectionId);
|
|
@@ -1282,7 +1390,7 @@ export function createClientAuth(options) {
|
|
|
1282
1390
|
const method = input?.method ?? 'direct';
|
|
1283
1391
|
if (!['direct', 'device', 'mediated'].includes(method))
|
|
1284
1392
|
throw new AuthFailure('unsupported_method', connectionId);
|
|
1285
|
-
const supported = found.metadata.
|
|
1393
|
+
const supported = signInMethods(found.metadata).find(item => item.method === method);
|
|
1286
1394
|
const authorizationEndpoint = found.metadata.authorization?.authorization_endpoint;
|
|
1287
1395
|
if (!supported || (method === 'direct' && !authorizationEndpoint) || (method === 'device' && !found.metadata.authorization?.device_authorization_endpoint))
|
|
1288
1396
|
throw new AuthFailure(`${method}_unavailable`, connectionId);
|
|
@@ -1297,7 +1405,7 @@ export function createClientAuth(options) {
|
|
|
1297
1405
|
checkCooldown(found);
|
|
1298
1406
|
if (method === 'mediated' && !options.trustedInput)
|
|
1299
1407
|
throw new AuthFailure('trusted_input_required', connectionId);
|
|
1300
|
-
if (method === 'direct' && (typeof redirectUri !== 'string' || !
|
|
1408
|
+
if (method === 'direct' && (typeof redirectUri !== 'string' || !registeredRedirect(redirectUri, options.redirectUris, clientId) || CALLBACK_PARAMS.some(name => new URL(redirectUri).searchParams.has(name))))
|
|
1301
1409
|
throw new AuthFailure('invalid_redirect', connectionId);
|
|
1302
1410
|
// The host derives this from its authenticated browser session; a short value would be guessable.
|
|
1303
1411
|
if (method === 'direct' && (typeof browserBinding !== 'string' || browserBinding.length < 22))
|
|
@@ -1306,6 +1414,10 @@ export function createClientAuth(options) {
|
|
|
1306
1414
|
const resource = found.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy')?.resource ?? null;
|
|
1307
1415
|
if (await resourceScheme(found.metadata, resource) !== 'DPoP')
|
|
1308
1416
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1417
|
+
if (found.needsSessionKey && found.status === 'uncertain') {
|
|
1418
|
+
found = await freshSessionKey(found);
|
|
1419
|
+
await signedOutToken(found.metadata, found.userId, found.dpopKeyRef, found.sessionId, resource, found.anonymous);
|
|
1420
|
+
}
|
|
1309
1421
|
// The code exchange names this Session (spec 4.5). A stale credential means it may have ended, so renew first,
|
|
1310
1422
|
// which replaces an ended Session; a failed renewal leaves the Session as it was for the issuer to judge.
|
|
1311
1423
|
const renew = () => credentialFor(connectionId, resource, null).catch(error => { if (!(error instanceof AuthFailure))
|
|
@@ -1323,6 +1435,7 @@ export function createClientAuth(options) {
|
|
|
1323
1435
|
throw new AuthFailure('sign_in_unavailable', connectionId);
|
|
1324
1436
|
const state = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1325
1437
|
const verifier = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1438
|
+
const sessionKey = (await loadDpopKey(connection.dpopKeyRef)).thumbprint;
|
|
1326
1439
|
const verifierRef = method === 'direct' ? await secrets.stage(verifier) : null;
|
|
1327
1440
|
const createdAt = now(), operationId = randomId('op_');
|
|
1328
1441
|
const outcome = await commit({ connectionId, history: true }, [verifierRef], draft => {
|
|
@@ -1333,7 +1446,7 @@ export function createClientAuth(options) {
|
|
|
1333
1446
|
const operation = draft.operations[operationId] = {
|
|
1334
1447
|
epoch: draft.epoch, id: operationId, kind: 'sign_in', connectionId, connectionGeneration: current.generation, grantId: current.grantId, grantGeneration: null,
|
|
1335
1448
|
phase: 'prepared', status: 'pending', createdAt, expiresAt: createdAt + SIGN_IN_MS, resource, scopes: [...scopes],
|
|
1336
|
-
method, signInSessionId: connection.sessionId, requiredExistingScopes: requiredExistingScopes ?? requirement.grantedScopes,
|
|
1449
|
+
method, signInSessionId: connection.sessionId, signInSessionKey: sessionKey, requiredExistingScopes: requiredExistingScopes ?? requirement.grantedScopes,
|
|
1337
1450
|
direct: method === 'direct' ? { stateHash: sha256(state), verifierRef: verifierRef, browserHash: sha256(browserBinding), redirectUri: redirectUri, issuer: current.issuer, clientId, sessionId: connection.sessionId } : null,
|
|
1338
1451
|
staged: null, result: null,
|
|
1339
1452
|
};
|
|
@@ -1344,7 +1457,7 @@ export function createClientAuth(options) {
|
|
|
1344
1457
|
if (method === 'mediated')
|
|
1345
1458
|
return { outcome: await continueMediated(outcome.operationId) };
|
|
1346
1459
|
const url = new URL(authorizationEndpoint);
|
|
1347
|
-
const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256' };
|
|
1460
|
+
const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256', dpop_jkt: sessionKey };
|
|
1348
1461
|
for (const [name, value] of Object.entries(query))
|
|
1349
1462
|
url.searchParams.set(name, value);
|
|
1350
1463
|
if (resource !== null)
|
|
@@ -1366,6 +1479,8 @@ export function createClientAuth(options) {
|
|
|
1366
1479
|
if (!found || !direct || direct.browserHash !== browserHash || !redirectMatches(base, new URL(direct.redirectUri)) || params.iss !== direct.issuer)
|
|
1367
1480
|
throw new AuthFailure('invalid_callback');
|
|
1368
1481
|
checkCooldown(await record(found.connectionId));
|
|
1482
|
+
if (found.phase === 'prepared' && !found.signInSessionKey)
|
|
1483
|
+
return terminate(found.id, 'failed', 'restart_required');
|
|
1369
1484
|
const operationId = found.id;
|
|
1370
1485
|
const verifier = found.phase === 'prepared' && params.code !== undefined ? await secrets.read(direct.verifierRef) : null;
|
|
1371
1486
|
const at = now();
|
|
@@ -1377,7 +1492,7 @@ export function createClientAuth(options) {
|
|
|
1377
1492
|
if (operation.phase !== 'prepared')
|
|
1378
1493
|
return outcomeOf(state, operation);
|
|
1379
1494
|
const connection = own(state.connections, operation.connectionId);
|
|
1380
|
-
if (connection.generation !== operation.connectionGeneration)
|
|
1495
|
+
if (connection.generation !== operation.connectionGeneration || connection.sessionId !== operation.signInSessionId)
|
|
1381
1496
|
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
1382
1497
|
if (at >= operation.expiresAt)
|
|
1383
1498
|
return finish(state, operation, 'expired');
|
|
@@ -1395,7 +1510,7 @@ export function createClientAuth(options) {
|
|
|
1395
1510
|
return claim;
|
|
1396
1511
|
}
|
|
1397
1512
|
const { connection, operation } = claim;
|
|
1398
|
-
const fields = { grant_type: 'authorization_code', code: params.code, redirect_uri: direct.redirectUri, code_verifier: verifier
|
|
1513
|
+
const fields = { grant_type: 'authorization_code', code: params.code, redirect_uri: direct.redirectUri, code_verifier: verifier };
|
|
1399
1514
|
if (operation.resource !== null)
|
|
1400
1515
|
fields.resource = operation.resource;
|
|
1401
1516
|
const issuedAt = now();
|
|
@@ -1477,6 +1592,8 @@ export function createClientAuth(options) {
|
|
|
1477
1592
|
result = activateSignIn(state, operation, drop);
|
|
1478
1593
|
else if (operation.phase !== 'prepared')
|
|
1479
1594
|
result = finish(state, operation, 'uncertain', { error: 'lost_response' });
|
|
1595
|
+
else if (operation.kind === 'sign_in' && !operation.signInSessionKey)
|
|
1596
|
+
result = finish(state, operation, 'failed', { error: 'restart_required' });
|
|
1480
1597
|
else if (at >= operation.expiresAt)
|
|
1481
1598
|
result = finish(state, operation, 'expired');
|
|
1482
1599
|
else
|
|
@@ -1550,7 +1667,7 @@ export function createClientAuth(options) {
|
|
|
1550
1667
|
},
|
|
1551
1668
|
async joinBrowser(connectionId, returnTo, controller) {
|
|
1552
1669
|
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')
|
|
1670
|
+
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
1671
|
throw new AuthFailure('invalid_browser', connectionId);
|
|
1555
1672
|
try {
|
|
1556
1673
|
browserDestination(returnTo, connection.metadata.document.organization.domain);
|
|
@@ -1559,25 +1676,21 @@ export function createClientAuth(options) {
|
|
|
1559
1676
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1560
1677
|
}
|
|
1561
1678
|
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
1679
|
try {
|
|
1576
|
-
|
|
1577
|
-
|
|
1680
|
+
const { code } = await controller.loadJoinForm(endpoint);
|
|
1681
|
+
if (typeof code !== 'string' || !code || code.length > 256)
|
|
1682
|
+
throw new Error('code');
|
|
1683
|
+
const live = await currentConnection(connectionId);
|
|
1684
|
+
if (live.generation !== connection.generation || live.dpopKeyRef !== connection.dpopKeyRef || live.status !== connection.status)
|
|
1685
|
+
throw new Error('state changed');
|
|
1686
|
+
await controller.postForm({ url: endpoint, fields: { code, proof: await proof(key, 'POST', endpoint, undefined, code) }, validateRedirect(status, location) {
|
|
1687
|
+
if (status !== 303)
|
|
1578
1688
|
throw new AuthFailure('invalid_browser_redirect', connectionId);
|
|
1579
|
-
|
|
1689
|
+
const destination = new URL(location, endpoint).href;
|
|
1690
|
+
browserDestination(destination, connection.metadata.document.organization.domain);
|
|
1691
|
+
return destination;
|
|
1580
1692
|
} });
|
|
1693
|
+
await controller.navigate(returnTo);
|
|
1581
1694
|
}
|
|
1582
1695
|
catch {
|
|
1583
1696
|
throw new AuthFailure('browser_failed', connectionId);
|
|
@@ -1589,8 +1702,23 @@ export function createClientAuth(options) {
|
|
|
1589
1702
|
if (!endpoint)
|
|
1590
1703
|
throw new AuthFailure('feedback_unavailable', connectionId);
|
|
1591
1704
|
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
|
-
|
|
1705
|
+
try {
|
|
1706
|
+
const text = await response.text();
|
|
1707
|
+
if (response.status === 202 && text === '')
|
|
1708
|
+
return;
|
|
1709
|
+
if (response.status === 200)
|
|
1710
|
+
return parseAlternative(parseJson(text), connection.metadata.document);
|
|
1711
|
+
}
|
|
1712
|
+
catch { /* Do not expose company response/parser details. */ }
|
|
1713
|
+
throw new AuthFailure('feedback_failed', connectionId);
|
|
1714
|
+
},
|
|
1715
|
+
async excludedTasks(connectionId) {
|
|
1716
|
+
const connection = await currentConnection(connectionId);
|
|
1717
|
+
if (!connection.metadata.document.excluded_tasks)
|
|
1718
|
+
return [];
|
|
1719
|
+
if (!options.metadata.resolveExcludedTasks)
|
|
1720
|
+
throw new AuthFailure('excluded_tasks_unavailable', connectionId);
|
|
1721
|
+
return options.metadata.resolveExcludedTasks(connection.metadata.document);
|
|
1594
1722
|
},
|
|
1595
1723
|
async authorizedFetch(connectionId, request, target, requiredScopes = []) {
|
|
1596
1724
|
const connection = await currentConnection(connectionId), expected = targetOf(connection);
|