@oxyhq/core 1.11.16 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/keyManager.js +184 -56
- package/dist/cjs/mixins/OxyServices.auth.js +39 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +58 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +184 -56
- package/dist/esm/mixins/OxyServices.auth.js +39 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +58 -3
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +49 -21
- package/dist/types/mixins/OxyServices.auth.d.ts +36 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +37 -1
- 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.auth.ts +65 -2
- package/src/mixins/OxyServices.fedcm.ts +67 -3
- package/src/mixins/__tests__/fedcm.test.ts +187 -0
package/src/crypto/keyManager.ts
CHANGED
|
@@ -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
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
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
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
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
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
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.
|
|
672
|
-
|
|
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
|
|
678
|
-
throw new IdentityPersistError(
|
|
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
|
-
//
|
|
682
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
1234
|
+
* Restore identity from backup if primary storage is genuinely missing or
|
|
1235
|
+
* corrupt.
|
|
1126
1236
|
*
|
|
1127
|
-
* SAFETY
|
|
1128
|
-
* If the primary passes a sign/verify probe,
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
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
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
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
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
// different
|
|
1178
|
-
//
|
|
1179
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1330
|
+
primaryPrivate &&
|
|
1331
|
+
KeyManager.isValidPrivateKey(primaryPrivate) &&
|
|
1332
|
+
KeyManager.derivePublicKey(primaryPrivate).toLowerCase() !== backupPublicKey.toLowerCase()
|
|
1183
1333
|
) {
|
|
1184
1334
|
logger.error(
|
|
1185
|
-
'Primary
|
|
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
|
);
|
|
@@ -440,6 +440,11 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
440
440
|
|
|
441
441
|
/**
|
|
442
442
|
* Get access token by session ID
|
|
443
|
+
*
|
|
444
|
+
* SECURITY: this endpoint requires the caller to already hold a
|
|
445
|
+
* bearer token whose user owns the referenced session (C1 hardening
|
|
446
|
+
* in the API). For the device-flow / QR sign-in case where the
|
|
447
|
+
* client has no bearer token yet, use `claimSessionByToken` instead.
|
|
443
448
|
*/
|
|
444
449
|
async getTokenBySession(sessionId: string): Promise<{ accessToken: string; expiresAt: string }> {
|
|
445
450
|
try {
|
|
@@ -449,9 +454,67 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
449
454
|
undefined,
|
|
450
455
|
{ cache: false, retry: false }
|
|
451
456
|
);
|
|
452
|
-
|
|
457
|
+
|
|
453
458
|
this.setTokens(res.accessToken);
|
|
454
|
-
|
|
459
|
+
|
|
460
|
+
return res;
|
|
461
|
+
} catch (error) {
|
|
462
|
+
throw this.handleError(error);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Exchange a device-flow sessionToken for the first access token.
|
|
468
|
+
*
|
|
469
|
+
* The originating client holds a 128-bit `sessionToken` that nobody
|
|
470
|
+
* else has seen — it was generated client-side, sent once on
|
|
471
|
+
* `POST /auth/session/create`, and is never echoed back. After
|
|
472
|
+
* another authenticated device approves the session via
|
|
473
|
+
* `POST /auth/session/authorize/{sessionToken}` (bearer-authed) and
|
|
474
|
+
* the auth socket / poll loop notifies this client, the client
|
|
475
|
+
* exchanges its `sessionToken` here for the first access token,
|
|
476
|
+
* refresh token, sessionId, and the authorized user.
|
|
477
|
+
*
|
|
478
|
+
* This call requires no Authorization header — the high-entropy
|
|
479
|
+
* `sessionToken` IS the credential (RFC 8628 §3.4). The exchange is
|
|
480
|
+
* single-use; replay attempts are rejected with 401.
|
|
481
|
+
*
|
|
482
|
+
* @param sessionToken - The same sessionToken the SDK passed to
|
|
483
|
+
* `POST /auth/session/create` at the start of the flow.
|
|
484
|
+
* @param options.deviceFingerprint - Optional fingerprint of the
|
|
485
|
+
* originating client device.
|
|
486
|
+
*/
|
|
487
|
+
async claimSessionByToken(
|
|
488
|
+
sessionToken: string,
|
|
489
|
+
options: { deviceFingerprint?: string } = {}
|
|
490
|
+
): Promise<{
|
|
491
|
+
accessToken: string;
|
|
492
|
+
refreshToken: string;
|
|
493
|
+
sessionId: string;
|
|
494
|
+
deviceId: string;
|
|
495
|
+
expiresAt: string;
|
|
496
|
+
user: User;
|
|
497
|
+
}> {
|
|
498
|
+
try {
|
|
499
|
+
const res = await this.makeRequest<{
|
|
500
|
+
accessToken: string;
|
|
501
|
+
refreshToken: string;
|
|
502
|
+
sessionId: string;
|
|
503
|
+
deviceId: string;
|
|
504
|
+
expiresAt: string;
|
|
505
|
+
user: User;
|
|
506
|
+
}>(
|
|
507
|
+
'POST',
|
|
508
|
+
'/auth/session/claim',
|
|
509
|
+
{
|
|
510
|
+
sessionToken,
|
|
511
|
+
...(options.deviceFingerprint ? { deviceFingerprint: options.deviceFingerprint } : {}),
|
|
512
|
+
},
|
|
513
|
+
{ cache: false, retry: false }
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
this.setTokens(res.accessToken, res.refreshToken);
|
|
517
|
+
|
|
455
518
|
return res;
|
|
456
519
|
} catch (error) {
|
|
457
520
|
throw this.handleError(error);
|
|
@@ -109,7 +109,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
try {
|
|
112
|
-
|
|
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
|
-
|
|
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
|
*
|