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