@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/keyManager.js +184 -56
- package/dist/cjs/mixins/OxyServices.fedcm.js +145 -30
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +184 -56
- package/dist/esm/mixins/OxyServices.fedcm.js +145 -30
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +49 -21
- package/dist/types/mixins/OxyServices.fedcm.d.ts +56 -2
- package/package.json +1 -1
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +214 -0
- package/src/crypto/keyManager.ts +200 -50
- package/src/mixins/OxyServices.fedcm.ts +213 -31
- package/src/mixins/__tests__/fedcm.test.ts +323 -0
|
@@ -548,16 +548,28 @@ class KeyManager {
|
|
|
548
548
|
/**
|
|
549
549
|
* Atomically persist a key pair to secure storage with verification + backup.
|
|
550
550
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
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
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
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.
|
|
578
|
-
|
|
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
|
|
585
|
-
throw new IdentityPersistError('
|
|
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
|
|
588
|
-
// fails we are
|
|
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
|
-
|
|
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
|
|
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
|
|
1087
|
+
* Restore identity from backup if primary storage is genuinely missing or
|
|
1088
|
+
* corrupt.
|
|
989
1089
|
*
|
|
990
|
-
* SAFETY
|
|
991
|
-
* If the primary passes a sign/verify probe,
|
|
992
|
-
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
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
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
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
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
// different
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
*
|