@oxyhq/core 1.11.17 → 1.11.19

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.
@@ -544,16 +544,28 @@ export class KeyManager {
544
544
  /**
545
545
  * Atomically persist a key pair to secure storage with verification + backup.
546
546
  *
547
- * Write order is critical:
548
- * 1. Backup (BACKUP_PRIVATE_KEY + BACKUP_PUBLIC_KEY + BACKUP_TIMESTAMP)
549
- * 2. Primary public key
550
- * 3. Primary private key (last so a partial write leaves us in a known
551
- * "no identity yet" stateeasier to retry than a half-written one)
552
- * 4. Read back + sign/verify to confirm the storage round-trip works
547
+ * INVARIANT (the reason this method exists): at no instant during the write
548
+ * may the device be left holding ZERO recoverable copies of a healthy
549
+ * identity. This matters most on the OVERWRITE / account-switch path: if we
550
+ * are replacing identity A with B and the write fails halfway, we MUST end
551
+ * up back on A never on a half-written B, and never on nothing.
553
552
  *
554
- * If any step throws, the caller sees the error AND any partial state is
555
- * cleaned up so the device is left either fully consistent or fully empty.
556
- * It never leaves an unusable half-identity that would fool `hasIdentity()`.
553
+ * Algorithm (recoverability-preserving):
554
+ * 0. Snapshot the existing primary (privA, pubA) so we can roll back to
555
+ * EXACTLY what was there.
556
+ * 1. Write the new primary: public first, then private.
557
+ * 2. Read back + sign/verify the new primary.
558
+ * 3. ONLY after the new primary is proven durable, refresh the backup to
559
+ * the new key. The backup is NEVER touched before this point, so any
560
+ * prior identity's backup remains intact and `restoreIdentityFromBackup`
561
+ * can always recover it.
562
+ * 4. On ANY failure in steps 1–2, restore the snapshotted primary verbatim
563
+ * (or delete it if there was none), then surface the error.
564
+ *
565
+ * Earlier versions wrote the *incoming* key to the backup FIRST, which
566
+ * destroyed the previous identity's backup, and rolled back by blindly
567
+ * deleting the primary — so a failed overwrite silently switched the user
568
+ * to (or lost them into) the half-written new identity. That is fixed here.
557
569
  *
558
570
  * @internal
559
571
  */
@@ -565,23 +577,60 @@ export class KeyManager {
565
577
  // subsequent reads see a stable representation.
566
578
  const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
567
579
  const canonicalPublic = publicKey.toLowerCase();
568
- // Step 1: Backup BEFORE touching primary storage so we always have a
569
- // recoverable copy even if the device crashes mid-write. Store the
570
- // backup in canonical form too so a backup-restore cycle preserves
571
- // canonicalization.
580
+ // Step 0: Snapshot the existing primary so a failed write can be rolled
581
+ // back to EXACTLY the prior state. If the read itself fails we treat the
582
+ // prior primary as unknown and refuse to proceed overwriting blind
583
+ // would risk clobbering an identity we just couldn't see (e.g. a
584
+ // transient keychain lock).
585
+ let priorPrivate;
586
+ let priorPublic;
572
587
  try {
573
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
574
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
575
- });
576
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
577
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
588
+ priorPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
589
+ priorPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
578
590
  }
579
591
  catch (error) {
580
- logger.error('Failed to write identity backup before primary', error, { component: 'KeyManager' });
581
- throw new IdentityPersistError('Failed to write identity backup', error);
592
+ logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
593
+ throw new IdentityPersistError('Could not read existing identity before writing a new one; refusing to overwrite blind.', error);
594
+ }
595
+ // If we are replacing a DIFFERENT, currently-healthy identity, make sure
596
+ // it is recoverable from the backup slot BEFORE we overwrite the primary.
597
+ // We only do this when the existing backup does not already hold that
598
+ // identity — otherwise we would needlessly churn the keychain. This keeps
599
+ // the "always at least one recoverable copy" invariant intact across the
600
+ // window where the primary briefly holds the new key but the new backup
601
+ // has not been written yet.
602
+ const priorIsHealthyDifferent = !!priorPrivate &&
603
+ !!priorPublic &&
604
+ priorPublic.toLowerCase() !== canonicalPublic &&
605
+ KeyManager.isValidPrivateKey(priorPrivate) &&
606
+ KeyManager.isValidPublicKey(priorPublic) &&
607
+ KeyManager.derivePublicKey(priorPrivate).toLowerCase() === priorPublic.toLowerCase();
608
+ if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
609
+ let existingBackupPublic = null;
610
+ try {
611
+ existingBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
612
+ }
613
+ catch {
614
+ existingBackupPublic = null;
615
+ }
616
+ if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
617
+ try {
618
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, KeyManager.canonicalPrivateKey(priorPrivate), {
619
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
620
+ });
621
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorPublic.toLowerCase());
622
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
623
+ }
624
+ catch (error) {
625
+ logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
626
+ throw new IdentityPersistError('Failed to back up existing identity before overwrite', error);
627
+ }
628
+ }
582
629
  }
583
- // Step 2 + 3: Write primary keys. Public first so that if private write
584
- // fails we are still missing the most critical bit.
630
+ // Step 1: Write the new primary. Public first so that if the private write
631
+ // fails we are missing the most critical bit. The backup is intentionally
632
+ // NOT touched here — it still holds the previous good identity until the
633
+ // new primary is proven durable.
585
634
  try {
586
635
  await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, canonicalPublic);
587
636
  await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, {
@@ -590,18 +639,10 @@ export class KeyManager {
590
639
  }
591
640
  catch (error) {
592
641
  logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
593
- // Roll back the public-key half-write so hasIdentity() doesn't lie later.
594
- try {
595
- await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY);
596
- }
597
- catch { /* best effort */ }
598
- try {
599
- await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY);
600
- }
601
- catch { /* best effort */ }
642
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
602
643
  throw new IdentityPersistError('Failed to write identity to secure store', error);
603
644
  }
604
- // Step 4: Verify round-trip. If the store silently drops our writes
645
+ // Step 2: Verify round-trip. If the store silently drops our writes
605
646
  // (e.g., a misconfigured keychain access group), we MUST surface it
606
647
  // before declaring success — otherwise the caller will think the
607
648
  // identity was saved and discard the in-memory copy.
@@ -613,6 +654,7 @@ export class KeyManager {
613
654
  }
614
655
  catch (error) {
615
656
  logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
657
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
616
658
  throw new IdentityPersistError('Failed to verify identity after write', error);
617
659
  }
618
660
  // Hex comparisons are case-insensitive — normalize on both sides so a
@@ -621,6 +663,7 @@ export class KeyManager {
621
663
  if (readBackPrivate?.toLowerCase() !== canonicalPrivate ||
622
664
  readBackPublic?.toLowerCase() !== canonicalPublic) {
623
665
  logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
666
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
624
667
  throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
625
668
  }
626
669
  // Final sanity: derive public from the stored private and confirm the
@@ -641,15 +684,71 @@ export class KeyManager {
641
684
  }
642
685
  }
643
686
  catch (error) {
687
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
644
688
  if (error instanceof IdentityPersistError)
645
689
  throw error;
646
690
  logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
647
691
  throw new IdentityPersistError('Stored identity failed crypto self-test', error);
648
692
  }
693
+ // Step 3: The new primary is durable and functional. NOW it is safe to
694
+ // refresh the backup to the new key. If this final backup write fails the
695
+ // user still has a fully working primary, and the backup still holds the
696
+ // PREVIOUS good identity — so we log and continue rather than failing the
697
+ // whole operation (failing here would be strictly worse: a working
698
+ // primary would be reported as an error to the caller).
699
+ try {
700
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
701
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
702
+ });
703
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
704
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
705
+ }
706
+ catch (error) {
707
+ logger.warn('Primary identity persisted successfully but refreshing the backup failed; primary is usable, backup may be stale', { component: 'KeyManager' }, error);
708
+ }
649
709
  // Update cache only after we are certain the identity is durable.
650
710
  KeyManager.cachedPublicKey = canonicalPublic;
651
711
  KeyManager.cachedHasIdentity = true;
652
712
  }
713
+ /**
714
+ * Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
715
+ * or delete it entirely if there was no prior identity. Best-effort: every
716
+ * step is wrapped so a rollback failure never masks the original error the
717
+ * caller is about to throw. Invalidates the in-memory cache so the next read
718
+ * reflects whatever actually landed on disk.
719
+ *
720
+ * @internal
721
+ */
722
+ static async _rollbackPrimary(store, priorPrivate, priorPublic) {
723
+ try {
724
+ if (priorPrivate && priorPublic) {
725
+ // Restore exactly what was there before the failed write.
726
+ await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, priorPublic, {});
727
+ await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, priorPrivate, {
728
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
729
+ });
730
+ }
731
+ else {
732
+ // There was no prior identity — leave the device empty rather than
733
+ // half-written so hasIdentity() does not lie.
734
+ try {
735
+ await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY);
736
+ }
737
+ catch { /* best effort */ }
738
+ try {
739
+ await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY);
740
+ }
741
+ catch { /* best effort */ }
742
+ }
743
+ }
744
+ catch (rollbackError) {
745
+ logger.error('Failed to roll back primary identity after a failed write', rollbackError, { component: 'KeyManager' });
746
+ }
747
+ finally {
748
+ // Whatever happened, the cached verdict is no longer trustworthy.
749
+ KeyManager.invalidateCache();
750
+ }
751
+ }
653
752
  /**
654
753
  * Generate and securely store a new key pair on the device.
655
754
  *
@@ -981,17 +1080,23 @@ export class KeyManager {
981
1080
  }
982
1081
  }
983
1082
  /**
984
- * Restore identity from backup if primary storage is corrupted.
1083
+ * Restore identity from backup if primary storage is genuinely missing or
1084
+ * corrupt.
985
1085
  *
986
- * SAFETY: this method will NEVER overwrite a verifying primary identity.
987
- * If the primary passes a sign/verify probe, the backup is left untouched
988
- * and `false` is returned this protects against a transient
989
- * `verifyIdentityIntegrity()` blip clobbering valid keys with stale
990
- * backup keys (e.g., from a previous account before an import).
1086
+ * SAFETY (three independent guards against silently switching accounts):
1087
+ * 1. If the primary passes a full sign/verify probe, do nothing.
1088
+ * 2. If the primary keys CANNOT BE READ (storage threw — e.g. a transient
1089
+ * keychain lock during a background launch), do nothing. We must NOT
1090
+ * treat "couldn't read" as "corrupted" and restore a possibly-stale
1091
+ * backup over an identity that is actually fine but momentarily
1092
+ * inaccessible.
1093
+ * 3. If a primary private/public key IS present but does not match the
1094
+ * backup, the backup may belong to a different identity — refuse, so we
1095
+ * never silently switch the user to another account.
991
1096
  *
992
- * Additionally, if the backup public key does NOT match the (still-
993
- * present-but-failing) primary public key, we refuse to overwrite the
994
- * backup may belong to a different identity entirely.
1097
+ * Only when the primary is provably absent (read succeeded, returned
1098
+ * null/empty) or provably corrupt (read succeeded, bytes malformed AND no
1099
+ * conflicting key material is present) do we rebuild it from the backup.
995
1100
  */
996
1101
  static async restoreIdentityFromBackup() {
997
1102
  if (isWebPlatform()) {
@@ -999,20 +1104,36 @@ export class KeyManager {
999
1104
  }
1000
1105
  try {
1001
1106
  const store = await initSecureStore();
1002
- // First: if the primary still works, do nothing. Returning true here
1003
- // would be misleading; returning false (no restore needed) is the
1004
- // honest answer.
1005
- const primaryOk = await KeyManager.verifyIdentityIntegrity();
1006
- if (primaryOk) {
1107
+ // Read the primary DIRECTLY (not via the error-swallowing getters) so
1108
+ // we can distinguish a transient read failure from a genuinely absent
1109
+ // key. A thrown read here means the keychain is locked/unavailable —
1110
+ // bail out and let a later call retry rather than risk restoring over a
1111
+ // healthy-but-locked identity.
1112
+ let primaryPrivate;
1113
+ let primaryPublic;
1114
+ try {
1115
+ primaryPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1116
+ primaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1117
+ }
1118
+ catch (error) {
1119
+ logger.warn('restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.', { component: 'KeyManager' }, error);
1007
1120
  return false;
1008
1121
  }
1009
- // Check if backup exists
1122
+ // If the primary reads back as a complete, self-consistent identity, it
1123
+ // is healthy — nothing to restore. (Guard 1.)
1124
+ if (primaryPrivate && primaryPublic) {
1125
+ if (KeyManager.isValidPrivateKey(primaryPrivate) &&
1126
+ KeyManager.isValidPublicKey(primaryPublic) &&
1127
+ KeyManager.derivePublicKey(primaryPrivate).toLowerCase() === primaryPublic.toLowerCase()) {
1128
+ return false;
1129
+ }
1130
+ }
1131
+ // Load + validate the backup.
1010
1132
  const backupPrivateKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
1011
1133
  const backupPublicKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1012
1134
  if (!backupPrivateKey || !backupPublicKey) {
1013
1135
  return false; // No backup available
1014
1136
  }
1015
- // Verify backup integrity
1016
1137
  if (!KeyManager.isValidPrivateKey(backupPrivateKey) || !KeyManager.isValidPublicKey(backupPublicKey)) {
1017
1138
  logger.warn('Backup identity is malformed; refusing to restore', { component: 'KeyManager' });
1018
1139
  return false;
@@ -1025,14 +1146,21 @@ export class KeyManager {
1025
1146
  logger.warn('Backup public key does not match derived; refusing to restore', { component: 'KeyManager' });
1026
1147
  return false;
1027
1148
  }
1028
- // CRITICAL: if there is still a (broken) primary public key present
1029
- // that does NOT match the backup, the backup may be from a completely
1030
- // different identity. Better to surface a corrupted state than
1031
- // silently switch the user to a different account.
1032
- const currentPrimaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY).catch(() => null);
1033
- if (currentPrimaryPublic &&
1034
- currentPrimaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()) {
1035
- logger.error('Primary identity is corrupted AND does not match the backup. Refusing to restore to avoid switching accounts.', undefined, { component: 'KeyManager' });
1149
+ // Guard 3: if ANY primary key material is still present and identifies a
1150
+ // DIFFERENT identity than the backup, refuse — the backup may be from a
1151
+ // completely different account and restoring it would silently switch
1152
+ // the user. We check the private key too (not just the public): a
1153
+ // present private key that derives to a non-backup public means a real,
1154
+ // different identity is sitting in the primary slot.
1155
+ if (primaryPublic &&
1156
+ primaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()) {
1157
+ logger.error('Primary public key is present, corrupt-or-mismatched, AND differs from the backup. Refusing to restore to avoid switching accounts.', undefined, { component: 'KeyManager' });
1158
+ return false;
1159
+ }
1160
+ if (primaryPrivate &&
1161
+ KeyManager.isValidPrivateKey(primaryPrivate) &&
1162
+ KeyManager.derivePublicKey(primaryPrivate).toLowerCase() !== backupPublicKey.toLowerCase()) {
1163
+ logger.error('Primary private key identifies a DIFFERENT identity than the backup. Refusing to restore to avoid switching accounts.', undefined, { component: 'KeyManager' });
1036
1164
  return false;
1037
1165
  }
1038
1166
  // Safe to restore: rebuild the primary using the same atomic write
@@ -1,6 +1,38 @@
1
1
  import { OxyAuthenticationError } from '../OxyServices.errors.js';
2
2
  import { createDebugLogger } from '../shared/utils/debugUtils.js';
3
3
  const debug = createDebugLogger('FedCM');
4
+ // Modern (W3C spec) → legacy (Chrome 125–131) mode value mapping. Used to
5
+ // retry a credential request when an older browser rejects the modern enum.
6
+ const MODERN_TO_LEGACY_MODE = {
7
+ active: 'button',
8
+ passive: 'widget',
9
+ };
10
+ // Legacy → modern mapping so callers may pass either spelling.
11
+ const LEGACY_TO_MODERN_MODE = {
12
+ button: 'active',
13
+ widget: 'passive',
14
+ };
15
+ /**
16
+ * Normalise any accepted mode value to the modern W3C spelling
17
+ * (`'active'`/`'passive'`), which is what is sent to the browser first.
18
+ */
19
+ function toModernMode(mode) {
20
+ return mode === 'button' || mode === 'widget' ? LEGACY_TO_MODERN_MODE[mode] : mode;
21
+ }
22
+ /**
23
+ * Detect the synchronous `TypeError` a pre-spec browser throws when it does not
24
+ * recognise a modern `mode` enum value (e.g. Chrome 125–131 rejecting
25
+ * `'active'`/`'passive'`). Such a browser only understands the legacy
26
+ * `'button'`/`'widget'` values, so the caller can retry with those.
27
+ */
28
+ function isUnknownModeEnumError(error) {
29
+ if (!(error instanceof TypeError))
30
+ return false;
31
+ const message = error.message.toLowerCase();
32
+ return (message.includes('identitycredentialrequestoptionsmode') ||
33
+ ((message.includes('active') || message.includes('passive')) &&
34
+ (message.includes('enum') || message.includes('not a valid'))));
35
+ }
4
36
  const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
5
37
  // Global lock to prevent concurrent FedCM requests
6
38
  // FedCM only allows one navigator.credentials.get request at a time
@@ -82,20 +114,25 @@ export function OxyServicesFedCMMixin(Base) {
82
114
  throw new OxyAuthenticationError('FedCM not supported in this browser. Please update your browser or use an alternative sign-in method.');
83
115
  }
84
116
  try {
85
- const nonce = options.nonce || this.generateNonce();
117
+ // Prefer a server-minted, origin-bound nonce so the downstream
118
+ // `/fedcm/exchange` can validate it. A caller-supplied nonce is
119
+ // respected as-is for advanced use cases.
120
+ const nonce = options.nonce || (await this.getFedcmNonce());
86
121
  const clientId = this.getClientId();
87
122
  // Use provided loginHint, or fall back to stored last-used account ID
88
123
  const loginHint = options.loginHint || this.getStoredLoginHint();
89
124
  debug.log('Interactive sign-in: Requesting credential for', clientId, loginHint ? `(hint: ${loginHint})` : '');
90
- // Request credential from browser's native identity flow
91
- // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
125
+ // Request credential from browser's native identity flow.
126
+ // mode: 'active' signals this is a user-gesture-initiated (button) flow.
127
+ // 'active' is the current W3C spec value; requestIdentityCredential
128
+ // transparently retries with the legacy 'button' value for Chrome 125–131.
92
129
  const credential = await this.requestIdentityCredential({
93
130
  configURL: this.resolveFedcmConfigUrl(),
94
131
  clientId,
95
132
  nonce,
96
133
  context: options.context,
97
134
  loginHint,
98
- mode: 'button',
135
+ mode: 'active',
99
136
  });
100
137
  if (!credential || !credential.token) {
101
138
  throw new OxyAuthenticationError('No credential received from browser');
@@ -177,7 +214,9 @@ export function OxyServicesFedCMMixin(Base) {
177
214
  let credential = null;
178
215
  const loginHint = this.getStoredLoginHint();
179
216
  try {
180
- const nonce = this.generateNonce();
217
+ // Server-minted, origin-bound nonce required for `/fedcm/exchange`
218
+ // to accept the resulting ID token (anti-replay binding).
219
+ const nonce = await this.getFedcmNonce();
181
220
  debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
182
221
  credential = await this.requestIdentityCredential({
183
222
  configURL: this.resolveFedcmConfigUrl(),
@@ -301,37 +340,63 @@ export function OxyServicesFedCMMixin(Base) {
301
340
  debug.log('Request timed out after', timeoutMs, 'ms (mediation:', requestedMediation + ')');
302
341
  controller.abort();
303
342
  }, timeoutMs);
343
+ // Normalise the caller's mode to the modern W3C value first. A modern
344
+ // browser accepts it; an older one (Chrome 125–131) rejects it with a
345
+ // synchronous TypeError, in which case we retry with the legacy value.
346
+ const modernMode = options.mode ? toModernMode(options.mode) : undefined;
347
+ // Build the identity request for a specific mode value. The `mode` field
348
+ // lives on the `identity` object (sibling of `providers`), separate from
349
+ // the top-level `mediation` field.
350
+ const buildCredentialOptions = (modeValue) => ({
351
+ identity: {
352
+ providers: [
353
+ {
354
+ configURL: options.configURL,
355
+ clientId: options.clientId,
356
+ // Older browsers read `nonce` at the top level; Chrome 145+
357
+ // expects it inside `params`. Send both for full coverage.
358
+ nonce: options.nonce,
359
+ params: {
360
+ nonce: options.nonce,
361
+ },
362
+ ...(options.loginHint && { loginHint: options.loginHint }),
363
+ },
364
+ ],
365
+ ...(modeValue && { mode: modeValue }),
366
+ },
367
+ mediation: requestedMediation,
368
+ signal: controller.signal,
369
+ });
370
+ // The DOM lib's `CredentialsContainer` does not declare the FedCM `identity`
371
+ // request in every TypeScript version we build against. Re-type through the
372
+ // minimal structural interface above (not `any`) to keep this typed.
373
+ const credentials = navigator.credentials;
304
374
  fedCMRequestPromise = (async () => {
305
375
  try {
306
- debug.log('Calling navigator.credentials.get with mediation:', requestedMediation);
307
- // Type assertion needed as FedCM types may not be in all TypeScript versions
308
- const credentialOptions = {
309
- identity: {
310
- providers: [
311
- {
312
- configURL: options.configURL,
313
- clientId: options.clientId,
314
- // Older browsers read `nonce` at the top level; Chrome 145+
315
- // expects it inside `params`. Send both for full coverage.
316
- nonce: options.nonce,
317
- params: {
318
- nonce: options.nonce,
319
- },
320
- ...(options.loginHint && { loginHint: options.loginHint }),
321
- },
322
- ],
323
- ...(options.mode && { mode: options.mode }),
324
- },
325
- mediation: requestedMediation,
326
- signal: controller.signal,
327
- };
328
- const credential = (await navigator.credentials.get(credentialOptions));
376
+ debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
377
+ let credential;
378
+ try {
379
+ credential = await credentials.get(buildCredentialOptions(modernMode));
380
+ }
381
+ catch (modeError) {
382
+ // Chrome 125–131 only knows the legacy 'button'/'widget' enum and
383
+ // throws a synchronous TypeError for the modern 'active'/'passive'
384
+ // values. Retry once with the legacy value so older browsers work.
385
+ if (modernMode && isUnknownModeEnumError(modeError)) {
386
+ const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
387
+ debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
388
+ credential = await credentials.get(buildCredentialOptions(legacyMode));
389
+ }
390
+ else {
391
+ throw modeError;
392
+ }
393
+ }
329
394
  debug.log('navigator.credentials.get returned:', {
330
395
  hasCredential: !!credential,
331
396
  type: credential?.type,
332
397
  hasToken: !!credential?.token,
333
398
  });
334
- if (!credential || credential.type !== 'identity') {
399
+ if (!credential || credential.type !== 'identity' || !credential.token) {
335
400
  debug.log('No valid identity credential returned');
336
401
  return null;
337
402
  }
@@ -419,7 +484,14 @@ export function OxyServicesFedCMMixin(Base) {
419
484
  };
420
485
  }
421
486
  /**
422
- * Generate a cryptographically secure nonce for FedCM
487
+ * Generate a cryptographically secure local nonce for FedCM.
488
+ *
489
+ * NOTE: this is a *local* fallback only. The server-side `/fedcm/exchange`
490
+ * endpoint requires the nonce embedded in the ID token to have been minted
491
+ * by `POST /fedcm/nonce` (see {@link mintServerNonce}) and bound to this
492
+ * origin. A purely local nonce will be rejected with `invalid_nonce`. Use
493
+ * {@link getFedcmNonce}, which prefers a server-minted nonce and only falls
494
+ * back to this generator when the mint endpoint is unreachable.
423
495
  *
424
496
  * @private
425
497
  */
@@ -434,6 +506,49 @@ export function OxyServicesFedCMMixin(Base) {
434
506
  }
435
507
  throw new Error('No secure random source available for nonce generation');
436
508
  }
509
+ /**
510
+ * Mint a single-use, origin-bound nonce from the Oxy API.
511
+ *
512
+ * The FedCM ID token issued by the IdP embeds this nonce as the `nonce`
513
+ * claim. When the consuming app calls `POST /fedcm/exchange`, the API burns
514
+ * the nonce (atomic `usedAt` transition) and verifies it was minted for the
515
+ * same origin as the token `aud`. This is the anti-replay binding required
516
+ * by the API's H9 hardening — without a server-minted nonce the exchange
517
+ * always fails.
518
+ *
519
+ * The browser attaches the `Origin` header automatically on this
520
+ * cross-origin request, so the API binds the nonce to the calling app's
521
+ * origin (which also becomes the FedCM `clientId`/token `aud`).
522
+ *
523
+ * @private
524
+ */
525
+ async mintServerNonce() {
526
+ const result = await this.makeRequest('POST', '/fedcm/nonce', {}, { cache: false });
527
+ if (!result?.nonce) {
528
+ throw new OxyAuthenticationError('FedCM nonce endpoint returned no nonce');
529
+ }
530
+ return result.nonce;
531
+ }
532
+ /**
533
+ * Resolve the nonce to use for a FedCM credential request.
534
+ *
535
+ * Prefers a server-minted, origin-bound nonce (required for the token
536
+ * exchange to succeed). If the mint endpoint is unreachable we fall back to
537
+ * a locally generated nonce so the browser flow can still proceed; the
538
+ * exchange may then fail server-side, but that is strictly better than
539
+ * throwing before the browser ever shows its UI.
540
+ *
541
+ * @private
542
+ */
543
+ async getFedcmNonce() {
544
+ try {
545
+ return await this.mintServerNonce();
546
+ }
547
+ catch (error) {
548
+ debug.warn('Could not mint server nonce, falling back to local nonce:', error instanceof Error ? error.message : String(error));
549
+ return this.generateNonce();
550
+ }
551
+ }
437
552
  /**
438
553
  * Get the client ID for this origin
439
554
  *