@oxyhq/core 1.11.17 → 1.11.18

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.
@@ -637,16 +637,28 @@ export class KeyManager {
637
637
  /**
638
638
  * Atomically persist a key pair to secure storage with verification + backup.
639
639
  *
640
- * Write order is critical:
641
- * 1. Backup (BACKUP_PRIVATE_KEY + BACKUP_PUBLIC_KEY + BACKUP_TIMESTAMP)
642
- * 2. Primary public key
643
- * 3. Primary private key (last so a partial write leaves us in a known
644
- * "no identity yet" stateeasier to retry than a half-written one)
645
- * 4. Read back + sign/verify to confirm the storage round-trip works
640
+ * INVARIANT (the reason this method exists): at no instant during the write
641
+ * may the device be left holding ZERO recoverable copies of a healthy
642
+ * identity. This matters most on the OVERWRITE / account-switch path: if we
643
+ * are replacing identity A with B and the write fails halfway, we MUST end
644
+ * up back on A never on a half-written B, and never on nothing.
646
645
  *
647
- * If any step throws, the caller sees the error AND any partial state is
648
- * cleaned up so the device is left either fully consistent or fully empty.
649
- * It never leaves an unusable half-identity that would fool `hasIdentity()`.
646
+ * Algorithm (recoverability-preserving):
647
+ * 0. Snapshot the existing primary (privA, pubA) so we can roll back to
648
+ * EXACTLY what was there.
649
+ * 1. Write the new primary: public first, then private.
650
+ * 2. Read back + sign/verify the new primary.
651
+ * 3. ONLY after the new primary is proven durable, refresh the backup to
652
+ * the new key. The backup is NEVER touched before this point, so any
653
+ * prior identity's backup remains intact and `restoreIdentityFromBackup`
654
+ * can always recover it.
655
+ * 4. On ANY failure in steps 1–2, restore the snapshotted primary verbatim
656
+ * (or delete it if there was none), then surface the error.
657
+ *
658
+ * Earlier versions wrote the *incoming* key to the backup FIRST, which
659
+ * destroyed the previous identity's backup, and rolled back by blindly
660
+ * deleting the primary — so a failed overwrite silently switched the user
661
+ * to (or lost them into) the half-written new identity. That is fixed here.
650
662
  *
651
663
  * @internal
652
664
  */
@@ -663,23 +675,64 @@ export class KeyManager {
663
675
  const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
664
676
  const canonicalPublic = publicKey.toLowerCase();
665
677
 
666
- // Step 1: Backup BEFORE touching primary storage so we always have a
667
- // recoverable copy even if the device crashes mid-write. Store the
668
- // backup in canonical form too so a backup-restore cycle preserves
669
- // canonicalization.
678
+ // Step 0: Snapshot the existing primary so a failed write can be rolled
679
+ // back to EXACTLY the prior state. If the read itself fails we treat the
680
+ // prior primary as unknown and refuse to proceed overwriting blind
681
+ // would risk clobbering an identity we just couldn't see (e.g. a
682
+ // transient keychain lock).
683
+ let priorPrivate: string | null;
684
+ let priorPublic: string | null;
670
685
  try {
671
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
672
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
673
- });
674
- await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
675
- await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
686
+ priorPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
687
+ priorPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
676
688
  } catch (error) {
677
- logger.error('Failed to write identity backup before primary', error, { component: 'KeyManager' });
678
- throw new IdentityPersistError('Failed to write identity backup', error);
689
+ logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
690
+ throw new IdentityPersistError(
691
+ 'Could not read existing identity before writing a new one; refusing to overwrite blind.',
692
+ error,
693
+ );
679
694
  }
680
695
 
681
- // Step 2 + 3: Write primary keys. Public first so that if private write
682
- // fails we are still missing the most critical bit.
696
+ // If we are replacing a DIFFERENT, currently-healthy identity, make sure
697
+ // it is recoverable from the backup slot BEFORE we overwrite the primary.
698
+ // We only do this when the existing backup does not already hold that
699
+ // identity — otherwise we would needlessly churn the keychain. This keeps
700
+ // the "always at least one recoverable copy" invariant intact across the
701
+ // window where the primary briefly holds the new key but the new backup
702
+ // has not been written yet.
703
+ const priorIsHealthyDifferent =
704
+ !!priorPrivate &&
705
+ !!priorPublic &&
706
+ priorPublic.toLowerCase() !== canonicalPublic &&
707
+ KeyManager.isValidPrivateKey(priorPrivate) &&
708
+ KeyManager.isValidPublicKey(priorPublic) &&
709
+ KeyManager.derivePublicKey(priorPrivate).toLowerCase() === priorPublic.toLowerCase();
710
+
711
+ if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
712
+ let existingBackupPublic: string | null = null;
713
+ try {
714
+ existingBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
715
+ } catch {
716
+ existingBackupPublic = null;
717
+ }
718
+ if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
719
+ try {
720
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, KeyManager.canonicalPrivateKey(priorPrivate), {
721
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
722
+ });
723
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorPublic.toLowerCase());
724
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
725
+ } catch (error) {
726
+ logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
727
+ throw new IdentityPersistError('Failed to back up existing identity before overwrite', error);
728
+ }
729
+ }
730
+ }
731
+
732
+ // Step 1: Write the new primary. Public first so that if the private write
733
+ // fails we are missing the most critical bit. The backup is intentionally
734
+ // NOT touched here — it still holds the previous good identity until the
735
+ // new primary is proven durable.
683
736
  try {
684
737
  await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, canonicalPublic);
685
738
  await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, canonicalPrivate, {
@@ -687,13 +740,11 @@ export class KeyManager {
687
740
  });
688
741
  } catch (error) {
689
742
  logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
690
- // Roll back the public-key half-write so hasIdentity() doesn't lie later.
691
- try { await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY); } catch { /* best effort */ }
692
- try { await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY); } catch { /* best effort */ }
743
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
693
744
  throw new IdentityPersistError('Failed to write identity to secure store', error);
694
745
  }
695
746
 
696
- // Step 4: Verify round-trip. If the store silently drops our writes
747
+ // Step 2: Verify round-trip. If the store silently drops our writes
697
748
  // (e.g., a misconfigured keychain access group), we MUST surface it
698
749
  // before declaring success — otherwise the caller will think the
699
750
  // identity was saved and discard the in-memory copy.
@@ -704,6 +755,7 @@ export class KeyManager {
704
755
  readBackPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
705
756
  } catch (error) {
706
757
  logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
758
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
707
759
  throw new IdentityPersistError('Failed to verify identity after write', error);
708
760
  }
709
761
 
@@ -715,6 +767,7 @@ export class KeyManager {
715
767
  readBackPublic?.toLowerCase() !== canonicalPublic
716
768
  ) {
717
769
  logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
770
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
718
771
  throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
719
772
  }
720
773
 
@@ -735,16 +788,72 @@ export class KeyManager {
735
788
  throw new IdentityPersistError('Sign/verify roundtrip failed for newly stored identity.');
736
789
  }
737
790
  } catch (error) {
791
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
738
792
  if (error instanceof IdentityPersistError) throw error;
739
793
  logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
740
794
  throw new IdentityPersistError('Stored identity failed crypto self-test', error);
741
795
  }
742
796
 
797
+ // Step 3: The new primary is durable and functional. NOW it is safe to
798
+ // refresh the backup to the new key. If this final backup write fails the
799
+ // user still has a fully working primary, and the backup still holds the
800
+ // PREVIOUS good identity — so we log and continue rather than failing the
801
+ // whole operation (failing here would be strictly worse: a working
802
+ // primary would be reported as an error to the caller).
803
+ try {
804
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
805
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
806
+ });
807
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
808
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
809
+ } catch (error) {
810
+ logger.warn(
811
+ 'Primary identity persisted successfully but refreshing the backup failed; primary is usable, backup may be stale',
812
+ { component: 'KeyManager' },
813
+ error,
814
+ );
815
+ }
816
+
743
817
  // Update cache only after we are certain the identity is durable.
744
818
  KeyManager.cachedPublicKey = canonicalPublic;
745
819
  KeyManager.cachedHasIdentity = true;
746
820
  }
747
821
 
822
+ /**
823
+ * Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
824
+ * or delete it entirely if there was no prior identity. Best-effort: every
825
+ * step is wrapped so a rollback failure never masks the original error the
826
+ * caller is about to throw. Invalidates the in-memory cache so the next read
827
+ * reflects whatever actually landed on disk.
828
+ *
829
+ * @internal
830
+ */
831
+ private static async _rollbackPrimary(
832
+ store: Awaited<ReturnType<typeof initSecureStore>>,
833
+ priorPrivate: string | null,
834
+ priorPublic: string | null,
835
+ ): Promise<void> {
836
+ try {
837
+ if (priorPrivate && priorPublic) {
838
+ // Restore exactly what was there before the failed write.
839
+ await store.setItemAsync(STORAGE_KEYS.PUBLIC_KEY, priorPublic, {});
840
+ await store.setItemAsync(STORAGE_KEYS.PRIVATE_KEY, priorPrivate, {
841
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
842
+ });
843
+ } else {
844
+ // There was no prior identity — leave the device empty rather than
845
+ // half-written so hasIdentity() does not lie.
846
+ try { await store.deleteItemAsync(STORAGE_KEYS.PUBLIC_KEY); } catch { /* best effort */ }
847
+ try { await store.deleteItemAsync(STORAGE_KEYS.PRIVATE_KEY); } catch { /* best effort */ }
848
+ }
849
+ } catch (rollbackError) {
850
+ logger.error('Failed to roll back primary identity after a failed write', rollbackError, { component: 'KeyManager' });
851
+ } finally {
852
+ // Whatever happened, the cached verdict is no longer trustworthy.
853
+ KeyManager.invalidateCache();
854
+ }
855
+ }
856
+
748
857
  /**
749
858
  * Generate and securely store a new key pair on the device.
750
859
  *
@@ -1122,17 +1231,23 @@ export class KeyManager {
1122
1231
  }
1123
1232
 
1124
1233
  /**
1125
- * Restore identity from backup if primary storage is corrupted.
1234
+ * Restore identity from backup if primary storage is genuinely missing or
1235
+ * corrupt.
1126
1236
  *
1127
- * SAFETY: this method will NEVER overwrite a verifying primary identity.
1128
- * If the primary passes a sign/verify probe, the backup is left untouched
1129
- * and `false` is returned this protects against a transient
1130
- * `verifyIdentityIntegrity()` blip clobbering valid keys with stale
1131
- * backup keys (e.g., from a previous account before an import).
1237
+ * SAFETY (three independent guards against silently switching accounts):
1238
+ * 1. If the primary passes a full sign/verify probe, do nothing.
1239
+ * 2. If the primary keys CANNOT BE READ (storage threw — e.g. a transient
1240
+ * keychain lock during a background launch), do nothing. We must NOT
1241
+ * treat "couldn't read" as "corrupted" and restore a possibly-stale
1242
+ * backup over an identity that is actually fine but momentarily
1243
+ * inaccessible.
1244
+ * 3. If a primary private/public key IS present but does not match the
1245
+ * backup, the backup may belong to a different identity — refuse, so we
1246
+ * never silently switch the user to another account.
1132
1247
  *
1133
- * Additionally, if the backup public key does NOT match the (still-
1134
- * present-but-failing) primary public key, we refuse to overwrite the
1135
- * backup may belong to a different identity entirely.
1248
+ * Only when the primary is provably absent (read succeeded, returned
1249
+ * null/empty) or provably corrupt (read succeeded, bytes malformed AND no
1250
+ * conflicting key material is present) do we rebuild it from the backup.
1136
1251
  */
1137
1252
  static async restoreIdentityFromBackup(): Promise<boolean> {
1138
1253
  if (isWebPlatform()) {
@@ -1141,15 +1256,38 @@ export class KeyManager {
1141
1256
  try {
1142
1257
  const store = await initSecureStore();
1143
1258
 
1144
- // First: if the primary still works, do nothing. Returning true here
1145
- // would be misleading; returning false (no restore needed) is the
1146
- // honest answer.
1147
- const primaryOk = await KeyManager.verifyIdentityIntegrity();
1148
- if (primaryOk) {
1259
+ // Read the primary DIRECTLY (not via the error-swallowing getters) so
1260
+ // we can distinguish a transient read failure from a genuinely absent
1261
+ // key. A thrown read here means the keychain is locked/unavailable —
1262
+ // bail out and let a later call retry rather than risk restoring over a
1263
+ // healthy-but-locked identity.
1264
+ let primaryPrivate: string | null;
1265
+ let primaryPublic: string | null;
1266
+ try {
1267
+ primaryPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
1268
+ primaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
1269
+ } catch (error) {
1270
+ logger.warn(
1271
+ 'restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.',
1272
+ { component: 'KeyManager' },
1273
+ error,
1274
+ );
1149
1275
  return false;
1150
1276
  }
1151
1277
 
1152
- // Check if backup exists
1278
+ // If the primary reads back as a complete, self-consistent identity, it
1279
+ // is healthy — nothing to restore. (Guard 1.)
1280
+ if (primaryPrivate && primaryPublic) {
1281
+ if (
1282
+ KeyManager.isValidPrivateKey(primaryPrivate) &&
1283
+ KeyManager.isValidPublicKey(primaryPublic) &&
1284
+ KeyManager.derivePublicKey(primaryPrivate).toLowerCase() === primaryPublic.toLowerCase()
1285
+ ) {
1286
+ return false;
1287
+ }
1288
+ }
1289
+
1290
+ // Load + validate the backup.
1153
1291
  const backupPrivateKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
1154
1292
  const backupPublicKey = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
1155
1293
 
@@ -1157,7 +1295,6 @@ export class KeyManager {
1157
1295
  return false; // No backup available
1158
1296
  }
1159
1297
 
1160
- // Verify backup integrity
1161
1298
  if (!KeyManager.isValidPrivateKey(backupPrivateKey) || !KeyManager.isValidPublicKey(backupPublicKey)) {
1162
1299
  logger.warn('Backup identity is malformed; refusing to restore', { component: 'KeyManager' });
1163
1300
  return false;
@@ -1172,17 +1309,30 @@ export class KeyManager {
1172
1309
  return false;
1173
1310
  }
1174
1311
 
1175
- // CRITICAL: if there is still a (broken) primary public key present
1176
- // that does NOT match the backup, the backup may be from a completely
1177
- // different identity. Better to surface a corrupted state than
1178
- // silently switch the user to a different account.
1179
- const currentPrimaryPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY).catch(() => null);
1312
+ // Guard 3: if ANY primary key material is still present and identifies a
1313
+ // DIFFERENT identity than the backup, refuse — the backup may be from a
1314
+ // completely different account and restoring it would silently switch
1315
+ // the user. We check the private key too (not just the public): a
1316
+ // present private key that derives to a non-backup public means a real,
1317
+ // different identity is sitting in the primary slot.
1318
+ if (
1319
+ primaryPublic &&
1320
+ primaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()
1321
+ ) {
1322
+ logger.error(
1323
+ 'Primary public key is present, corrupt-or-mismatched, AND differs from the backup. Refusing to restore to avoid switching accounts.',
1324
+ undefined,
1325
+ { component: 'KeyManager' },
1326
+ );
1327
+ return false;
1328
+ }
1180
1329
  if (
1181
- currentPrimaryPublic &&
1182
- currentPrimaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()
1330
+ primaryPrivate &&
1331
+ KeyManager.isValidPrivateKey(primaryPrivate) &&
1332
+ KeyManager.derivePublicKey(primaryPrivate).toLowerCase() !== backupPublicKey.toLowerCase()
1183
1333
  ) {
1184
1334
  logger.error(
1185
- 'Primary identity is corrupted AND does not match the backup. Refusing to restore to avoid switching accounts.',
1335
+ 'Primary private key identifies a DIFFERENT identity than the backup. Refusing to restore to avoid switching accounts.',
1186
1336
  undefined,
1187
1337
  { component: 'KeyManager' },
1188
1338
  );
@@ -109,7 +109,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
109
109
  }
110
110
 
111
111
  try {
112
- const nonce = options.nonce || this.generateNonce();
112
+ // Prefer a server-minted, origin-bound nonce so the downstream
113
+ // `/fedcm/exchange` can validate it. A caller-supplied nonce is
114
+ // respected as-is for advanced use cases.
115
+ const nonce = options.nonce || (await this.getFedcmNonce());
113
116
  const clientId = this.getClientId();
114
117
 
115
118
  // Use provided loginHint, or fall back to stored last-used account ID
@@ -219,7 +222,9 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
219
222
  const loginHint = this.getStoredLoginHint();
220
223
 
221
224
  try {
222
- const nonce = this.generateNonce();
225
+ // Server-minted, origin-bound nonce required for `/fedcm/exchange`
226
+ // to accept the resulting ID token (anti-replay binding).
227
+ const nonce = await this.getFedcmNonce();
223
228
  debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
224
229
 
225
230
  credential = await this.requestIdentityCredential({
@@ -494,7 +499,14 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
494
499
  }
495
500
 
496
501
  /**
497
- * Generate a cryptographically secure nonce for FedCM
502
+ * Generate a cryptographically secure local nonce for FedCM.
503
+ *
504
+ * NOTE: this is a *local* fallback only. The server-side `/fedcm/exchange`
505
+ * endpoint requires the nonce embedded in the ID token to have been minted
506
+ * by `POST /fedcm/nonce` (see {@link mintServerNonce}) and bound to this
507
+ * origin. A purely local nonce will be rejected with `invalid_nonce`. Use
508
+ * {@link getFedcmNonce}, which prefers a server-minted nonce and only falls
509
+ * back to this generator when the mint endpoint is unreachable.
498
510
  *
499
511
  * @private
500
512
  */
@@ -510,6 +522,58 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
510
522
  throw new Error('No secure random source available for nonce generation');
511
523
  }
512
524
 
525
+ /**
526
+ * Mint a single-use, origin-bound nonce from the Oxy API.
527
+ *
528
+ * The FedCM ID token issued by the IdP embeds this nonce as the `nonce`
529
+ * claim. When the consuming app calls `POST /fedcm/exchange`, the API burns
530
+ * the nonce (atomic `usedAt` transition) and verifies it was minted for the
531
+ * same origin as the token `aud`. This is the anti-replay binding required
532
+ * by the API's H9 hardening — without a server-minted nonce the exchange
533
+ * always fails.
534
+ *
535
+ * The browser attaches the `Origin` header automatically on this
536
+ * cross-origin request, so the API binds the nonce to the calling app's
537
+ * origin (which also becomes the FedCM `clientId`/token `aud`).
538
+ *
539
+ * @private
540
+ */
541
+ public async mintServerNonce(): Promise<string> {
542
+ const result = await this.makeRequest<{ nonce: string; expiresAt: string }>(
543
+ 'POST',
544
+ '/fedcm/nonce',
545
+ {},
546
+ { cache: false }
547
+ );
548
+ if (!result?.nonce) {
549
+ throw new OxyAuthenticationError('FedCM nonce endpoint returned no nonce');
550
+ }
551
+ return result.nonce;
552
+ }
553
+
554
+ /**
555
+ * Resolve the nonce to use for a FedCM credential request.
556
+ *
557
+ * Prefers a server-minted, origin-bound nonce (required for the token
558
+ * exchange to succeed). If the mint endpoint is unreachable we fall back to
559
+ * a locally generated nonce so the browser flow can still proceed; the
560
+ * exchange may then fail server-side, but that is strictly better than
561
+ * throwing before the browser ever shows its UI.
562
+ *
563
+ * @private
564
+ */
565
+ public async getFedcmNonce(): Promise<string> {
566
+ try {
567
+ return await this.mintServerNonce();
568
+ } catch (error) {
569
+ debug.warn(
570
+ 'Could not mint server nonce, falling back to local nonce:',
571
+ error instanceof Error ? error.message : String(error)
572
+ );
573
+ return this.generateNonce();
574
+ }
575
+ }
576
+
513
577
  /**
514
578
  * Get the client ID for this origin
515
579
  *
@@ -0,0 +1,187 @@
1
+ /**
2
+ * FedCM mixin regression tests.
3
+ *
4
+ * Locks in the fix for the broken SSO token exchange: the API's H9 hardening
5
+ * (commit 21af7c48) made `/fedcm/exchange` require a server-minted,
6
+ * origin-bound nonce, but the SDK was still generating a purely local nonce
7
+ * that the API always rejected with `invalid_nonce`. These tests assert that
8
+ * both the silent and interactive FedCM flows now:
9
+ *
10
+ * 1. mint a nonce from `POST /fedcm/nonce` and pass THAT nonce (not a local
11
+ * UUID) to `navigator.credentials.get`;
12
+ * 2. fall back to a local nonce if the mint endpoint is unreachable, rather
13
+ * than throwing before the browser UI can show;
14
+ * 3. resolve silent SSO cleanly to `null` (never throw into a retry loop)
15
+ * when the browser returns no credential or rejects the request.
16
+ *
17
+ * The browser globals (`window`, `navigator.credentials`, `IdentityCredential`)
18
+ * are stubbed so the platform-agnostic mixin can run under the node test env.
19
+ */
20
+
21
+ import { OxyServices } from '../../OxyServices';
22
+
23
+ interface CredentialGetCall {
24
+ identity: {
25
+ providers: Array<{ configURL: string; clientId: string; nonce: string; params?: { nonce?: string } }>;
26
+ mode?: string;
27
+ };
28
+ mediation: string;
29
+ }
30
+
31
+ const ORIGIN = 'https://accounts.oxy.so';
32
+
33
+ function installBrowserGlobals(options: {
34
+ credentialsGet: (opts: CredentialGetCall) => Promise<unknown>;
35
+ }): void {
36
+ const store = new Map<string, string>();
37
+ const localStorageStub = {
38
+ getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
39
+ setItem: (k: string, v: string) => { store.set(k, v); },
40
+ removeItem: (k: string) => { store.delete(k); },
41
+ };
42
+ const nav = {
43
+ credentials: {
44
+ get: (opts: CredentialGetCall) => options.credentialsGet(opts),
45
+ },
46
+ };
47
+ // `isFedCMSupported()` checks: 'IdentityCredential' in window &&
48
+ // 'navigator' in window && 'credentials' in navigator. The stub must expose
49
+ // all three the way a real browser does.
50
+ const win = {
51
+ location: { origin: ORIGIN, hostname: 'accounts.oxy.so' },
52
+ IdentityCredential: function IdentityCredential() {},
53
+ navigator: nav,
54
+ localStorage: localStorageStub,
55
+ };
56
+ (globalThis as unknown as { window: unknown }).window = win;
57
+ (globalThis as unknown as { navigator: unknown }).navigator = nav;
58
+ (globalThis as unknown as { localStorage: unknown }).localStorage = localStorageStub;
59
+ (globalThis as unknown as { IdentityCredential: unknown }).IdentityCredential = win.IdentityCredential;
60
+ }
61
+
62
+ function clearBrowserGlobals(): void {
63
+ for (const key of ['window', 'navigator', 'localStorage', 'IdentityCredential'] as const) {
64
+ delete (globalThis as Record<string, unknown>)[key];
65
+ }
66
+ }
67
+
68
+ describe('OxyServices FedCM nonce binding', () => {
69
+ afterEach(() => {
70
+ clearBrowserGlobals();
71
+ jest.restoreAllMocks();
72
+ });
73
+
74
+ it('silent SSO mints a server nonce and forwards it to the browser', async () => {
75
+ let credentialCall: CredentialGetCall | null = null;
76
+ installBrowserGlobals({
77
+ credentialsGet: async (opts) => {
78
+ credentialCall = opts;
79
+ // Browser returns no credential (user not logged in at IdP)
80
+ return null;
81
+ },
82
+ });
83
+
84
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
85
+ const makeRequest = jest
86
+ .spyOn(oxy, 'makeRequest')
87
+ .mockImplementation(async (_method: string, url: string) => {
88
+ if (url === '/fedcm/nonce') {
89
+ return { nonce: 'server-minted-nonce-123', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
90
+ }
91
+ throw new Error(`unexpected request to ${url}`);
92
+ });
93
+
94
+ const result = await oxy.silentSignInWithFedCM();
95
+
96
+ expect(result).toBeNull();
97
+ // The mint endpoint was hit
98
+ expect(makeRequest).toHaveBeenCalledWith('POST', '/fedcm/nonce', {}, { cache: false });
99
+ // The server nonce — not a random UUID — was passed to the browser
100
+ expect(credentialCall).not.toBeNull();
101
+ const call = credentialCall as unknown as CredentialGetCall;
102
+ expect(call.identity.providers[0].nonce).toBe('server-minted-nonce-123');
103
+ expect(call.identity.providers[0].params?.nonce).toBe('server-minted-nonce-123');
104
+ expect(call.mediation).toBe('silent');
105
+ });
106
+
107
+ it('interactive sign-in mints a server nonce and exchanges the returned token', async () => {
108
+ installBrowserGlobals({
109
+ credentialsGet: async () => ({ type: 'identity', token: 'idp-id-token', isAutoSelected: false }),
110
+ });
111
+
112
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
113
+ const exchanged: string[] = [];
114
+ jest
115
+ .spyOn(oxy, 'makeRequest')
116
+ .mockImplementation(async (_method: string, url: string, data?: unknown) => {
117
+ if (url === '/fedcm/nonce') {
118
+ return { nonce: 'server-minted-nonce-xyz', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
119
+ }
120
+ if (url === '/fedcm/exchange') {
121
+ exchanged.push((data as { id_token: string }).id_token);
122
+ return {
123
+ sessionId: 'sess_1',
124
+ deviceId: 'dev_1',
125
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
126
+ accessToken: 'access_1',
127
+ user: { id: 'user_1', username: 'tester' },
128
+ } as never;
129
+ }
130
+ throw new Error(`unexpected request to ${url}`);
131
+ });
132
+
133
+ const session = await oxy.signInWithFedCM();
134
+
135
+ expect(session.sessionId).toBe('sess_1');
136
+ // The browser-issued token was exchanged for a session
137
+ expect(exchanged).toEqual(['idp-id-token']);
138
+ });
139
+
140
+ it('falls back to a local nonce when the mint endpoint is unreachable', async () => {
141
+ let credentialCall: CredentialGetCall | null = null;
142
+ installBrowserGlobals({
143
+ credentialsGet: async (opts) => {
144
+ credentialCall = opts;
145
+ return null;
146
+ },
147
+ });
148
+
149
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
150
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
151
+ if (url === '/fedcm/nonce') {
152
+ throw new Error('network down');
153
+ }
154
+ throw new Error(`unexpected request to ${url}`);
155
+ });
156
+
157
+ const result = await oxy.silentSignInWithFedCM();
158
+
159
+ // Did not throw; resolved cleanly to null
160
+ expect(result).toBeNull();
161
+ // Still passed a (locally generated) non-empty nonce to the browser
162
+ expect(credentialCall).not.toBeNull();
163
+ const call = credentialCall as unknown as CredentialGetCall;
164
+ expect(typeof call.identity.providers[0].nonce).toBe('string');
165
+ expect(call.identity.providers[0].nonce.length).toBeGreaterThan(0);
166
+ });
167
+
168
+ it('silent SSO resolves to null (no throw) when the browser rejects', async () => {
169
+ installBrowserGlobals({
170
+ credentialsGet: async () => {
171
+ const err = new Error('User not signed in');
172
+ err.name = 'NotAllowedError';
173
+ throw err;
174
+ },
175
+ });
176
+
177
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
178
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
179
+ if (url === '/fedcm/nonce') {
180
+ return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
181
+ }
182
+ throw new Error(`unexpected request to ${url}`);
183
+ });
184
+
185
+ await expect(oxy.silentSignInWithFedCM()).resolves.toBeNull();
186
+ });
187
+ });