@poppykit/client 0.4.1 → 0.6.0

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