@poppykit/client 0.4.1 → 0.5.0

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