@poppykit/client 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/API.md +9 -1
  2. package/AUTH.md +97 -90
  3. package/CHANGELOG.md +20 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +19 -3
  7. package/INTEGRATIONS.md +35 -23
  8. package/OPERATIONS.md +120 -0
  9. package/PROVIDER.md +20 -12
  10. package/README.md +6 -17
  11. package/RELEASING.md +2 -0
  12. package/SECURITY.md +2 -0
  13. package/STORAGE.md +20 -7
  14. package/dist/adapter-contract.d.ts +6 -0
  15. package/dist/adapter-contract.js +30 -0
  16. package/dist/auth/challenge.d.ts +2 -0
  17. package/dist/auth/challenge.js +8 -8
  18. package/dist/auth/client-auth.d.ts +20 -7
  19. package/dist/auth/client-auth.js +386 -334
  20. package/dist/auth/sqlite-store.js +13 -4
  21. package/dist/auth/store.d.ts +6 -1
  22. package/dist/conversation.d.ts +6 -0
  23. package/dist/conversation.js +18 -14
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +2 -0
  26. package/dist/integrations.d.ts +11 -4
  27. package/dist/integrations.js +30 -38
  28. package/dist/network/discovery.d.ts +31 -13
  29. package/dist/network/discovery.js +103 -66
  30. package/dist/operations.d.ts +103 -0
  31. package/dist/operations.js +261 -0
  32. package/dist/postgres-runtime.js +12 -4
  33. package/dist/postgres.d.ts +11 -0
  34. package/dist/postgres.js +40 -3
  35. package/dist/protocol/operations.d.ts +51 -0
  36. package/dist/protocol/operations.js +98 -0
  37. package/dist/protocol/session.d.ts +28 -0
  38. package/dist/protocol/session.js +93 -0
  39. package/dist/protocol/sse.d.ts +1 -0
  40. package/dist/protocol/sse.js +1 -1
  41. package/dist/protocol/wire.d.ts +8 -4
  42. package/dist/protocol/wire.js +37 -15
  43. package/dist/provider-postgres.js +3 -3
  44. package/dist/provider-storage.js +4 -4
  45. package/dist/provider.d.ts +26 -3
  46. package/dist/provider.js +94 -13
  47. package/dist/sqlite.d.ts +12 -0
  48. package/dist/sqlite.js +42 -0
  49. package/examples/operations.mjs +25 -0
  50. package/examples/quickstart.mjs +1 -0
  51. package/package.json +5 -2
@@ -1,12 +1,13 @@
1
+ import { supportsOperations } from '../protocol/operations.js';
1
2
  import { parseAlternative } from '../network/tasks.js';
2
3
  import { registeredRedirect } from '../network/redirect.js';
3
4
  import { acquireOwner, releaseOwner, localOwnerAlive } from './ownership.js';
4
5
  import { createHash } from 'node:crypto';
5
6
  import { calculateJwkThumbprint } from 'jose';
6
7
  import { parseJson, parseProtocolError, parseTokenResponse, retryAfterMilliseconds } from '../protocol/wire.js';
7
- import { validateClientId, signInMethods } from '../network/discovery.js';
8
+ import { validateClientId, signInMethods, companyIdentifier, mediatedSignIn, MEDIATED_GRANT } from '../network/discovery.js';
8
9
  import { dpopChallenge, schemeChallenge } from './challenge.js';
9
- import { resolveApiTarget, mcpScheme, browserDestination, validateFeedback } from '../integrations.js';
10
+ import { resolveApiTarget, serviceAuthorization, browserDestination, validateFeedback } from '../integrations.js';
10
11
  export class AuthFailure extends Error {
11
12
  code;
12
13
  connectionId;
@@ -21,6 +22,13 @@ export class AuthFailure extends Error {
21
22
  this.name = 'AuthFailure';
22
23
  }
23
24
  }
25
+ class MediatedCodeRequired extends AuthFailure {
26
+ challenge;
27
+ constructor(challenge) {
28
+ super('code_required');
29
+ this.challenge = challenge;
30
+ }
31
+ }
24
32
  const EC = { name: 'ECDSA', namedCurve: 'P-256' };
25
33
  const ES256 = { name: 'ECDSA', hash: 'SHA-256' };
26
34
  const SKEW_MS = 30_000;
@@ -86,13 +94,13 @@ function parseCallback(callbackUrl) {
86
94
  return { params, base };
87
95
  }
88
96
  export function initialClientAuthState() {
89
- return { version: 2, epoch: randomId(), owner: null, identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
97
+ return { version: 3, epoch: randomId(), owner: null, identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
90
98
  }
91
99
  /** Upgrade a complete namespace under its writer lock; dispatched/staged work still goes through recover(). */
92
100
  export function upgradeClientAuthState(state) {
93
- if (state.version === 2)
101
+ if (state.version === 3)
94
102
  return;
95
- if (state.version !== 1)
103
+ if (state.version !== 1 && state.version !== 2)
96
104
  throw new Error('unsupported auth state version');
97
105
  for (const connection of Object.values(state.connections)) {
98
106
  connection.needsSessionKey = true;
@@ -102,12 +110,15 @@ export function upgradeClientAuthState(state) {
102
110
  if (operation.phase === 'prepared') {
103
111
  operation.phase = 'terminal';
104
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] : []));
105
116
  operation.result = null;
106
117
  const grant = operation.grantId ? own(state.grants, operation.grantId) : undefined;
107
118
  if (grant?.operationId === operation.id)
108
119
  grant.operationId = null;
109
120
  }
110
- state.version = 2;
121
+ state.version = 3;
111
122
  }
112
123
  function publicEcJwk(value, code) {
113
124
  const jwk = (value ?? {});
@@ -161,7 +172,7 @@ export function createClientAuth(options) {
161
172
  function destinations(metadata) {
162
173
  const auth = metadata.authorization;
163
174
  return JSON.stringify([auth?.issuer, auth?.token_endpoint, auth?.revocation_endpoint, auth?.authorization_endpoint, auth?.device_authorization_endpoint,
164
- metadata.document.auth?.mediated?.endpoint, metadata.document.agent?.protocols, metadata.document.apis, metadata.document.web, metadata.document.feedback]);
175
+ metadata.document.extensions?.['mediated-sign-in'], metadata.document.agent?.protocols, metadata.document.apis, metadata.document.web, metadata.document.feedback]);
165
176
  }
166
177
  async function verified(metadata) {
167
178
  let fresh;
@@ -246,6 +257,100 @@ export function createClientAuth(options) {
246
257
  payload.nonce = nonce;
247
258
  return compact({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk }, payload, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
248
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
+ }
249
354
  function rememberNonce(url, response) {
250
355
  const nonce = response.headers.get('dpop-nonce');
251
356
  if (nonce)
@@ -257,7 +362,7 @@ export function createClientAuth(options) {
257
362
  }
258
363
  async function assertion(purpose, issuer, key, sub = clientId, operationId = randomId('op_')) {
259
364
  if (!options.assertions)
260
- return signIdentity({ typ: purpose === 'session' ? 'JWT' : 'client-authentication+jwt' }, assertionClaims(sub, issuer), key.thumbprint);
365
+ return signIdentity({ typ: 'client-authentication+jwt' }, assertionClaims(sub, issuer), key.thumbprint);
261
366
  let result;
262
367
  try {
263
368
  result = await options.assertions.assert({ operationId, issuer, purpose });
@@ -285,21 +390,25 @@ export function createClientAuth(options) {
285
390
  : delay === undefined ? undefined : String(Math.floor(now() + delay));
286
391
  return new AuthFailure(code, undefined, delay, deadline);
287
392
  }
288
- // Token endpoint request with client assertion and fresh DPoP proof (spec 4.1-4.3); sessionSub adds a session assertion.
393
+ // OAuth requests use client authentication and optional DPoP, independently of Sessions.
289
394
  // Throws transport_error only when the request may have reached the issuer without a readable answer.
290
- async function tokenRequest(metadata, dpopKeyRef, fields, sessionSub, localIdentity = false) {
395
+ async function tokenRequest(metadata, dpopKeyRef, fields, operationId) {
291
396
  const authorization = (await verified(metadata)).authorization;
292
397
  const endpoint = authorization.token_endpoint;
293
398
  const key = await loadDpopKey(dpopKeyRef);
294
399
  const scheme = await resourceScheme(metadata, fields.resource ?? null);
295
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();
296
404
  const body = new URLSearchParams(fields);
297
- if (sessionSub !== null)
298
- body.set('assertion', localIdentity ? await signIdentity({ typ: 'JWT' }, assertionClaims(sessionSub, authorization.issuer), key.thumbprint) : await assertion('session', authorization.issuer, key, sessionSub));
299
405
  body.set('client_id', clientId);
300
406
  body.set('client_assertion_type', CLIENT_ASSERTION);
301
- body.set('client_assertion', localIdentity ? await signIdentity({ typ: 'client-authentication+jwt' }, assertionClaims(clientId, authorization.issuer), key.thumbprint) : await assertion('client-authentication', authorization.issuer, key));
302
- const request = new Request(endpoint, { method: 'POST', body, headers: { accept: 'application/json', dpop: await proof(key, 'POST', endpoint) } });
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 });
303
412
  let response, text;
304
413
  try {
305
414
  response = await transport.fetch(request);
@@ -321,14 +430,21 @@ export function createClientAuth(options) {
321
430
  catch { /* reported below */ }
322
431
  if (response.status === 200) {
323
432
  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'))
433
+ const token = parseTokenResponse(json);
434
+ if (token.token_type === 'DPoP' && !headers.has('dpop'))
325
435
  throw 0;
326
- return { token: parseTokenResponse(json), scheme };
436
+ return { token, scheme: token.token_type };
327
437
  }
328
438
  catch {
329
439
  throw new AuthFailure('invalid_response');
330
440
  }
331
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
+ }
332
448
  let error = response.status === 429 ? 'rate_limited' : 'token_error';
333
449
  try {
334
450
  if (response.status !== 429)
@@ -344,32 +460,14 @@ export function createClientAuth(options) {
344
460
  async function resourceScheme(metadata, resource) {
345
461
  const mcp = metadata.document.apis?.find(api => api.type === 'mcp' && api.url === resource);
346
462
  if (!mcp)
347
- return 'DPoP';
348
- if (mcp.resource !== undefined && mcp.resource !== mcp.url)
349
- throw new AuthFailure('invalid_target');
463
+ return metadata.authorization?.dpop_signing_alg_values_supported?.includes('ES256') ? 'DPoP' : 'Bearer';
350
464
  try {
351
- const scheme = await mcpScheme(transport, metadata.authorization.issuer, mcp.url);
352
- // Bearer MCP tokens cannot authorize a conversation, even when discovery reuses their resource identifier.
353
- if (scheme === 'Bearer' && metadata.document.agent?.protocols.some(protocol => protocol.resource === resource))
354
- throw 0;
355
- return scheme;
465
+ return (await serviceAuthorization(metadata, options.metadata, transport, mcp.url, { mcp: true })).scheme;
356
466
  }
357
467
  catch {
358
468
  throw new AuthFailure('invalid_target');
359
469
  }
360
470
  }
361
- // The proof key names the Session; signed-out tokens have no account scopes.
362
- async function signedOutToken(metadata, userId, dpopKeyRef, sessionId, resource, anonymous = false) {
363
- const fields = { grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' };
364
- if (resource !== null)
365
- fields.resource = resource;
366
- const { token, scheme } = await tokenRequest(metadata, dpopKeyRef, fields, userId, anonymous);
367
- if (token.token_type !== scheme)
368
- throw new AuthFailure('unsupported_token_type');
369
- if ((token.scope ?? '') !== '' || token.refresh_token !== undefined)
370
- throw new AuthFailure('invalid_response');
371
- return token;
372
- }
373
471
  async function newSessionKey() {
374
472
  // shortcut: SecretStore stores a private JWK; use a host signer when non-extractable key persistence is available.
375
473
  const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']);
@@ -413,7 +511,6 @@ export function createClientAuth(options) {
413
511
  live.dpopKeyRef = ref;
414
512
  delete live.needsSessionKey;
415
513
  live.generation++;
416
- switchSession(live, randomId('ses_'), drop);
417
514
  if (operationId)
418
515
  own(state.operations, operationId).connectionGeneration = live.generation;
419
516
  return live;
@@ -475,30 +572,9 @@ export function createClientAuth(options) {
475
572
  };
476
573
  }
477
574
  async function signedOutRenewal(connection, resource) {
478
- // Expiry counts from before dispatch so a slow response never extends the token's believed lifetime.
479
- const issuedAt = now(), operationId = randomId('op_');
480
- if (connection.needsSessionKey)
481
- connection = await freshSessionKey(connection);
482
- let token;
483
- try {
484
- token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, connection.sessionId, resource, connection.anonymous);
485
- }
486
- catch (error) {
487
- // The Session ended (spec 4.2): start a new signed-out one. Signed-out renewal never touches an account.
488
- if (!(error instanceof AuthFailure && error.code === 'invalid_grant'))
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);
493
- token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, null, resource, connection.anonymous);
494
- }
495
- const secretRef = await stageSessionToken(token.access_token);
496
- return commit({ connectionId: connection.id }, [secretRef], (state, drop) => {
575
+ return transaction({ connectionId: connection.id }, state => {
497
576
  const current = own(state.connections, connection.id);
498
- if (!current || current.generation !== connection.generation || current.status !== 'signed_out' || current.sessionId !== connection.sessionId)
499
- throw new AuthFailure('state_changed', connection.id);
500
- replaceSlot(current, resource, { secretRef, tokenType: token.token_type, dpopKeyRef: current.dpopKeyRef, scopes: [], expiresAt: issuedAt + token.expires_in * 1000, generation: current.generation }, drop);
501
- return finish(state, renewOperation(state, operationId, current, null, issuedAt, resource, []), 'completed');
577
+ return finish(state, renewOperation(state, randomId('op_'), current, null, now(), resource, []), 'completed');
502
578
  });
503
579
  }
504
580
  // Account Token refresh (spec 4.8). The grant claim allows one rotation in flight per grant across processes,
@@ -554,31 +630,10 @@ export function createClientAuth(options) {
554
630
  if (!dispatched)
555
631
  return await endRefresh(operationId, 'failed', 'state_changed');
556
632
  const issuedAt = now();
557
- const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields, null);
633
+ const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields);
558
634
  let acquired;
559
635
  try {
560
- try {
561
- acquired = await send();
562
- }
563
- catch (error) {
564
- if (!(error instanceof AuthFailure && error.code === 'invalid_grant'))
565
- throw error;
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');
581
- }
636
+ acquired = await send();
582
637
  }
583
638
  catch (error) {
584
639
  if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
@@ -614,7 +669,7 @@ export function createClientAuth(options) {
614
669
  if (problem || accessRef === null)
615
670
  return await endRefresh(operationId, 'failed', problem ?? 'storage_error', refreshRef);
616
671
  const staged = {
617
- accessRef, refreshRef, tokenType: token.token_type, sessionId: claim.connection.sessionId, scopes: granted, signedIn: true, expiresAt: issuedAt + token.expires_in * 1000,
672
+ accessRef, refreshRef, tokenType: token.token_type, sessionId: claim.connection.sessionId, scopes: granted, signedIn: true, expiresAt: issuedAt + (token.expires_in ?? 60) * 1000,
618
673
  refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000,
619
674
  };
620
675
  // Its own commit, so a crash before activation leaves the new Account Token referenced for recover (interface 4).
@@ -657,19 +712,28 @@ export function createClientAuth(options) {
657
712
  const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
658
713
  if (grant?.operationId === operation.id) {
659
714
  grant.operationId = null;
660
- if (connection.grantId === grant.id && connection.generation === operation.connectionGeneration) {
715
+ if (Object.values(state.connections).some(peer => peer.grantId === grant.id)) {
661
716
  if (error === 'invalid_grant') {
662
717
  // Expired or revoked: the Session continues signed out and the User signs in again only on request (spec 4.8, 4.9).
663
- drop.push(grant.secretRef, ...Object.values(connection.slots).map(slot => slot.secretRef));
718
+ drop.push(grant.secretRef);
664
719
  delete state.grants[grant.id];
665
- connection.grantId = null;
666
- connection.status = 'signed_out';
667
- connection.slots = {};
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
+ }
668
728
  }
669
729
  else if (status === 'uncertain' || rotated) {
670
730
  // The Account Token we hold may already be replaced; only a fresh sign-in is safe.
671
731
  grant.status = 'uncertain';
672
- connection.status = 'uncertain';
732
+ for (const peer of Object.values(state.connections))
733
+ if (peer.grantId === grant.id) {
734
+ peer.status = 'uncertain';
735
+ peer.generation++;
736
+ }
673
737
  }
674
738
  }
675
739
  }
@@ -679,24 +743,25 @@ export function createClientAuth(options) {
679
743
  const staged = operation.staged;
680
744
  const connection = own(state.connections, operation.connectionId);
681
745
  const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
682
- if (!grant || grant.operationId !== operation.id || grant.generation !== operation.grantGeneration || connection.grantId !== grant.id || connection.generation !== operation.connectionGeneration) {
683
- // 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) {
684
747
  if (staged.refreshRef !== null)
685
748
  retire(state, `cln_${operation.id}`, connection, staged.refreshRef);
686
749
  drop.push(staged.accessRef);
687
- if (grant?.operationId === operation.id)
688
- grant.operationId = null;
689
750
  return finish(state, operation, 'failed', { error: 'state_changed' });
690
751
  }
691
752
  grant.operationId = null;
692
753
  grant.generation++;
693
754
  if (staged.refreshRef !== null) {
694
- // The Company rotated the Account Token; the old one is dead and is never sent again (spec 4.8).
695
755
  drop.push(grant.secretRef);
696
756
  grant.secretRef = staged.refreshRef;
697
757
  if (staged.refreshExpiresAt !== null)
698
758
  grant.expiresAt = staged.refreshExpiresAt;
699
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
+ }
700
765
  switchSession(connection, staged.sessionId, drop);
701
766
  // Only this resource's credential changes; other resources' credentials survive (IMPLEMENTATION 3).
702
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);
@@ -717,56 +782,6 @@ export function createClientAuth(options) {
717
782
  if (wait > 0n)
718
783
  throw new AuthFailure('rate_limited', connection.id, Number(wait), connection.notBefore);
719
784
  }
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
- }
770
785
  // Every token-request path (internal refresh and public renew) goes through here, so all honour Retry-After.
771
786
  async function renewal(connection, resource, scopes) {
772
787
  // Local failures first: a wait is only meaningful for a connection that can still send a token request.
@@ -777,10 +792,7 @@ export function createClientAuth(options) {
777
792
  checkCooldown(await record(connection.id));
778
793
  let outcome;
779
794
  try {
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);
795
+ outcome = connection.status === 'signed_out' ? await signedOutRenewal(connection, resource) : await refresh(connection, resource, scopes);
784
796
  }
785
797
  catch (error) {
786
798
  if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
@@ -814,6 +826,8 @@ export function createClientAuth(options) {
814
826
  const connection = await record(connectionId);
815
827
  if (connection.status === 'disconnected')
816
828
  throw new AuthFailure('disconnected', connectionId);
829
+ if (connection.status === 'uncertain')
830
+ throw new AuthFailure('grant_uncertain', connectionId);
817
831
  const slot = own(connection.slots, slotKey(connection.sessionId, resource));
818
832
  return slot && slot.secretRef !== stale && slot.expiresAt - SKEW_MS > now() ? slot : renewShared(connectionId, resource);
819
833
  }
@@ -823,7 +837,7 @@ export function createClientAuth(options) {
823
837
  // A lost exchange or rotation may have issued an Account Token we never saw, so it can never be claimed revoked.
824
838
  if (operations.some(op => op.status === 'uncertain') || items.some(item => item.status === 'uncertain'))
825
839
  return 'uncertain';
826
- return items.length || operations.some(op => op.phase === 'dispatched' || op.phase === 'staged') ? 'pending' : 'complete';
840
+ return items.length || operations.some(op => op.phase !== 'terminal' && op.phase !== 'prepared') || (own(state.connections, connectionId)?.signingCleanup?.length ?? 0) ? 'pending' : 'complete';
827
841
  }
828
842
  // RFC 7009 revocation with a fresh client assertion (spec 4.9). Replay is safe, so anything but 200 stays pending.
829
843
  async function revoke(connection, item, token) {
@@ -851,14 +865,17 @@ export function createClientAuth(options) {
851
865
  }
852
866
  }
853
867
  async function cleanupNow(connectionId) {
854
- const connection = await record(connectionId);
855
- const items = Object.values((await load({ connectionId })).cleanup).filter(item => item.connectionId === connectionId && item.status === 'pending');
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');
856
873
  for (const item of items) {
857
874
  const token = await secrets.read(item.secretRef);
858
- const revoked = token !== null && await revoke(connection, item, token);
875
+ const revoked = token !== null && await revoke(own(snapshot.connections, item.connectionId), item, token);
859
876
  if (token !== null && !revoked)
860
877
  continue;
861
- await commit({ connectionId }, [], (state, drop) => {
878
+ await commit({ connectionId: item.connectionId }, [], (state, drop) => {
862
879
  const current = own(state.cleanup, item.id);
863
880
  if (current?.secretRef !== item.secretRef)
864
881
  return;
@@ -870,14 +887,14 @@ export function createClientAuth(options) {
870
887
  current.status = 'uncertain';
871
888
  });
872
889
  }
873
- const state = await load({ connectionId });
874
- return { connection: publicConnection(state, own(state.connections, connectionId)), cleanup: cleanupStatus(state, connectionId) };
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' };
875
892
  }
876
893
  function targetOf(connection) {
877
894
  const entry = connection.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy');
878
895
  if (!entry)
879
896
  throw new AuthFailure('no_conversation', connection.id);
880
- return { issuer: connection.issuer, endpoint: entry.endpoint, ...(entry.resource === undefined ? {} : { resource: entry.resource }) };
897
+ return { issuer: connection.issuer, endpoint: entry.endpoint };
881
898
  }
882
899
  function outcomeOf(state, operation) {
883
900
  return operation.result ?? { operationId: operation.id, status: operation.status, connection: publicConnection(state, own(state.connections, operation.connectionId)), ...(operation.phase === 'terminal' ? {} : { nextAction: 'wait' }) };
@@ -933,18 +950,19 @@ export function createClientAuth(options) {
933
950
  drop.push(staged.accessRef);
934
951
  return finish(state, operation, problem === 'cancelled' ? 'cancelled' : 'failed', { error: problem, grantedScopes: [...granted], missingScopes });
935
952
  }
936
- if (previous) {
953
+ if (previous && !Object.values(state.connections).some(row => row.id !== target.id && row.grantId === previous.id)) {
937
954
  retire(state, `cln_${previous.id}`, target, previous.secretRef);
938
955
  delete state.grants[previous.id];
939
956
  }
940
957
  const grantId = randomId('grt_');
941
958
  state.grants[grantId] = { id: grantId, issuer: target.issuer, userId: target.userId, secretRef: staged.refreshRef, scopes: granted,
942
959
  expiresAt: staged.refreshExpiresAt, generation: 1, status: 'active', operationId: null };
960
+ switchSession(target, staged.sessionId, drop);
943
961
  target.grantId = grantId;
944
962
  target.status = 'signed_in';
945
963
  target.generation++;
946
964
  drop.push(...Object.values(target.slots).map(slot => slot.secretRef));
947
- 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,
948
966
  scopes: granted, expiresAt: staged.expiresAt, generation: target.generation } };
949
967
  return finish(state, operation, 'completed', { grantedScopes: [...granted], missingScopes });
950
968
  }
@@ -959,19 +977,19 @@ export function createClientAuth(options) {
959
977
  catch {
960
978
  return await terminate(operationId, 'uncertain', 'storage_error');
961
979
  }
962
- if (token.token_type !== 'DPoP' || !refreshRef ||
980
+ if (!refreshRef ||
963
981
  !granted.length || new Set(granted).size !== granted.length || granted.some(scope => !operation.scopes.includes(scope))) {
964
982
  return commit({ operationId }, [], (state, drop) => {
965
983
  const current = own(state.operations, operationId);
966
984
  if (refreshRef)
967
985
  retire(state, `cln_${operationId}`, own(state.connections, current.connectionId), refreshRef);
968
986
  drop.push(...privateRefs(current));
969
- return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error: token.token_type !== 'DPoP' ? 'unsupported_token_type' : 'invalid_response' });
987
+ return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error: 'invalid_response' });
970
988
  });
971
989
  }
972
990
  const accessRef = await stageSessionToken(token.access_token);
973
- const staged = { accessRef, refreshRef, sessionId: operation.signInSessionId, scopes: granted, signedIn: true,
974
- 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 };
975
993
  // Stage before activation: recovery can finish locally without exchanging a consumed credential again.
976
994
  const recorded = await transaction({ operationId }, state => {
977
995
  const current = own(state.operations, operationId);
@@ -1108,7 +1126,7 @@ export function createClientAuth(options) {
1108
1126
  fields.resource = operation.resource;
1109
1127
  let token;
1110
1128
  try {
1111
- token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields, null)).token;
1129
+ token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields)).token;
1112
1130
  }
1113
1131
  catch (error) {
1114
1132
  if (error instanceof AuthFailure && ['authorization_pending', 'slow_down', 'rate_limited'].includes(error.code)) {
@@ -1144,41 +1162,26 @@ export function createClientAuth(options) {
1144
1162
  const claim = await claimSignIn(operationId, executionOwner, true);
1145
1163
  if (!('operation' in claim))
1146
1164
  return claim;
1147
- const { operation, connection } = claim, mediated = connection.metadata.document.auth.mediated;
1148
- if (!options.trustedInput)
1149
- return await terminate(operationId, 'failed', 'trusted_input_required');
1165
+ const { operation, connection } = claim, mediated = mediatedSignIn(connection.metadata.document);
1166
+ if (!mediated || !options.trustedInput)
1167
+ return terminate(operationId, 'failed', 'trusted_input_required');
1150
1168
  const fields = operation.mediated ? [{ name: 'code', label: 'One-time code', secret: true }] : mediated.fields;
1151
- let values;
1152
1169
  try {
1153
- values = await options.trustedInput.collect({ operationId, issuer: connection.issuer, organization: { ...connection.metadata.document.organization }, fields: structuredClone(fields),
1154
- ...(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 } } : {}) });
1155
1171
  }
1156
1172
  catch {
1157
- return await terminate(operationId, 'failed', 'input_unavailable');
1173
+ return terminate(operationId, 'failed', 'input_unavailable');
1158
1174
  }
1159
- sensitive = values;
1175
+ const values = sensitive;
1160
1176
  if (values === null)
1161
- return await terminate(operationId, 'cancelled');
1162
- if (!values || 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))
1163
- return await terminate(operationId, 'failed', 'invalid_input');
1164
- const endpoint = operation.mediated ? `${mediated.endpoint.replace(/\/$/, '')}/${encodeURIComponent(operation.mediated.challengeId)}` : mediated.endpoint;
1165
- let credential = await credentialFor(connection.id, operation.resource, null), token = await secrets.read(credential.secretRef);
1166
- if (!token) {
1167
- credential = await credentialFor(connection.id, operation.resource, credential.secretRef);
1168
- token = await secrets.read(credential.secretRef);
1169
- }
1170
- if (!token)
1171
- return await terminate(operationId, 'failed', 'missing_credential');
1172
- if (connection.sessionId !== (await record(connection.id)).sessionId || now() >= operation.expiresAt)
1173
- return await terminate(operationId, 'expired');
1174
- let response, json;
1175
- 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');
1176
1180
  const dispatch = await transaction({ operationId }, state => {
1177
- const current = own(state.operations, operationId);
1181
+ const current = own(state.operations, operationId), live = own(state.connections, current.connectionId);
1178
1182
  if (current.phase !== 'prepared' || current.owner?.id !== executionOwner.id || current.cancelRequested)
1179
1183
  return false;
1180
- const live = own(state.connections, current.connectionId);
1181
- if (live.generation !== current.connectionGeneration || live.sessionId !== current.signInSessionId || current.expiresAt <= now()) {
1184
+ if (live.generation !== current.connectionGeneration || current.expiresAt <= now()) {
1182
1185
  finish(state, current, 'cancelled');
1183
1186
  return false;
1184
1187
  }
@@ -1187,69 +1190,48 @@ export function createClientAuth(options) {
1187
1190
  });
1188
1191
  if (!dispatch)
1189
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();
1190
1197
  try {
1191
- // Bounded nonce retry only; credentials remain exclusively within this trusted call.
1192
- for (let attempt = 0; attempt < 2; attempt++) {
1193
- if (attempt > 0 && own((await load({ operationId })).operations, operationId)?.cancelRequested)
1194
- return await terminate(operationId, 'cancelled');
1195
- response = await transport.fetch(new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `DPoP ${token}`, dpop: await proof(key, 'POST', endpoint, token) },
1196
- body: JSON.stringify(operation.mediated ? { code: values.code } : { scope: operation.scopes.join(' '), credentials: values }) }));
1197
- rememberNonce(endpoint, response);
1198
- if (response.status === 401 && attempt === 0 && dpopChallenge(response.headers.get('www-authenticate'))?.error === 'use_dpop_nonce' && response.headers.has('dpop-nonce')) {
1199
- await response.body?.cancel();
1200
- continue;
1201
- }
1202
- break;
1203
- }
1204
- if (response.status === 429 || response.status === 503)
1205
- await holdOff(connection.id, responseFailure(response, 'rate_limited'));
1206
- json = parseJson(await response.text());
1207
- }
1208
- catch {
1209
- return await terminate(operationId, response?.status === 429 ? 'failed' : 'uncertain', response?.status === 429 ? 'rate_limited' : 'lost_response');
1210
- }
1211
- const echoed = Object.values(values);
1212
- for (const name of Object.keys(values))
1213
- delete values[name];
1214
- values = null;
1215
- if (response.status !== 200) {
1216
- 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');
1217
- return await remoteFailure(operationId, error);
1198
+ const { token } = await tokenRequest(connection.metadata, connection.dpopKeyRef, params, operationId);
1199
+ return await acceptSignIn(operationId, token, issuedAt);
1218
1200
  }
1219
- if (json?.status === 'complete') {
1220
- let result;
1221
- try {
1222
- result = parseTokenResponse(json);
1223
- }
1224
- catch {
1225
- return await terminate(operationId, 'uncertain', 'invalid_response');
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
+ });
1226
1219
  }
1227
- return await acceptSignIn(operationId, result, issuedAt);
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];
1228
1229
  }
1229
- if (json?.status === 'failed' || json?.status === 'expired')
1230
- return await terminate(operationId, json.status === 'expired' ? 'expired' : 'denied');
1231
- const code = json?.code, expiresAt = typeof json?.expires_at === 'string' ? Date.parse(json.expires_at) : NaN;
1232
- if (json?.status !== 'code_required' || typeof json.sign_in_id !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(json.sign_in_id) ||
1233
- (operation.mediated && operation.mediated.challengeId !== json.sign_in_id) || typeof code?.sent_to !== 'string' || code.sent_to.length > 512 || !Number.isFinite(expiresAt) || expiresAt <= now())
1234
- return await terminate(operationId, 'failed', 'invalid_response');
1235
- const sentTo = echoed.some(value => value.length > 2 && code.sent_to.includes(value)) ? 'The company sent a one-time code.' : code.sent_to;
1236
- return await transaction({ operationId }, state => {
1237
- const current = own(state.operations, operationId);
1238
- if (current.phase !== 'dispatched')
1239
- return outcomeOf(state, current);
1240
- if (current.cancelRequested)
1241
- return finish(state, current, 'cancelled');
1242
- current.phase = 'prepared';
1243
- delete current.owner;
1244
- current.expiresAt = Math.min(current.expiresAt, expiresAt);
1245
- current.mediated = { challengeId: json.sign_in_id, sentTo };
1246
- return outcomeOf(state, current);
1247
- });
1248
1230
  }
1249
1231
  catch (error) {
1250
- if (!(error instanceof AuthFailure))
1232
+ if (own((await load({ operationId })).operations, operationId)?.phase === 'staged')
1251
1233
  throw error;
1252
- return await remoteFailure(operationId, error);
1234
+ return remoteFailure(operationId, error);
1253
1235
  }
1254
1236
  finally {
1255
1237
  if (sensitive)
@@ -1258,61 +1240,120 @@ export function createClientAuth(options) {
1258
1240
  releaseOwner(executionOwner);
1259
1241
  }
1260
1242
  }
1261
- async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true) {
1262
- const connection = await record(connectionId);
1243
+ async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true, service, expectedContext) {
1263
1244
  const url = new URL(request.url), endpoint = new URL(target.endpoint), base = endpoint.pathname.replace(/\/$/, '');
1264
1245
  if (!safeHttps(request.url) || url.origin !== endpoint.origin || (url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${base}/`)))
1265
1246
  throw new AuthFailure('invalid_target', connectionId);
1266
- if (connection.status === 'disconnected')
1267
- throw new AuthFailure('disconnected', connectionId);
1268
- checkCooldown(connection);
1269
- const resource = target.resource ?? null, wait = url.searchParams.get('wait');
1247
+ let resource = target.resource ?? null;
1248
+ const wait = url.searchParams.get('wait');
1270
1249
  const waitMs = wait !== null && /^\d+$/.test(wait) ? Math.min(Number(wait), MAX_WAIT_SECONDS) * 1000 : 0;
1271
- let credential = await credentialFor(connectionId, resource, null), nonceRetried = false, renewed = false;
1250
+ let restarted = false, nonceRetried = false, renewed = false, discovered = false;
1272
1251
  for (;;) {
1273
- if (credential.tokenType !== scheme)
1274
- throw new AuthFailure('invalid_target', connectionId);
1275
- if (!requiredScopes.every(scope => credential.scopes.includes(scope))) {
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))) {
1276
1290
  const requirement = await api.authorization(connectionId, requiredScopes);
1277
1291
  if (!renewed && requirement.sufficient) {
1278
1292
  renewed = true;
1279
- credential = await renewShared(connectionId, resource);
1293
+ await renewShared(connectionId, resource);
1280
1294
  continue;
1281
1295
  }
1282
1296
  throw new AuthFailure('insufficient_scope', connectionId);
1283
1297
  }
1284
- const token = await secrets.read(credential.secretRef);
1285
- if (token === null) {
1286
- if (renewed)
1287
- throw new AuthFailure('missing_credential', connectionId);
1288
- renewed = true;
1289
- credential = await credentialFor(connectionId, resource, credential.secretRef);
1290
- continue;
1291
- }
1292
1298
  const headers = new Headers(request.headers);
1293
- headers.delete('cookie');
1294
- headers.delete('dpop');
1295
- headers.set('authorization', `${scheme} ${token}`);
1296
- if (scheme === 'DPoP')
1297
- headers.set('dpop', await proof(await loadDpopKey(credential.dpopKeyRef), request.method, request.url, token));
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;
1298
1309
  const authorized = new Request(request.clone(), { headers, credentials: 'omit', redirect: 'error' });
1299
1310
  const streaming = request.headers.get('accept')?.split(',').some(value => value.trim().split(';')[0] === 'text/event-stream');
1300
1311
  const response = await (streaming && transport.stream ? transport.stream(authorized) : transport.fetch(authorized, { waitMs }));
1301
1312
  rememberNonce(request.url, response);
1302
1313
  if (response.status === 429 || response.status === 503)
1303
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
+ }
1304
1337
  if (response.status !== 401 || !retryRejected)
1305
1338
  return response;
1306
- const error = schemeChallenge(response.headers.get('www-authenticate'), scheme)?.error;
1339
+ const challenge = schemeChallenge(response.headers.get('www-authenticate'), credential?.tokenType ?? scheme), error = challenge?.error;
1307
1340
  if (error === 'use_dpop_nonce' && !nonceRetried && response.headers.has('dpop-nonce')) {
1308
1341
  await response.body?.cancel().catch(() => { });
1309
1342
  nonceRetried = true;
1310
1343
  continue;
1311
1344
  }
1312
- if (error === 'invalid_token' && !renewed) {
1345
+ if (error === 'invalid_token' && credential && !renewed) {
1313
1346
  await response.body?.cancel().catch(() => { });
1314
1347
  renewed = true;
1315
- credential = await credentialFor(connectionId, resource, credential.secretRef);
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);
1316
1357
  continue;
1317
1358
  }
1318
1359
  return response;
@@ -1327,26 +1368,31 @@ export function createClientAuth(options) {
1327
1368
  catch {
1328
1369
  throw new AuthFailure('discovery_failed');
1329
1370
  }
1330
- if (!metadata.authorization)
1331
- throw new AuthFailure('no_auth');
1332
- const issuer = metadata.authorization.issuer;
1371
+ const issuer = companyIdentifier(metadata);
1333
1372
  const anonymous = input?.anonymous === true;
1373
+ if (input?.accountFrom !== undefined && (anonymous || typeof input.accountFrom !== 'string'))
1374
+ throw new AuthFailure('invalid_account_source');
1334
1375
  const { userId } = await identityFor(issuer, anonymous);
1335
1376
  const epoch = (await load({ issuer })).epoch;
1336
1377
  const dpopKeyRef = await newSessionKey();
1337
1378
  const createdAt = now();
1338
- const token = await signedOutToken(metadata, userId, dpopKeyRef, null, null, anonymous);
1339
- const secretRef = await stageSessionToken(token.access_token);
1340
1379
  const id = randomId('con_'), operationId = randomId('op_'), sessionId = randomId('ses_');
1341
- return commit({ issuer, history: true }, [secretRef, dpopKeyRef], state => {
1380
+ return commit(input?.accountFrom ? { account: { issuer, userId }, history: true } : { issuer, history: true }, [dpopKeyRef], state => {
1342
1381
  // A reset or competing identity since we signed means this session belongs to no current identity.
1343
1382
  if (state.epoch !== epoch || (!anonymous && own(state.identities, issuer)?.userId !== userId))
1344
1383
  throw new AuthFailure('state_changed');
1345
1384
  const connection = {
1346
1385
  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 } },
1386
+ slots: {},
1348
1387
  ...(anonymous ? { anonymous: true } : {}),
1349
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
+ }
1350
1396
  state.connections[id] = connection;
1351
1397
  pruneHistory(state);
1352
1398
  const result = { operationId, status: 'completed', connection: publicConnection(state, connection) };
@@ -1395,7 +1441,7 @@ export function createClientAuth(options) {
1395
1441
  if (!supported || (method === 'direct' && !authorizationEndpoint) || (method === 'device' && !found.metadata.authorization?.device_authorization_endpoint))
1396
1442
  throw new AuthFailure(`${method}_unavailable`, connectionId);
1397
1443
  const { scopes, redirectUri, browserBinding, requiredExistingScopes } = input ?? {};
1398
- 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)))))
1399
1445
  throw new AuthFailure('invalid_scope', connectionId);
1400
1446
  const requirement = await api.authorization(connectionId, scopes);
1401
1447
  if (requiredExistingScopes !== undefined && (!Array.isArray(requiredExistingScopes) || !requiredExistingScopes.every(scope => requirement.grantedScopes.includes(scope))))
@@ -1410,32 +1456,17 @@ export function createClientAuth(options) {
1410
1456
  // The host derives this from its authenticated browser session; a short value would be guessable.
1411
1457
  if (method === 'direct' && (typeof browserBinding !== 'string' || browserBinding.length < 22))
1412
1458
  throw new AuthFailure('invalid_binding', connectionId);
1413
- // Ask for the conversation resource up front so the signed-in token is usable there (spec 4.3 "Narrower tokens").
1414
- const resource = found.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy')?.resource ?? null;
1415
- if (await resourceScheme(found.metadata, resource) !== 'DPoP')
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
- }
1421
- // The code exchange names this Session (spec 4.5). A stale credential means it may have ended, so renew first,
1422
- // which replaces an ended Session; a failed renewal leaves the Session as it was for the issuer to judge.
1423
- const renew = () => credentialFor(connectionId, resource, null).catch(error => { if (!(error instanceof AuthFailure))
1424
- throw error; });
1425
- if (found.status === 'signed_out' || found.status === 'signed_in')
1426
- await renew();
1427
- let connection = await record(connectionId);
1428
- // An expired Account Token ends the refresh as invalid_grant, leaving the connection signed out on a possibly ended Session.
1429
- if (found.status === 'signed_in' && connection.status === 'signed_out') {
1430
- await renew();
1431
- connection = await record(connectionId);
1432
- }
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);
1433
1463
  // An uncertain grant recovers only through a fresh sign-in; a disconnected connection never signs in again (spec 4.9).
1434
1464
  if (connection.status === 'disconnected' || connection.sessionId === null)
1435
1465
  throw new AuthFailure('sign_in_unavailable', connectionId);
1436
1466
  const state = b64(crypto.getRandomValues(new Uint8Array(32)));
1437
1467
  const verifier = b64(crypto.getRandomValues(new Uint8Array(32)));
1438
1468
  const sessionKey = (await loadDpopKey(connection.dpopKeyRef)).thumbprint;
1469
+ const proofBound = method === 'direct' && await resourceScheme(found.metadata, resource) === 'DPoP';
1439
1470
  const verifierRef = method === 'direct' ? await secrets.stage(verifier) : null;
1440
1471
  const createdAt = now(), operationId = randomId('op_');
1441
1472
  const outcome = await commit({ connectionId, history: true }, [verifierRef], draft => {
@@ -1457,9 +1488,11 @@ export function createClientAuth(options) {
1457
1488
  if (method === 'mediated')
1458
1489
  return { outcome: await continueMediated(outcome.operationId) };
1459
1490
  const url = new URL(authorizationEndpoint);
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 };
1491
+ const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256' };
1461
1492
  for (const [name, value] of Object.entries(query))
1462
1493
  url.searchParams.set(name, value);
1494
+ if (proofBound)
1495
+ url.searchParams.set('dpop_jkt', sessionKey);
1463
1496
  if (resource !== null)
1464
1497
  url.searchParams.set('resource', resource);
1465
1498
  return { outcome, authorizationUrl: url.href };
@@ -1516,7 +1549,7 @@ export function createClientAuth(options) {
1516
1549
  const issuedAt = now();
1517
1550
  let token;
1518
1551
  try {
1519
- token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields, null)).token;
1552
+ token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields)).token;
1520
1553
  }
1521
1554
  catch (error) {
1522
1555
  if (error instanceof AuthFailure)
@@ -1616,38 +1649,52 @@ export function createClientAuth(options) {
1616
1649
  throw new AuthFailure('invalid_scope', connectionId);
1617
1650
  return renewal(await currentConnection(connectionId), resource, scopes ?? null);
1618
1651
  },
1619
- // Sign-out wins over in-flight sign-in and refresh: disconnection and the new generation commit before anything goes remote (IMPLEMENTATION 4).
1652
+ // Opaque grants have no standard account identifier; revoke all grants for this local User and Company.
1620
1653
  async signOut(connectionId) {
1621
- await record(connectionId);
1622
- await commit({ connectionId }, [], (state, drop) => {
1623
- const connection = own(state.connections, connectionId);
1624
- if (connection.status === 'disconnected')
1625
- return;
1626
- connection.generation++;
1627
- connection.status = 'disconnected';
1628
- drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
1629
- connection.slots = {};
1630
- if (connection.anonymous) {
1631
- drop.push(connection.dpopKeyRef);
1632
- connection.userId = '';
1633
- connection.sessionId = null;
1634
- }
1635
- const grant = connection.grantId === null ? undefined : own(state.grants, connection.grantId);
1636
- if (grant) {
1637
- retire(state, `cln_${grant.id}`, connection, grant.secretRef);
1638
- delete state.grants[grant.id];
1639
- }
1640
- connection.grantId = null;
1641
- for (const operation of Object.values(state.operations)) {
1642
- 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')
1643
1658
  continue;
1644
- finish(state, operation, 'cancelled');
1645
- drop.push(...privateRefs(operation));
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
+ }
1646
1683
  }
1647
1684
  });
1648
1685
  return cleanupNow(connectionId);
1649
1686
  },
1650
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
+ },
1651
1698
  async apiTarget(connectionId, apiUrl, destination) {
1652
1699
  const connection = await currentConnection(connectionId);
1653
1700
  try {
@@ -1657,13 +1704,13 @@ export function createClientAuth(options) {
1657
1704
  throw new AuthFailure('invalid_target', connectionId);
1658
1705
  }
1659
1706
  },
1660
- async apiFetch(connectionId, request, target, requiredScopes = []) {
1707
+ async apiFetch(connectionId, request, target, requiredScopes = [], expectedContext) {
1661
1708
  const expected = await api.apiTarget(connectionId, target.apiUrl, target.endpoint);
1662
1709
  if (expected.kind !== target.kind || expected.issuer !== target.issuer || expected.endpoint !== target.endpoint || expected.resource !== target.resource || expected.scheme !== target.scheme)
1663
1710
  throw new AuthFailure('invalid_target', connectionId);
1664
1711
  if (expected.kind === 'mcp' && request.url !== expected.endpoint)
1665
1712
  throw new AuthFailure('invalid_target', connectionId);
1666
- return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme);
1713
+ return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme, true, expected.kind, expectedContext);
1667
1714
  },
1668
1715
  async joinBrowser(connectionId, returnTo, controller) {
1669
1716
  const connection = await currentConnection(connectionId), endpoint = connection.metadata.document.web?.browser_session_endpoint;
@@ -1675,7 +1722,6 @@ export function createClientAuth(options) {
1675
1722
  catch {
1676
1723
  throw new AuthFailure('invalid_target', connectionId);
1677
1724
  }
1678
- const key = await loadDpopKey(connection.dpopKeyRef);
1679
1725
  try {
1680
1726
  const { code } = await controller.loadJoinForm(endpoint);
1681
1727
  if (typeof code !== 'string' || !code || code.length > 256)
@@ -1683,7 +1729,13 @@ export function createClientAuth(options) {
1683
1729
  const live = await currentConnection(connectionId);
1684
1730
  if (live.generation !== connection.generation || live.dpopKeyRef !== connection.dpopKeyRef || live.status !== connection.status)
1685
1731
  throw new Error('state changed');
1686
- await controller.postForm({ url: endpoint, fields: { code, proof: await proof(key, 'POST', endpoint, undefined, code) }, validateRedirect(status, location) {
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) {
1687
1739
  if (status !== 303)
1688
1740
  throw new AuthFailure('invalid_browser_redirect', connectionId);
1689
1741
  const destination = new URL(location, endpoint).href;
@@ -1720,11 +1772,11 @@ export function createClientAuth(options) {
1720
1772
  throw new AuthFailure('excluded_tasks_unavailable', connectionId);
1721
1773
  return options.metadata.resolveExcludedTasks(connection.metadata.document);
1722
1774
  },
1723
- async authorizedFetch(connectionId, request, target, requiredScopes = []) {
1775
+ async authorizedFetch(connectionId, request, target, requiredScopes = [], expectedContext) {
1724
1776
  const connection = await currentConnection(connectionId), expected = targetOf(connection);
1725
1777
  if (target.issuer !== expected.issuer || target.endpoint !== expected.endpoint || target.resource !== expected.resource)
1726
1778
  throw new AuthFailure('invalid_target', connectionId);
1727
- return sendAuthorized(connectionId, request, expected, requiredScopes);
1779
+ return sendAuthorized(connectionId, request, expected, requiredScopes, 'DPoP', true, 'conversation', expectedContext);
1728
1780
  },
1729
1781
  };
1730
1782
  return api;