@oxy.so/core 7.10.0 → 7.10.2
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/HttpService.js +34 -5
- package/dist/cjs/session/SessionClient.js +126 -12
- package/dist/esm/HttpService.js +34 -5
- package/dist/esm/session/SessionClient.js +126 -12
- package/dist/types/HttpService.d.ts +4 -2
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/package.json +1 -1
- package/src/HttpService.ts +37 -6
- package/src/session/SessionClient.ts +149 -13
package/dist/cjs/HttpService.js
CHANGED
|
@@ -58,6 +58,14 @@ function sameTokenSession(left, right) {
|
|
|
58
58
|
}
|
|
59
59
|
if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub))
|
|
60
60
|
return false;
|
|
61
|
+
// A legacy bearer may omit deviceId; empty or non-string values also carry no
|
|
62
|
+
// usable device. Two known, different devices are distinct local contexts.
|
|
63
|
+
if (typeof a.deviceId === 'string' &&
|
|
64
|
+
a.deviceId.length > 0 &&
|
|
65
|
+
typeof b.deviceId === 'string' &&
|
|
66
|
+
b.deviceId.length > 0 &&
|
|
67
|
+
a.deviceId !== b.deviceId)
|
|
68
|
+
return false;
|
|
61
69
|
// A refresh may rotate exp/iat/jti while preserving its account and session.
|
|
62
70
|
// Opaque/malformed context fields cannot establish that equivalence.
|
|
63
71
|
return [
|
|
@@ -71,6 +79,13 @@ function sameTokenSession(left, right) {
|
|
|
71
79
|
'authMethodId',
|
|
72
80
|
].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
73
81
|
}
|
|
82
|
+
/** Extract a known device for local ownership checks, never authorization. */
|
|
83
|
+
function knownTokenDeviceId(token) {
|
|
84
|
+
const claims = (0, tokenClaims_1.decodeTokenClaims)(token);
|
|
85
|
+
return claims?.type !== 'service' && typeof claims?.deviceId === 'string' && claims.deviceId
|
|
86
|
+
? claims.deviceId
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
74
89
|
/** Discard an unused body even when a platform's stream shim lacks cancel. */
|
|
75
90
|
function discardResponseBody(response) {
|
|
76
91
|
try {
|
|
@@ -328,6 +343,8 @@ class HttpService {
|
|
|
328
343
|
* bearer — and through the token listeners, the account — back.
|
|
329
344
|
*/
|
|
330
345
|
this.sessionEpoch = 0;
|
|
346
|
+
/** Preserve a known device across legacy rotations which omit the claim. */
|
|
347
|
+
this.sessionDeviceId = null;
|
|
331
348
|
/**
|
|
332
349
|
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
333
350
|
* native Commons-identity re-mint arm: after a sign-out a 401 is the expected
|
|
@@ -566,8 +583,12 @@ class HttpService {
|
|
|
566
583
|
const authHeader = usesSessionAuth
|
|
567
584
|
? await (0, requestSignal_1.awaitRequestSignal)(this.getAuthHeader(), signal)
|
|
568
585
|
: null;
|
|
569
|
-
const isOriginalSession = () =>
|
|
570
|
-
|
|
586
|
+
const isOriginalSession = () => {
|
|
587
|
+
if (this.sessionEpoch !== contextEpoch)
|
|
588
|
+
return false;
|
|
589
|
+
const currentToken = this.syncAccessTokenFromProvider();
|
|
590
|
+
return this.sessionEpoch === contextEpoch && sameTokenSession(contextToken, currentToken);
|
|
591
|
+
};
|
|
571
592
|
const newerRequestToken = () => {
|
|
572
593
|
if (!contextToken || !authHeader?.startsWith('Bearer '))
|
|
573
594
|
return null;
|
|
@@ -770,6 +791,7 @@ class HttpService {
|
|
|
770
791
|
if (this.tokenStore.getAccessToken())
|
|
771
792
|
this.sessionEpoch += 1;
|
|
772
793
|
this.tokenStore.clearTokens();
|
|
794
|
+
this.sessionDeviceId = null;
|
|
773
795
|
selfClearedEpoch = this.sessionEpoch;
|
|
774
796
|
this.notifyTokenChange();
|
|
775
797
|
}
|
|
@@ -1653,8 +1675,13 @@ class HttpService {
|
|
|
1653
1675
|
// Token management
|
|
1654
1676
|
setTokens(accessToken) {
|
|
1655
1677
|
const current = this.tokenStore.getAccessToken();
|
|
1656
|
-
|
|
1678
|
+
const deviceId = knownTokenDeviceId(accessToken);
|
|
1679
|
+
const changed = current !== null &&
|
|
1680
|
+
(!sameTokenSession(current, accessToken) ||
|
|
1681
|
+
(this.sessionDeviceId !== null && deviceId !== null && this.sessionDeviceId !== deviceId));
|
|
1682
|
+
if (changed)
|
|
1657
1683
|
this.sessionEpoch += 1;
|
|
1684
|
+
this.sessionDeviceId = !current || changed ? deviceId : (deviceId ?? this.sessionDeviceId);
|
|
1658
1685
|
this.sessionEnded = false;
|
|
1659
1686
|
this.tokenStore.setTokens(accessToken);
|
|
1660
1687
|
this.notifyTokenChange();
|
|
@@ -1673,8 +1700,8 @@ class HttpService {
|
|
|
1673
1700
|
this.clearTokens();
|
|
1674
1701
|
}
|
|
1675
1702
|
/**
|
|
1676
|
-
* The current context epoch: account/session switches
|
|
1677
|
-
* invalidate pending work;
|
|
1703
|
+
* The current context epoch: account/session switches, known device changes,
|
|
1704
|
+
* and bearer removal invalidate pending work; same-context rotation preserves it.
|
|
1678
1705
|
* A re-mint lane that
|
|
1679
1706
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1680
1707
|
*/
|
|
@@ -1705,6 +1732,7 @@ class HttpService {
|
|
|
1705
1732
|
if (this.tokenStore.getAccessToken())
|
|
1706
1733
|
this.sessionEpoch += 1;
|
|
1707
1734
|
this.tokenStore.clearTokens();
|
|
1735
|
+
this.sessionDeviceId = null;
|
|
1708
1736
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1709
1737
|
// different user could never read these entries, but a logged-out client
|
|
1710
1738
|
// must not keep the previous session's personalized data resident in
|
|
@@ -1864,6 +1892,7 @@ class HttpService {
|
|
|
1864
1892
|
__resetTokensForTests() {
|
|
1865
1893
|
this.sessionEnded = false;
|
|
1866
1894
|
this.tokenStore.clearTokens();
|
|
1895
|
+
this.sessionDeviceId = null;
|
|
1867
1896
|
}
|
|
1868
1897
|
}
|
|
1869
1898
|
exports.HttpService = HttpService;
|
|
@@ -39,6 +39,8 @@ class SessionClient {
|
|
|
39
39
|
this.tokenUnsub = null;
|
|
40
40
|
this.started = false;
|
|
41
41
|
this.lifecycleGeneration = 0;
|
|
42
|
+
/** Changes whenever the locally projected device changes, including ABA. */
|
|
43
|
+
this.deviceGeneration = 0;
|
|
42
44
|
this.fullSignOutPersistence = null;
|
|
43
45
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
44
46
|
this.channel = null;
|
|
@@ -68,6 +70,25 @@ class SessionClient {
|
|
|
68
70
|
throw new Error('Device session request was superseded');
|
|
69
71
|
return response;
|
|
70
72
|
}
|
|
73
|
+
/** Capture the local device before a request, including device ABA. */
|
|
74
|
+
captureDeviceRequest() {
|
|
75
|
+
return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
|
|
76
|
+
}
|
|
77
|
+
isCurrentDeviceRequest(request, response) {
|
|
78
|
+
if (request.generation === this.deviceGeneration)
|
|
79
|
+
return true;
|
|
80
|
+
if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
|
|
81
|
+
return false;
|
|
82
|
+
if (response === null || typeof response !== 'object')
|
|
83
|
+
return false;
|
|
84
|
+
const record = response;
|
|
85
|
+
const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
|
|
86
|
+
return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
|
|
87
|
+
}
|
|
88
|
+
assertCurrentDeviceRequest(request, response) {
|
|
89
|
+
if (!this.isCurrentDeviceRequest(request, response))
|
|
90
|
+
throw new Error('Device session request was superseded');
|
|
91
|
+
}
|
|
71
92
|
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
72
93
|
resetLocalState() {
|
|
73
94
|
this.lifecycleGeneration += 1;
|
|
@@ -216,6 +237,8 @@ class SessionClient {
|
|
|
216
237
|
return false;
|
|
217
238
|
}
|
|
218
239
|
const previousState = this.state;
|
|
240
|
+
if (previousState?.deviceId !== next.deviceId)
|
|
241
|
+
this.deviceGeneration += 1;
|
|
219
242
|
this.state = next;
|
|
220
243
|
if (next.accounts.length === 0) {
|
|
221
244
|
// A device with nobody signed in has no principals either. The directory
|
|
@@ -324,11 +347,13 @@ class SessionClient {
|
|
|
324
347
|
.ensureActiveToken(next)
|
|
325
348
|
.then(finishApply)
|
|
326
349
|
.catch((error) => {
|
|
327
|
-
if (generation !== this.lifecycleGeneration)
|
|
350
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
328
351
|
return;
|
|
329
352
|
logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
330
353
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
331
354
|
// state so subscribers keep observing the account whose token is planted.
|
|
355
|
+
if (this.state?.deviceId !== previousState?.deviceId)
|
|
356
|
+
this.deviceGeneration += 1;
|
|
332
357
|
this.state = previousState ?? null;
|
|
333
358
|
});
|
|
334
359
|
}
|
|
@@ -378,6 +403,21 @@ class SessionClient {
|
|
|
378
403
|
if (this.fullSignOutPersistence?.state === this.state)
|
|
379
404
|
await this.fullSignOutPersistence.pending;
|
|
380
405
|
}
|
|
406
|
+
/** Keep mutation callers from reporting success after a device change during persistence. */
|
|
407
|
+
async applySyncForMutation(raw) {
|
|
408
|
+
const before = this.captureDeviceRequest();
|
|
409
|
+
const lifecycle = this.lifecycleGeneration;
|
|
410
|
+
const checked = (0, client_1.deviceSessionSyncCheck)(raw, []);
|
|
411
|
+
const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
|
|
412
|
+
const expectedGeneration = before.generation + (responseDevice === before.initialDevice ? 0 : 1);
|
|
413
|
+
const pending = this.applySync(raw);
|
|
414
|
+
const generation = this.deviceGeneration;
|
|
415
|
+
await pending;
|
|
416
|
+
if (generation !== expectedGeneration ||
|
|
417
|
+
generation !== this.deviceGeneration ||
|
|
418
|
+
lifecycle !== this.lifecycleGeneration)
|
|
419
|
+
throw new Error('Device session request was superseded');
|
|
420
|
+
}
|
|
381
421
|
/**
|
|
382
422
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
383
423
|
* sync. Split out so the context-aware removal lane — whose response is a
|
|
@@ -397,14 +437,15 @@ class SessionClient {
|
|
|
397
437
|
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
398
438
|
// Equal-revision restate (this revision was already applied by a preceding
|
|
399
439
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
400
|
-
// needs planting.
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
440
|
+
// needs planting. Only the exact same device revision is an equal-revision
|
|
441
|
+
// restate: an older response may identify a different operator of the same
|
|
442
|
+
// account. Also require the current account and the identity pin to match.
|
|
404
443
|
const pinnedAccountId = this.pinnedAccountId();
|
|
405
444
|
if (!applied &&
|
|
406
445
|
sync.activeToken &&
|
|
407
446
|
this.state &&
|
|
447
|
+
sync.state.deviceId === this.state.deviceId &&
|
|
448
|
+
sync.state.revision === this.state.revision &&
|
|
408
449
|
sync.state.activeAccountId !== null &&
|
|
409
450
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
410
451
|
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
@@ -454,9 +495,12 @@ class SessionClient {
|
|
|
454
495
|
}
|
|
455
496
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
456
497
|
async fetchDirectory() {
|
|
498
|
+
const request = this.captureDeviceRequest();
|
|
457
499
|
const res = await this.requestDevice('GET', '/session/device/directory', undefined, {
|
|
458
500
|
cache: false,
|
|
459
501
|
});
|
|
502
|
+
if (!this.isCurrentDeviceRequest(request, res))
|
|
503
|
+
return false;
|
|
460
504
|
return this.applyDirectory(res);
|
|
461
505
|
}
|
|
462
506
|
/**
|
|
@@ -518,14 +562,13 @@ class SessionClient {
|
|
|
518
562
|
if (!accessToken) {
|
|
519
563
|
return;
|
|
520
564
|
}
|
|
565
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
521
566
|
const subjectAccountId = (0, deviceDirectory_1.resolveActiveContext)(directory)?.subject.accountId ?? null;
|
|
522
567
|
if (subjectAccountId === null) {
|
|
523
568
|
return;
|
|
524
569
|
}
|
|
525
|
-
|
|
526
|
-
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
|
|
570
|
+
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId)
|
|
527
571
|
return;
|
|
528
|
-
}
|
|
529
572
|
if (accessToken === this.host.getAccessToken()) {
|
|
530
573
|
return;
|
|
531
574
|
}
|
|
@@ -565,9 +608,12 @@ class SessionClient {
|
|
|
565
608
|
}
|
|
566
609
|
}
|
|
567
610
|
async bootstrap() {
|
|
611
|
+
const request = this.captureDeviceRequest();
|
|
568
612
|
const res = await this.requestDevice('GET', '/session/device/state', undefined, {
|
|
569
613
|
cache: false,
|
|
570
614
|
});
|
|
615
|
+
if (!this.isCurrentDeviceRequest(request, res))
|
|
616
|
+
return;
|
|
571
617
|
await this.applySync(res);
|
|
572
618
|
}
|
|
573
619
|
/**
|
|
@@ -611,13 +657,41 @@ class SessionClient {
|
|
|
611
657
|
* compatibility path for callers still keyed on account ids.
|
|
612
658
|
*/
|
|
613
659
|
async activateContext(contextId) {
|
|
660
|
+
const request = this.captureDeviceRequest();
|
|
614
661
|
const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
|
|
662
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
615
663
|
const activation = (0, client_1.checkContract)(client_1.deviceActivateResponseCheck, res);
|
|
616
664
|
if (!activation) {
|
|
617
665
|
logger_1.logger.warn('[SessionClient] discarded invalid activation response');
|
|
618
666
|
return;
|
|
619
667
|
}
|
|
668
|
+
if (this.pinnedAccountId() === null) {
|
|
669
|
+
const active = (0, deviceDirectory_1.resolveActiveContext)(activation.directory);
|
|
670
|
+
if (!active || activation.directory.activeContextId !== contextId)
|
|
671
|
+
throw new Error('Device session request was superseded');
|
|
672
|
+
const claims = activation.activeToken?.accessToken
|
|
673
|
+
? (0, tokenClaims_1.decodeTokenClaims)(activation.activeToken.accessToken)
|
|
674
|
+
: null;
|
|
675
|
+
const contextClaim = claims?.device_context_id;
|
|
676
|
+
const actor = claims?.act;
|
|
677
|
+
const actorClaim = actor !== null && typeof actor === 'object' ? actor.sub : undefined;
|
|
678
|
+
const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
|
|
679
|
+
// Decoded claims are only a consistency check, never authorization.
|
|
680
|
+
// Legacy and opaque bearers may omit these claims entirely.
|
|
681
|
+
if ((typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
|
|
682
|
+
(typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
|
|
683
|
+
(typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId))
|
|
684
|
+
throw new Error('Device session request was superseded');
|
|
685
|
+
}
|
|
620
686
|
const known = this.knownRevisionFor(activation.directory.deviceId);
|
|
687
|
+
if (known !== null && activation.directory.revision < known)
|
|
688
|
+
throw new Error('Device session request was superseded');
|
|
689
|
+
const held = this.directory;
|
|
690
|
+
if (this.pinnedAccountId() === null &&
|
|
691
|
+
held?.deviceId === activation.directory.deviceId &&
|
|
692
|
+
held.revision === activation.directory.revision &&
|
|
693
|
+
held.activeContextId !== activation.directory.activeContextId)
|
|
694
|
+
throw new Error('Device session request was superseded');
|
|
621
695
|
const moved = known === null || activation.directory.revision > known;
|
|
622
696
|
this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
|
|
623
697
|
// Applied BEFORE the reconcile, and only then: the reconcile's own
|
|
@@ -627,8 +701,21 @@ class SessionClient {
|
|
|
627
701
|
// already holding in hand.
|
|
628
702
|
const applied = this.applyDirectory(activation.directory);
|
|
629
703
|
if (moved) {
|
|
704
|
+
const beforeReconcile = this.captureDeviceRequest();
|
|
705
|
+
const lifecycle = this.lifecycleGeneration;
|
|
630
706
|
await this.reconcileFlatState(activation.directory);
|
|
707
|
+
const landedOnTarget = beforeReconcile.initialDevice !== activation.directory.deviceId &&
|
|
708
|
+
this.deviceGeneration === beforeReconcile.generation + 1 &&
|
|
709
|
+
this.state?.deviceId === activation.directory.deviceId;
|
|
710
|
+
if (lifecycle !== this.lifecycleGeneration ||
|
|
711
|
+
(this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget))
|
|
712
|
+
throw new Error('Device session request was superseded');
|
|
631
713
|
}
|
|
714
|
+
if (this.pinnedAccountId() === null &&
|
|
715
|
+
(this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
|
|
716
|
+
this.directory?.deviceId !== activation.directory.deviceId ||
|
|
717
|
+
this.directory.activeContextId !== contextId))
|
|
718
|
+
throw new Error('Device session request was superseded');
|
|
632
719
|
if (applied) {
|
|
633
720
|
this.notify();
|
|
634
721
|
}
|
|
@@ -637,8 +724,18 @@ class SessionClient {
|
|
|
637
724
|
}
|
|
638
725
|
}
|
|
639
726
|
async switchAccount(accountId) {
|
|
727
|
+
const request = this.captureDeviceRequest();
|
|
640
728
|
const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
641
|
-
|
|
729
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
730
|
+
const checked = (0, client_1.deviceSessionSyncCheck)(res, []);
|
|
731
|
+
const isStale = () => checked.ok &&
|
|
732
|
+
this.state?.deviceId === checked.value.state.deviceId &&
|
|
733
|
+
this.state.revision > checked.value.state.revision;
|
|
734
|
+
if (isStale())
|
|
735
|
+
throw new Error('Device session request was superseded');
|
|
736
|
+
await this.applySyncForMutation(res);
|
|
737
|
+
if (isStale())
|
|
738
|
+
throw new Error('Device session request was superseded');
|
|
642
739
|
this.postCommitPing();
|
|
643
740
|
}
|
|
644
741
|
/**
|
|
@@ -653,10 +750,12 @@ class SessionClient {
|
|
|
653
750
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
654
751
|
*/
|
|
655
752
|
async signOut(target) {
|
|
753
|
+
const request = this.captureDeviceRequest();
|
|
656
754
|
const res = await this.requestDevice('POST', '/session/device/signout', target, {
|
|
657
755
|
cache: false,
|
|
658
756
|
});
|
|
659
|
-
|
|
757
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
758
|
+
await this.applySyncForMutation(res);
|
|
660
759
|
this.postCommitPing();
|
|
661
760
|
if (this.state?.accounts.length === 0)
|
|
662
761
|
await this.persistFullSignOut();
|
|
@@ -698,21 +797,34 @@ class SessionClient {
|
|
|
698
797
|
* applied this revision.
|
|
699
798
|
*/
|
|
700
799
|
async removeFromDevice(target) {
|
|
800
|
+
const request = this.captureDeviceRequest();
|
|
701
801
|
const res = await this.requestDevice('POST', '/session/device/signout', target, {
|
|
702
802
|
cache: false,
|
|
703
803
|
});
|
|
804
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
704
805
|
const removal = (0, client_1.checkContract)(client_1.deviceDirectorySyncCheck, res);
|
|
705
806
|
if (!removal) {
|
|
706
807
|
logger_1.logger.warn('[SessionClient] discarded invalid device removal response');
|
|
707
808
|
return;
|
|
708
809
|
}
|
|
709
810
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
811
|
+
const beforeCommit = this.captureDeviceRequest();
|
|
812
|
+
const commitLifecycle = this.lifecycleGeneration;
|
|
813
|
+
const expectedGeneration = beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
|
|
710
814
|
const stateApplied = this.commitSync({
|
|
711
815
|
state: removal.state,
|
|
712
816
|
activeToken: removal.activeToken,
|
|
713
817
|
});
|
|
714
|
-
if (
|
|
818
|
+
if (expectedGeneration !== this.deviceGeneration ||
|
|
819
|
+
commitLifecycle !== this.lifecycleGeneration)
|
|
820
|
+
throw new Error('Device session request was superseded');
|
|
821
|
+
if (this.fullSignOutPersistence?.state === this.state) {
|
|
822
|
+
const generation = this.deviceGeneration;
|
|
823
|
+
const lifecycle = this.lifecycleGeneration;
|
|
715
824
|
await this.fullSignOutPersistence.pending;
|
|
825
|
+
if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
|
|
826
|
+
throw new Error('Device session request was superseded');
|
|
827
|
+
}
|
|
716
828
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
717
829
|
// directory did — a socket push already applied this revision — the
|
|
718
830
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -724,10 +836,12 @@ class SessionClient {
|
|
|
724
836
|
await this.persistFullSignOut();
|
|
725
837
|
}
|
|
726
838
|
async addCurrentAccount() {
|
|
839
|
+
const request = this.captureDeviceRequest();
|
|
727
840
|
const res = await this.requestDevice('POST', '/session/device/add', undefined, {
|
|
728
841
|
cache: false,
|
|
729
842
|
});
|
|
730
|
-
|
|
843
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
844
|
+
await this.applySyncForMutation(res);
|
|
731
845
|
this.postCommitPing();
|
|
732
846
|
}
|
|
733
847
|
/**
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -55,6 +55,14 @@ function sameTokenSession(left, right) {
|
|
|
55
55
|
}
|
|
56
56
|
if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub))
|
|
57
57
|
return false;
|
|
58
|
+
// A legacy bearer may omit deviceId; empty or non-string values also carry no
|
|
59
|
+
// usable device. Two known, different devices are distinct local contexts.
|
|
60
|
+
if (typeof a.deviceId === 'string' &&
|
|
61
|
+
a.deviceId.length > 0 &&
|
|
62
|
+
typeof b.deviceId === 'string' &&
|
|
63
|
+
b.deviceId.length > 0 &&
|
|
64
|
+
a.deviceId !== b.deviceId)
|
|
65
|
+
return false;
|
|
58
66
|
// A refresh may rotate exp/iat/jti while preserving its account and session.
|
|
59
67
|
// Opaque/malformed context fields cannot establish that equivalence.
|
|
60
68
|
return [
|
|
@@ -68,6 +76,13 @@ function sameTokenSession(left, right) {
|
|
|
68
76
|
'authMethodId',
|
|
69
77
|
].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
70
78
|
}
|
|
79
|
+
/** Extract a known device for local ownership checks, never authorization. */
|
|
80
|
+
function knownTokenDeviceId(token) {
|
|
81
|
+
const claims = decodeTokenClaims(token);
|
|
82
|
+
return claims?.type !== 'service' && typeof claims?.deviceId === 'string' && claims.deviceId
|
|
83
|
+
? claims.deviceId
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
71
86
|
/** Discard an unused body even when a platform's stream shim lacks cancel. */
|
|
72
87
|
function discardResponseBody(response) {
|
|
73
88
|
try {
|
|
@@ -325,6 +340,8 @@ export class HttpService {
|
|
|
325
340
|
* bearer — and through the token listeners, the account — back.
|
|
326
341
|
*/
|
|
327
342
|
this.sessionEpoch = 0;
|
|
343
|
+
/** Preserve a known device across legacy rotations which omit the claim. */
|
|
344
|
+
this.sessionDeviceId = null;
|
|
328
345
|
/**
|
|
329
346
|
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
330
347
|
* native Commons-identity re-mint arm: after a sign-out a 401 is the expected
|
|
@@ -563,8 +580,12 @@ export class HttpService {
|
|
|
563
580
|
const authHeader = usesSessionAuth
|
|
564
581
|
? await awaitRequestSignal(this.getAuthHeader(), signal)
|
|
565
582
|
: null;
|
|
566
|
-
const isOriginalSession = () =>
|
|
567
|
-
|
|
583
|
+
const isOriginalSession = () => {
|
|
584
|
+
if (this.sessionEpoch !== contextEpoch)
|
|
585
|
+
return false;
|
|
586
|
+
const currentToken = this.syncAccessTokenFromProvider();
|
|
587
|
+
return this.sessionEpoch === contextEpoch && sameTokenSession(contextToken, currentToken);
|
|
588
|
+
};
|
|
568
589
|
const newerRequestToken = () => {
|
|
569
590
|
if (!contextToken || !authHeader?.startsWith('Bearer '))
|
|
570
591
|
return null;
|
|
@@ -767,6 +788,7 @@ export class HttpService {
|
|
|
767
788
|
if (this.tokenStore.getAccessToken())
|
|
768
789
|
this.sessionEpoch += 1;
|
|
769
790
|
this.tokenStore.clearTokens();
|
|
791
|
+
this.sessionDeviceId = null;
|
|
770
792
|
selfClearedEpoch = this.sessionEpoch;
|
|
771
793
|
this.notifyTokenChange();
|
|
772
794
|
}
|
|
@@ -1650,8 +1672,13 @@ export class HttpService {
|
|
|
1650
1672
|
// Token management
|
|
1651
1673
|
setTokens(accessToken) {
|
|
1652
1674
|
const current = this.tokenStore.getAccessToken();
|
|
1653
|
-
|
|
1675
|
+
const deviceId = knownTokenDeviceId(accessToken);
|
|
1676
|
+
const changed = current !== null &&
|
|
1677
|
+
(!sameTokenSession(current, accessToken) ||
|
|
1678
|
+
(this.sessionDeviceId !== null && deviceId !== null && this.sessionDeviceId !== deviceId));
|
|
1679
|
+
if (changed)
|
|
1654
1680
|
this.sessionEpoch += 1;
|
|
1681
|
+
this.sessionDeviceId = !current || changed ? deviceId : (deviceId ?? this.sessionDeviceId);
|
|
1655
1682
|
this.sessionEnded = false;
|
|
1656
1683
|
this.tokenStore.setTokens(accessToken);
|
|
1657
1684
|
this.notifyTokenChange();
|
|
@@ -1670,8 +1697,8 @@ export class HttpService {
|
|
|
1670
1697
|
this.clearTokens();
|
|
1671
1698
|
}
|
|
1672
1699
|
/**
|
|
1673
|
-
* The current context epoch: account/session switches
|
|
1674
|
-
* invalidate pending work;
|
|
1700
|
+
* The current context epoch: account/session switches, known device changes,
|
|
1701
|
+
* and bearer removal invalidate pending work; same-context rotation preserves it.
|
|
1675
1702
|
* A re-mint lane that
|
|
1676
1703
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1677
1704
|
*/
|
|
@@ -1702,6 +1729,7 @@ export class HttpService {
|
|
|
1702
1729
|
if (this.tokenStore.getAccessToken())
|
|
1703
1730
|
this.sessionEpoch += 1;
|
|
1704
1731
|
this.tokenStore.clearTokens();
|
|
1732
|
+
this.sessionDeviceId = null;
|
|
1705
1733
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1706
1734
|
// different user could never read these entries, but a logged-out client
|
|
1707
1735
|
// must not keep the previous session's personalized data resident in
|
|
@@ -1861,6 +1889,7 @@ export class HttpService {
|
|
|
1861
1889
|
__resetTokensForTests() {
|
|
1862
1890
|
this.sessionEnded = false;
|
|
1863
1891
|
this.tokenStore.clearTokens();
|
|
1892
|
+
this.sessionDeviceId = null;
|
|
1864
1893
|
}
|
|
1865
1894
|
}
|
|
1866
1895
|
/**
|
|
@@ -36,6 +36,8 @@ export class SessionClient {
|
|
|
36
36
|
this.tokenUnsub = null;
|
|
37
37
|
this.started = false;
|
|
38
38
|
this.lifecycleGeneration = 0;
|
|
39
|
+
/** Changes whenever the locally projected device changes, including ABA. */
|
|
40
|
+
this.deviceGeneration = 0;
|
|
39
41
|
this.fullSignOutPersistence = null;
|
|
40
42
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
41
43
|
this.channel = null;
|
|
@@ -65,6 +67,25 @@ export class SessionClient {
|
|
|
65
67
|
throw new Error('Device session request was superseded');
|
|
66
68
|
return response;
|
|
67
69
|
}
|
|
70
|
+
/** Capture the local device before a request, including device ABA. */
|
|
71
|
+
captureDeviceRequest() {
|
|
72
|
+
return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
|
|
73
|
+
}
|
|
74
|
+
isCurrentDeviceRequest(request, response) {
|
|
75
|
+
if (request.generation === this.deviceGeneration)
|
|
76
|
+
return true;
|
|
77
|
+
if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
|
|
78
|
+
return false;
|
|
79
|
+
if (response === null || typeof response !== 'object')
|
|
80
|
+
return false;
|
|
81
|
+
const record = response;
|
|
82
|
+
const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
|
|
83
|
+
return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
|
|
84
|
+
}
|
|
85
|
+
assertCurrentDeviceRequest(request, response) {
|
|
86
|
+
if (!this.isCurrentDeviceRequest(request, response))
|
|
87
|
+
throw new Error('Device session request was superseded');
|
|
88
|
+
}
|
|
68
89
|
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
69
90
|
resetLocalState() {
|
|
70
91
|
this.lifecycleGeneration += 1;
|
|
@@ -213,6 +234,8 @@ export class SessionClient {
|
|
|
213
234
|
return false;
|
|
214
235
|
}
|
|
215
236
|
const previousState = this.state;
|
|
237
|
+
if (previousState?.deviceId !== next.deviceId)
|
|
238
|
+
this.deviceGeneration += 1;
|
|
216
239
|
this.state = next;
|
|
217
240
|
if (next.accounts.length === 0) {
|
|
218
241
|
// A device with nobody signed in has no principals either. The directory
|
|
@@ -321,11 +344,13 @@ export class SessionClient {
|
|
|
321
344
|
.ensureActiveToken(next)
|
|
322
345
|
.then(finishApply)
|
|
323
346
|
.catch((error) => {
|
|
324
|
-
if (generation !== this.lifecycleGeneration)
|
|
347
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
325
348
|
return;
|
|
326
349
|
logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
327
350
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
328
351
|
// state so subscribers keep observing the account whose token is planted.
|
|
352
|
+
if (this.state?.deviceId !== previousState?.deviceId)
|
|
353
|
+
this.deviceGeneration += 1;
|
|
329
354
|
this.state = previousState ?? null;
|
|
330
355
|
});
|
|
331
356
|
}
|
|
@@ -375,6 +400,21 @@ export class SessionClient {
|
|
|
375
400
|
if (this.fullSignOutPersistence?.state === this.state)
|
|
376
401
|
await this.fullSignOutPersistence.pending;
|
|
377
402
|
}
|
|
403
|
+
/** Keep mutation callers from reporting success after a device change during persistence. */
|
|
404
|
+
async applySyncForMutation(raw) {
|
|
405
|
+
const before = this.captureDeviceRequest();
|
|
406
|
+
const lifecycle = this.lifecycleGeneration;
|
|
407
|
+
const checked = deviceSessionSyncCheck(raw, []);
|
|
408
|
+
const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
|
|
409
|
+
const expectedGeneration = before.generation + (responseDevice === before.initialDevice ? 0 : 1);
|
|
410
|
+
const pending = this.applySync(raw);
|
|
411
|
+
const generation = this.deviceGeneration;
|
|
412
|
+
await pending;
|
|
413
|
+
if (generation !== expectedGeneration ||
|
|
414
|
+
generation !== this.deviceGeneration ||
|
|
415
|
+
lifecycle !== this.lifecycleGeneration)
|
|
416
|
+
throw new Error('Device session request was superseded');
|
|
417
|
+
}
|
|
378
418
|
/**
|
|
379
419
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
380
420
|
* sync. Split out so the context-aware removal lane — whose response is a
|
|
@@ -394,14 +434,15 @@ export class SessionClient {
|
|
|
394
434
|
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
395
435
|
// Equal-revision restate (this revision was already applied by a preceding
|
|
396
436
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
397
|
-
// needs planting.
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
437
|
+
// needs planting. Only the exact same device revision is an equal-revision
|
|
438
|
+
// restate: an older response may identify a different operator of the same
|
|
439
|
+
// account. Also require the current account and the identity pin to match.
|
|
401
440
|
const pinnedAccountId = this.pinnedAccountId();
|
|
402
441
|
if (!applied &&
|
|
403
442
|
sync.activeToken &&
|
|
404
443
|
this.state &&
|
|
444
|
+
sync.state.deviceId === this.state.deviceId &&
|
|
445
|
+
sync.state.revision === this.state.revision &&
|
|
405
446
|
sync.state.activeAccountId !== null &&
|
|
406
447
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
407
448
|
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
@@ -451,9 +492,12 @@ export class SessionClient {
|
|
|
451
492
|
}
|
|
452
493
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
453
494
|
async fetchDirectory() {
|
|
495
|
+
const request = this.captureDeviceRequest();
|
|
454
496
|
const res = await this.requestDevice('GET', '/session/device/directory', undefined, {
|
|
455
497
|
cache: false,
|
|
456
498
|
});
|
|
499
|
+
if (!this.isCurrentDeviceRequest(request, res))
|
|
500
|
+
return false;
|
|
457
501
|
return this.applyDirectory(res);
|
|
458
502
|
}
|
|
459
503
|
/**
|
|
@@ -515,14 +559,13 @@ export class SessionClient {
|
|
|
515
559
|
if (!accessToken) {
|
|
516
560
|
return;
|
|
517
561
|
}
|
|
562
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
518
563
|
const subjectAccountId = resolveActiveContext(directory)?.subject.accountId ?? null;
|
|
519
564
|
if (subjectAccountId === null) {
|
|
520
565
|
return;
|
|
521
566
|
}
|
|
522
|
-
|
|
523
|
-
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
|
|
567
|
+
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId)
|
|
524
568
|
return;
|
|
525
|
-
}
|
|
526
569
|
if (accessToken === this.host.getAccessToken()) {
|
|
527
570
|
return;
|
|
528
571
|
}
|
|
@@ -562,9 +605,12 @@ export class SessionClient {
|
|
|
562
605
|
}
|
|
563
606
|
}
|
|
564
607
|
async bootstrap() {
|
|
608
|
+
const request = this.captureDeviceRequest();
|
|
565
609
|
const res = await this.requestDevice('GET', '/session/device/state', undefined, {
|
|
566
610
|
cache: false,
|
|
567
611
|
});
|
|
612
|
+
if (!this.isCurrentDeviceRequest(request, res))
|
|
613
|
+
return;
|
|
568
614
|
await this.applySync(res);
|
|
569
615
|
}
|
|
570
616
|
/**
|
|
@@ -608,13 +654,41 @@ export class SessionClient {
|
|
|
608
654
|
* compatibility path for callers still keyed on account ids.
|
|
609
655
|
*/
|
|
610
656
|
async activateContext(contextId) {
|
|
657
|
+
const request = this.captureDeviceRequest();
|
|
611
658
|
const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
|
|
659
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
612
660
|
const activation = checkContract(deviceActivateResponseCheck, res);
|
|
613
661
|
if (!activation) {
|
|
614
662
|
logger.warn('[SessionClient] discarded invalid activation response');
|
|
615
663
|
return;
|
|
616
664
|
}
|
|
665
|
+
if (this.pinnedAccountId() === null) {
|
|
666
|
+
const active = resolveActiveContext(activation.directory);
|
|
667
|
+
if (!active || activation.directory.activeContextId !== contextId)
|
|
668
|
+
throw new Error('Device session request was superseded');
|
|
669
|
+
const claims = activation.activeToken?.accessToken
|
|
670
|
+
? decodeTokenClaims(activation.activeToken.accessToken)
|
|
671
|
+
: null;
|
|
672
|
+
const contextClaim = claims?.device_context_id;
|
|
673
|
+
const actor = claims?.act;
|
|
674
|
+
const actorClaim = actor !== null && typeof actor === 'object' ? actor.sub : undefined;
|
|
675
|
+
const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
|
|
676
|
+
// Decoded claims are only a consistency check, never authorization.
|
|
677
|
+
// Legacy and opaque bearers may omit these claims entirely.
|
|
678
|
+
if ((typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
|
|
679
|
+
(typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
|
|
680
|
+
(typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId))
|
|
681
|
+
throw new Error('Device session request was superseded');
|
|
682
|
+
}
|
|
617
683
|
const known = this.knownRevisionFor(activation.directory.deviceId);
|
|
684
|
+
if (known !== null && activation.directory.revision < known)
|
|
685
|
+
throw new Error('Device session request was superseded');
|
|
686
|
+
const held = this.directory;
|
|
687
|
+
if (this.pinnedAccountId() === null &&
|
|
688
|
+
held?.deviceId === activation.directory.deviceId &&
|
|
689
|
+
held.revision === activation.directory.revision &&
|
|
690
|
+
held.activeContextId !== activation.directory.activeContextId)
|
|
691
|
+
throw new Error('Device session request was superseded');
|
|
618
692
|
const moved = known === null || activation.directory.revision > known;
|
|
619
693
|
this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
|
|
620
694
|
// Applied BEFORE the reconcile, and only then: the reconcile's own
|
|
@@ -624,8 +698,21 @@ export class SessionClient {
|
|
|
624
698
|
// already holding in hand.
|
|
625
699
|
const applied = this.applyDirectory(activation.directory);
|
|
626
700
|
if (moved) {
|
|
701
|
+
const beforeReconcile = this.captureDeviceRequest();
|
|
702
|
+
const lifecycle = this.lifecycleGeneration;
|
|
627
703
|
await this.reconcileFlatState(activation.directory);
|
|
704
|
+
const landedOnTarget = beforeReconcile.initialDevice !== activation.directory.deviceId &&
|
|
705
|
+
this.deviceGeneration === beforeReconcile.generation + 1 &&
|
|
706
|
+
this.state?.deviceId === activation.directory.deviceId;
|
|
707
|
+
if (lifecycle !== this.lifecycleGeneration ||
|
|
708
|
+
(this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget))
|
|
709
|
+
throw new Error('Device session request was superseded');
|
|
628
710
|
}
|
|
711
|
+
if (this.pinnedAccountId() === null &&
|
|
712
|
+
(this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
|
|
713
|
+
this.directory?.deviceId !== activation.directory.deviceId ||
|
|
714
|
+
this.directory.activeContextId !== contextId))
|
|
715
|
+
throw new Error('Device session request was superseded');
|
|
629
716
|
if (applied) {
|
|
630
717
|
this.notify();
|
|
631
718
|
}
|
|
@@ -634,8 +721,18 @@ export class SessionClient {
|
|
|
634
721
|
}
|
|
635
722
|
}
|
|
636
723
|
async switchAccount(accountId) {
|
|
724
|
+
const request = this.captureDeviceRequest();
|
|
637
725
|
const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
638
|
-
|
|
726
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
727
|
+
const checked = deviceSessionSyncCheck(res, []);
|
|
728
|
+
const isStale = () => checked.ok &&
|
|
729
|
+
this.state?.deviceId === checked.value.state.deviceId &&
|
|
730
|
+
this.state.revision > checked.value.state.revision;
|
|
731
|
+
if (isStale())
|
|
732
|
+
throw new Error('Device session request was superseded');
|
|
733
|
+
await this.applySyncForMutation(res);
|
|
734
|
+
if (isStale())
|
|
735
|
+
throw new Error('Device session request was superseded');
|
|
639
736
|
this.postCommitPing();
|
|
640
737
|
}
|
|
641
738
|
/**
|
|
@@ -650,10 +747,12 @@ export class SessionClient {
|
|
|
650
747
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
651
748
|
*/
|
|
652
749
|
async signOut(target) {
|
|
750
|
+
const request = this.captureDeviceRequest();
|
|
653
751
|
const res = await this.requestDevice('POST', '/session/device/signout', target, {
|
|
654
752
|
cache: false,
|
|
655
753
|
});
|
|
656
|
-
|
|
754
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
755
|
+
await this.applySyncForMutation(res);
|
|
657
756
|
this.postCommitPing();
|
|
658
757
|
if (this.state?.accounts.length === 0)
|
|
659
758
|
await this.persistFullSignOut();
|
|
@@ -695,21 +794,34 @@ export class SessionClient {
|
|
|
695
794
|
* applied this revision.
|
|
696
795
|
*/
|
|
697
796
|
async removeFromDevice(target) {
|
|
797
|
+
const request = this.captureDeviceRequest();
|
|
698
798
|
const res = await this.requestDevice('POST', '/session/device/signout', target, {
|
|
699
799
|
cache: false,
|
|
700
800
|
});
|
|
801
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
701
802
|
const removal = checkContract(deviceDirectorySyncCheck, res);
|
|
702
803
|
if (!removal) {
|
|
703
804
|
logger.warn('[SessionClient] discarded invalid device removal response');
|
|
704
805
|
return;
|
|
705
806
|
}
|
|
706
807
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
808
|
+
const beforeCommit = this.captureDeviceRequest();
|
|
809
|
+
const commitLifecycle = this.lifecycleGeneration;
|
|
810
|
+
const expectedGeneration = beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
|
|
707
811
|
const stateApplied = this.commitSync({
|
|
708
812
|
state: removal.state,
|
|
709
813
|
activeToken: removal.activeToken,
|
|
710
814
|
});
|
|
711
|
-
if (
|
|
815
|
+
if (expectedGeneration !== this.deviceGeneration ||
|
|
816
|
+
commitLifecycle !== this.lifecycleGeneration)
|
|
817
|
+
throw new Error('Device session request was superseded');
|
|
818
|
+
if (this.fullSignOutPersistence?.state === this.state) {
|
|
819
|
+
const generation = this.deviceGeneration;
|
|
820
|
+
const lifecycle = this.lifecycleGeneration;
|
|
712
821
|
await this.fullSignOutPersistence.pending;
|
|
822
|
+
if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
|
|
823
|
+
throw new Error('Device session request was superseded');
|
|
824
|
+
}
|
|
713
825
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
714
826
|
// directory did — a socket push already applied this revision — the
|
|
715
827
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -721,10 +833,12 @@ export class SessionClient {
|
|
|
721
833
|
await this.persistFullSignOut();
|
|
722
834
|
}
|
|
723
835
|
async addCurrentAccount() {
|
|
836
|
+
const request = this.captureDeviceRequest();
|
|
724
837
|
const res = await this.requestDevice('POST', '/session/device/add', undefined, {
|
|
725
838
|
cache: false,
|
|
726
839
|
});
|
|
727
|
-
|
|
840
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
841
|
+
await this.applySyncForMutation(res);
|
|
728
842
|
this.postCommitPing();
|
|
729
843
|
}
|
|
730
844
|
/**
|
|
@@ -228,6 +228,8 @@ export declare class HttpService {
|
|
|
228
228
|
* bearer — and through the token listeners, the account — back.
|
|
229
229
|
*/
|
|
230
230
|
private sessionEpoch;
|
|
231
|
+
/** Preserve a known device across legacy rotations which omit the claim. */
|
|
232
|
+
private sessionDeviceId;
|
|
231
233
|
/**
|
|
232
234
|
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
233
235
|
* native Commons-identity re-mint arm: after a sign-out a 401 is the expected
|
|
@@ -509,8 +511,8 @@ export declare class HttpService {
|
|
|
509
511
|
*/
|
|
510
512
|
endSession(): void;
|
|
511
513
|
/**
|
|
512
|
-
* The current context epoch: account/session switches
|
|
513
|
-
* invalidate pending work;
|
|
514
|
+
* The current context epoch: account/session switches, known device changes,
|
|
515
|
+
* and bearer removal invalidate pending work; same-context rotation preserves it.
|
|
514
516
|
* A re-mint lane that
|
|
515
517
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
516
518
|
*/
|
|
@@ -110,6 +110,8 @@ export declare class SessionClient {
|
|
|
110
110
|
private tokenUnsub;
|
|
111
111
|
private started;
|
|
112
112
|
private lifecycleGeneration;
|
|
113
|
+
/** Changes whenever the locally projected device changes, including ABA. */
|
|
114
|
+
private deviceGeneration;
|
|
113
115
|
private fullSignOutPersistence;
|
|
114
116
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
115
117
|
private channel;
|
|
@@ -120,6 +122,10 @@ export declare class SessionClient {
|
|
|
120
122
|
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
121
123
|
private persistFullSignOut;
|
|
122
124
|
private requestDevice;
|
|
125
|
+
/** Capture the local device before a request, including device ABA. */
|
|
126
|
+
private captureDeviceRequest;
|
|
127
|
+
private isCurrentDeviceRequest;
|
|
128
|
+
private assertCurrentDeviceRequest;
|
|
123
129
|
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
124
130
|
resetLocalState(): void;
|
|
125
131
|
getState(): DeviceSessionState | null;
|
|
@@ -189,6 +195,8 @@ export declare class SessionClient {
|
|
|
189
195
|
* account that is no longer active.
|
|
190
196
|
*/
|
|
191
197
|
private applySync;
|
|
198
|
+
/** Keep mutation callers from reporting success after a device change during persistence. */
|
|
199
|
+
private applySyncForMutation;
|
|
192
200
|
/**
|
|
193
201
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
194
202
|
* sync. Split out so the context-aware removal lane — whose response is a
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -74,6 +74,16 @@ function sameTokenSession(left: string | null, right: string | null): boolean {
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub)) return false;
|
|
77
|
+
// A legacy bearer may omit deviceId; empty or non-string values also carry no
|
|
78
|
+
// usable device. Two known, different devices are distinct local contexts.
|
|
79
|
+
if (
|
|
80
|
+
typeof a.deviceId === 'string' &&
|
|
81
|
+
a.deviceId.length > 0 &&
|
|
82
|
+
typeof b.deviceId === 'string' &&
|
|
83
|
+
b.deviceId.length > 0 &&
|
|
84
|
+
a.deviceId !== b.deviceId
|
|
85
|
+
)
|
|
86
|
+
return false;
|
|
77
87
|
// A refresh may rotate exp/iat/jti while preserving its account and session.
|
|
78
88
|
// Opaque/malformed context fields cannot establish that equivalence.
|
|
79
89
|
return [
|
|
@@ -88,6 +98,14 @@ function sameTokenSession(left: string | null, right: string | null): boolean {
|
|
|
88
98
|
].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
89
99
|
}
|
|
90
100
|
|
|
101
|
+
/** Extract a known device for local ownership checks, never authorization. */
|
|
102
|
+
function knownTokenDeviceId(token: string): string | null {
|
|
103
|
+
const claims = decodeTokenClaims(token);
|
|
104
|
+
return claims?.type !== 'service' && typeof claims?.deviceId === 'string' && claims.deviceId
|
|
105
|
+
? claims.deviceId
|
|
106
|
+
: null;
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
/** Discard an unused body even when a platform's stream shim lacks cancel. */
|
|
92
110
|
function discardResponseBody(response: Response): Promise<void> {
|
|
93
111
|
try {
|
|
@@ -529,6 +547,8 @@ export class HttpService {
|
|
|
529
547
|
* bearer — and through the token listeners, the account — back.
|
|
530
548
|
*/
|
|
531
549
|
private sessionEpoch = 0;
|
|
550
|
+
/** Preserve a known device across legacy rotations which omit the claim. */
|
|
551
|
+
private sessionDeviceId: string | null = null;
|
|
532
552
|
/**
|
|
533
553
|
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
534
554
|
* native Commons-identity re-mint arm: after a sign-out a 401 is the expected
|
|
@@ -825,9 +845,11 @@ export class HttpService {
|
|
|
825
845
|
const authHeader = usesSessionAuth
|
|
826
846
|
? await awaitRequestSignal(this.getAuthHeader(), signal)
|
|
827
847
|
: null;
|
|
828
|
-
const isOriginalSession = () =>
|
|
829
|
-
this.sessionEpoch
|
|
830
|
-
|
|
848
|
+
const isOriginalSession = () => {
|
|
849
|
+
if (this.sessionEpoch !== contextEpoch) return false;
|
|
850
|
+
const currentToken = this.syncAccessTokenFromProvider();
|
|
851
|
+
return this.sessionEpoch === contextEpoch && sameTokenSession(contextToken, currentToken);
|
|
852
|
+
};
|
|
831
853
|
const newerRequestToken = () => {
|
|
832
854
|
if (!contextToken || !authHeader?.startsWith('Bearer ')) return null;
|
|
833
855
|
const requestToken = authHeader.slice('Bearer '.length);
|
|
@@ -1060,6 +1082,7 @@ export class HttpService {
|
|
|
1060
1082
|
if (isOriginalSession()) {
|
|
1061
1083
|
if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
|
|
1062
1084
|
this.tokenStore.clearTokens();
|
|
1085
|
+
this.sessionDeviceId = null;
|
|
1063
1086
|
selfClearedEpoch = this.sessionEpoch;
|
|
1064
1087
|
this.notifyTokenChange();
|
|
1065
1088
|
}
|
|
@@ -2080,7 +2103,13 @@ export class HttpService {
|
|
|
2080
2103
|
// Token management
|
|
2081
2104
|
setTokens(accessToken: string): void {
|
|
2082
2105
|
const current = this.tokenStore.getAccessToken();
|
|
2083
|
-
|
|
2106
|
+
const deviceId = knownTokenDeviceId(accessToken);
|
|
2107
|
+
const changed =
|
|
2108
|
+
current !== null &&
|
|
2109
|
+
(!sameTokenSession(current, accessToken) ||
|
|
2110
|
+
(this.sessionDeviceId !== null && deviceId !== null && this.sessionDeviceId !== deviceId));
|
|
2111
|
+
if (changed) this.sessionEpoch += 1;
|
|
2112
|
+
this.sessionDeviceId = !current || changed ? deviceId : (deviceId ?? this.sessionDeviceId);
|
|
2084
2113
|
this.sessionEnded = false;
|
|
2085
2114
|
this.tokenStore.setTokens(accessToken);
|
|
2086
2115
|
this.notifyTokenChange();
|
|
@@ -2100,8 +2129,8 @@ export class HttpService {
|
|
|
2100
2129
|
}
|
|
2101
2130
|
|
|
2102
2131
|
/**
|
|
2103
|
-
* The current context epoch: account/session switches
|
|
2104
|
-
* invalidate pending work;
|
|
2132
|
+
* The current context epoch: account/session switches, known device changes,
|
|
2133
|
+
* and bearer removal invalidate pending work; same-context rotation preserves it.
|
|
2105
2134
|
* A re-mint lane that
|
|
2106
2135
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
2107
2136
|
*/
|
|
@@ -2136,6 +2165,7 @@ export class HttpService {
|
|
|
2136
2165
|
clearTokens(): void {
|
|
2137
2166
|
if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
|
|
2138
2167
|
this.tokenStore.clearTokens();
|
|
2168
|
+
this.sessionDeviceId = null;
|
|
2139
2169
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
2140
2170
|
// different user could never read these entries, but a logged-out client
|
|
2141
2171
|
// must not keep the previous session's personalized data resident in
|
|
@@ -2309,5 +2339,6 @@ export class HttpService {
|
|
|
2309
2339
|
__resetTokensForTests(): void {
|
|
2310
2340
|
this.sessionEnded = false;
|
|
2311
2341
|
this.tokenStore.clearTokens();
|
|
2342
|
+
this.sessionDeviceId = null;
|
|
2312
2343
|
}
|
|
2313
2344
|
}
|
|
@@ -149,6 +149,8 @@ export class SessionClient {
|
|
|
149
149
|
private tokenUnsub: (() => void) | null = null;
|
|
150
150
|
private started = false;
|
|
151
151
|
private lifecycleGeneration = 0;
|
|
152
|
+
/** Changes whenever the locally projected device changes, including ABA. */
|
|
153
|
+
private deviceGeneration = 0;
|
|
152
154
|
private fullSignOutPersistence: { state: DeviceSessionState; pending: Promise<void> } | null =
|
|
153
155
|
null;
|
|
154
156
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
@@ -188,6 +190,36 @@ export class SessionClient {
|
|
|
188
190
|
return response;
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/** Capture the local device before a request, including device ABA. */
|
|
194
|
+
private captureDeviceRequest(): { generation: number; initialDevice: string | null } {
|
|
195
|
+
return { generation: this.deviceGeneration, initialDevice: this.state?.deviceId ?? null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private isCurrentDeviceRequest(
|
|
199
|
+
request: { generation: number; initialDevice: string | null },
|
|
200
|
+
response: unknown,
|
|
201
|
+
): boolean {
|
|
202
|
+
if (request.generation === this.deviceGeneration) return true;
|
|
203
|
+
if (request.initialDevice !== null || this.deviceGeneration !== request.generation + 1)
|
|
204
|
+
return false;
|
|
205
|
+
if (response === null || typeof response !== 'object') return false;
|
|
206
|
+
const record = response as {
|
|
207
|
+
deviceId?: unknown;
|
|
208
|
+
state?: { deviceId?: unknown };
|
|
209
|
+
directory?: { deviceId?: unknown };
|
|
210
|
+
};
|
|
211
|
+
const deviceId = record.state?.deviceId ?? record.directory?.deviceId ?? record.deviceId;
|
|
212
|
+
return typeof deviceId === 'string' && deviceId === this.state?.deviceId;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private assertCurrentDeviceRequest(
|
|
216
|
+
request: { generation: number; initialDevice: string | null },
|
|
217
|
+
response: unknown,
|
|
218
|
+
): void {
|
|
219
|
+
if (!this.isCurrentDeviceRequest(request, response))
|
|
220
|
+
throw new Error('Device session request was superseded');
|
|
221
|
+
}
|
|
222
|
+
|
|
191
223
|
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
192
224
|
resetLocalState(): void {
|
|
193
225
|
this.lifecycleGeneration += 1;
|
|
@@ -351,6 +383,7 @@ export class SessionClient {
|
|
|
351
383
|
return false;
|
|
352
384
|
}
|
|
353
385
|
const previousState = this.state;
|
|
386
|
+
if (previousState?.deviceId !== next.deviceId) this.deviceGeneration += 1;
|
|
354
387
|
this.state = next;
|
|
355
388
|
if (next.accounts.length === 0) {
|
|
356
389
|
// A device with nobody signed in has no principals either. The directory
|
|
@@ -465,7 +498,7 @@ export class SessionClient {
|
|
|
465
498
|
.ensureActiveToken(next)
|
|
466
499
|
.then(finishApply)
|
|
467
500
|
.catch((error) => {
|
|
468
|
-
if (generation !== this.lifecycleGeneration) return;
|
|
501
|
+
if (generation !== this.lifecycleGeneration || this.state !== next) return;
|
|
469
502
|
logger.warn(
|
|
470
503
|
'[SessionClient] ensureActiveToken failed — reverting session state',
|
|
471
504
|
{ component: 'SessionClient' },
|
|
@@ -473,6 +506,7 @@ export class SessionClient {
|
|
|
473
506
|
);
|
|
474
507
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
475
508
|
// state so subscribers keep observing the account whose token is planted.
|
|
509
|
+
if (this.state?.deviceId !== previousState?.deviceId) this.deviceGeneration += 1;
|
|
476
510
|
this.state = previousState ?? null;
|
|
477
511
|
});
|
|
478
512
|
} else {
|
|
@@ -527,6 +561,25 @@ export class SessionClient {
|
|
|
527
561
|
await this.fullSignOutPersistence.pending;
|
|
528
562
|
}
|
|
529
563
|
|
|
564
|
+
/** Keep mutation callers from reporting success after a device change during persistence. */
|
|
565
|
+
private async applySyncForMutation(raw: unknown): Promise<void> {
|
|
566
|
+
const before = this.captureDeviceRequest();
|
|
567
|
+
const lifecycle = this.lifecycleGeneration;
|
|
568
|
+
const checked = deviceSessionSyncCheck(raw, []);
|
|
569
|
+
const responseDevice = checked.ok ? checked.value.state.deviceId : before.initialDevice;
|
|
570
|
+
const expectedGeneration =
|
|
571
|
+
before.generation + (responseDevice === before.initialDevice ? 0 : 1);
|
|
572
|
+
const pending = this.applySync(raw);
|
|
573
|
+
const generation = this.deviceGeneration;
|
|
574
|
+
await pending;
|
|
575
|
+
if (
|
|
576
|
+
generation !== expectedGeneration ||
|
|
577
|
+
generation !== this.deviceGeneration ||
|
|
578
|
+
lifecycle !== this.lifecycleGeneration
|
|
579
|
+
)
|
|
580
|
+
throw new Error('Device session request was superseded');
|
|
581
|
+
}
|
|
582
|
+
|
|
530
583
|
/**
|
|
531
584
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
532
585
|
* sync. Split out so the context-aware removal lane — whose response is a
|
|
@@ -546,15 +599,16 @@ export class SessionClient {
|
|
|
546
599
|
const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
|
|
547
600
|
// Equal-revision restate (this revision was already applied by a preceding
|
|
548
601
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
549
|
-
// needs planting.
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
602
|
+
// needs planting. Only the exact same device revision is an equal-revision
|
|
603
|
+
// restate: an older response may identify a different operator of the same
|
|
604
|
+
// account. Also require the current account and the identity pin to match.
|
|
553
605
|
const pinnedAccountId = this.pinnedAccountId();
|
|
554
606
|
if (
|
|
555
607
|
!applied &&
|
|
556
608
|
sync.activeToken &&
|
|
557
609
|
this.state &&
|
|
610
|
+
sync.state.deviceId === this.state.deviceId &&
|
|
611
|
+
sync.state.revision === this.state.revision &&
|
|
558
612
|
sync.state.activeAccountId !== null &&
|
|
559
613
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
560
614
|
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
@@ -609,9 +663,11 @@ export class SessionClient {
|
|
|
609
663
|
|
|
610
664
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
611
665
|
private async fetchDirectory(): Promise<boolean> {
|
|
666
|
+
const request = this.captureDeviceRequest();
|
|
612
667
|
const res = await this.requestDevice<unknown>('GET', '/session/device/directory', undefined, {
|
|
613
668
|
cache: false,
|
|
614
669
|
});
|
|
670
|
+
if (!this.isCurrentDeviceRequest(request, res)) return false;
|
|
615
671
|
return this.applyDirectory(res);
|
|
616
672
|
}
|
|
617
673
|
|
|
@@ -684,14 +740,12 @@ export class SessionClient {
|
|
|
684
740
|
if (!accessToken) {
|
|
685
741
|
return;
|
|
686
742
|
}
|
|
743
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
687
744
|
const subjectAccountId = resolveActiveContext(directory)?.subject.accountId ?? null;
|
|
688
745
|
if (subjectAccountId === null) {
|
|
689
746
|
return;
|
|
690
747
|
}
|
|
691
|
-
|
|
692
|
-
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
748
|
+
if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) return;
|
|
695
749
|
if (accessToken === this.host.getAccessToken()) {
|
|
696
750
|
return;
|
|
697
751
|
}
|
|
@@ -738,9 +792,11 @@ export class SessionClient {
|
|
|
738
792
|
}
|
|
739
793
|
|
|
740
794
|
async bootstrap(): Promise<void> {
|
|
795
|
+
const request = this.captureDeviceRequest();
|
|
741
796
|
const res = await this.requestDevice<unknown>('GET', '/session/device/state', undefined, {
|
|
742
797
|
cache: false,
|
|
743
798
|
});
|
|
799
|
+
if (!this.isCurrentDeviceRequest(request, res)) return;
|
|
744
800
|
await this.applySync(res);
|
|
745
801
|
}
|
|
746
802
|
|
|
@@ -787,18 +843,51 @@ export class SessionClient {
|
|
|
787
843
|
* compatibility path for callers still keyed on account ids.
|
|
788
844
|
*/
|
|
789
845
|
async activateContext(contextId: string): Promise<void> {
|
|
846
|
+
const request = this.captureDeviceRequest();
|
|
790
847
|
const res = await this.requestDevice<unknown>(
|
|
791
848
|
'POST',
|
|
792
849
|
'/session/device/activate',
|
|
793
850
|
{ contextId },
|
|
794
851
|
{ cache: false },
|
|
795
852
|
);
|
|
853
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
796
854
|
const activation = checkContract(deviceActivateResponseCheck, res);
|
|
797
855
|
if (!activation) {
|
|
798
856
|
logger.warn('[SessionClient] discarded invalid activation response');
|
|
799
857
|
return;
|
|
800
858
|
}
|
|
859
|
+
if (this.pinnedAccountId() === null) {
|
|
860
|
+
const active = resolveActiveContext(activation.directory);
|
|
861
|
+
if (!active || activation.directory.activeContextId !== contextId)
|
|
862
|
+
throw new Error('Device session request was superseded');
|
|
863
|
+
const claims = activation.activeToken?.accessToken
|
|
864
|
+
? decodeTokenClaims(activation.activeToken.accessToken)
|
|
865
|
+
: null;
|
|
866
|
+
const contextClaim = claims?.device_context_id;
|
|
867
|
+
const actor = claims?.act;
|
|
868
|
+
const actorClaim =
|
|
869
|
+
actor !== null && typeof actor === 'object' ? (actor as { sub?: unknown }).sub : undefined;
|
|
870
|
+
const subjectClaim = claims?.userId ?? claims?.id ?? claims?.sub;
|
|
871
|
+
// Decoded claims are only a consistency check, never authorization.
|
|
872
|
+
// Legacy and opaque bearers may omit these claims entirely.
|
|
873
|
+
if (
|
|
874
|
+
(typeof contextClaim === 'string' && contextClaim !== active.contextId) ||
|
|
875
|
+
(typeof actorClaim === 'string' && actorClaim !== active.actor.userId) ||
|
|
876
|
+
(typeof subjectClaim === 'string' && subjectClaim !== active.subject.accountId)
|
|
877
|
+
)
|
|
878
|
+
throw new Error('Device session request was superseded');
|
|
879
|
+
}
|
|
801
880
|
const known = this.knownRevisionFor(activation.directory.deviceId);
|
|
881
|
+
if (known !== null && activation.directory.revision < known)
|
|
882
|
+
throw new Error('Device session request was superseded');
|
|
883
|
+
const held = this.directory;
|
|
884
|
+
if (
|
|
885
|
+
this.pinnedAccountId() === null &&
|
|
886
|
+
held?.deviceId === activation.directory.deviceId &&
|
|
887
|
+
held.revision === activation.directory.revision &&
|
|
888
|
+
held.activeContextId !== activation.directory.activeContextId
|
|
889
|
+
)
|
|
890
|
+
throw new Error('Device session request was superseded');
|
|
802
891
|
const moved = known === null || activation.directory.revision > known;
|
|
803
892
|
this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
|
|
804
893
|
// Applied BEFORE the reconcile, and only then: the reconcile's own
|
|
@@ -808,8 +897,26 @@ export class SessionClient {
|
|
|
808
897
|
// already holding in hand.
|
|
809
898
|
const applied = this.applyDirectory(activation.directory);
|
|
810
899
|
if (moved) {
|
|
900
|
+
const beforeReconcile = this.captureDeviceRequest();
|
|
901
|
+
const lifecycle = this.lifecycleGeneration;
|
|
811
902
|
await this.reconcileFlatState(activation.directory);
|
|
903
|
+
const landedOnTarget =
|
|
904
|
+
beforeReconcile.initialDevice !== activation.directory.deviceId &&
|
|
905
|
+
this.deviceGeneration === beforeReconcile.generation + 1 &&
|
|
906
|
+
this.state?.deviceId === activation.directory.deviceId;
|
|
907
|
+
if (
|
|
908
|
+
lifecycle !== this.lifecycleGeneration ||
|
|
909
|
+
(this.deviceGeneration !== beforeReconcile.generation && !landedOnTarget)
|
|
910
|
+
)
|
|
911
|
+
throw new Error('Device session request was superseded');
|
|
812
912
|
}
|
|
913
|
+
if (
|
|
914
|
+
this.pinnedAccountId() === null &&
|
|
915
|
+
(this.knownRevisionFor(activation.directory.deviceId) !== activation.directory.revision ||
|
|
916
|
+
this.directory?.deviceId !== activation.directory.deviceId ||
|
|
917
|
+
this.directory.activeContextId !== contextId)
|
|
918
|
+
)
|
|
919
|
+
throw new Error('Device session request was superseded');
|
|
813
920
|
if (applied) {
|
|
814
921
|
this.notify();
|
|
815
922
|
}
|
|
@@ -819,13 +926,22 @@ export class SessionClient {
|
|
|
819
926
|
}
|
|
820
927
|
|
|
821
928
|
async switchAccount(accountId: string): Promise<void> {
|
|
929
|
+
const request = this.captureDeviceRequest();
|
|
822
930
|
const res = await this.requestDevice<unknown>(
|
|
823
931
|
'POST',
|
|
824
932
|
'/session/device/switch',
|
|
825
933
|
{ accountId },
|
|
826
934
|
{ cache: false },
|
|
827
935
|
);
|
|
828
|
-
|
|
936
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
937
|
+
const checked = deviceSessionSyncCheck(res, []);
|
|
938
|
+
const isStale = () =>
|
|
939
|
+
checked.ok &&
|
|
940
|
+
this.state?.deviceId === checked.value.state.deviceId &&
|
|
941
|
+
this.state.revision > checked.value.state.revision;
|
|
942
|
+
if (isStale()) throw new Error('Device session request was superseded');
|
|
943
|
+
await this.applySyncForMutation(res);
|
|
944
|
+
if (isStale()) throw new Error('Device session request was superseded');
|
|
829
945
|
this.postCommitPing();
|
|
830
946
|
}
|
|
831
947
|
|
|
@@ -841,10 +957,12 @@ export class SessionClient {
|
|
|
841
957
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
842
958
|
*/
|
|
843
959
|
async signOut(target: { accountId: string } | { all: true }): Promise<void> {
|
|
960
|
+
const request = this.captureDeviceRequest();
|
|
844
961
|
const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, {
|
|
845
962
|
cache: false,
|
|
846
963
|
});
|
|
847
|
-
|
|
964
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
965
|
+
await this.applySyncForMutation(res);
|
|
848
966
|
this.postCommitPing();
|
|
849
967
|
if (this.state?.accounts.length === 0) await this.persistFullSignOut();
|
|
850
968
|
}
|
|
@@ -890,21 +1008,37 @@ export class SessionClient {
|
|
|
890
1008
|
private async removeFromDevice(
|
|
891
1009
|
target: { contextId: string } | { principalId: string },
|
|
892
1010
|
): Promise<void> {
|
|
1011
|
+
const request = this.captureDeviceRequest();
|
|
893
1012
|
const res = await this.requestDevice<unknown>('POST', '/session/device/signout', target, {
|
|
894
1013
|
cache: false,
|
|
895
1014
|
});
|
|
1015
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
896
1016
|
const removal = checkContract(deviceDirectorySyncCheck, res);
|
|
897
1017
|
if (!removal) {
|
|
898
1018
|
logger.warn('[SessionClient] discarded invalid device removal response');
|
|
899
1019
|
return;
|
|
900
1020
|
}
|
|
901
1021
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
1022
|
+
const beforeCommit = this.captureDeviceRequest();
|
|
1023
|
+
const commitLifecycle = this.lifecycleGeneration;
|
|
1024
|
+
const expectedGeneration =
|
|
1025
|
+
beforeCommit.generation + (beforeCommit.initialDevice === removal.state.deviceId ? 0 : 1);
|
|
902
1026
|
const stateApplied = this.commitSync({
|
|
903
1027
|
state: removal.state,
|
|
904
1028
|
activeToken: removal.activeToken,
|
|
905
1029
|
});
|
|
906
|
-
if (
|
|
1030
|
+
if (
|
|
1031
|
+
expectedGeneration !== this.deviceGeneration ||
|
|
1032
|
+
commitLifecycle !== this.lifecycleGeneration
|
|
1033
|
+
)
|
|
1034
|
+
throw new Error('Device session request was superseded');
|
|
1035
|
+
if (this.fullSignOutPersistence?.state === this.state) {
|
|
1036
|
+
const generation = this.deviceGeneration;
|
|
1037
|
+
const lifecycle = this.lifecycleGeneration;
|
|
907
1038
|
await this.fullSignOutPersistence.pending;
|
|
1039
|
+
if (generation !== this.deviceGeneration || lifecycle !== this.lifecycleGeneration)
|
|
1040
|
+
throw new Error('Device session request was superseded');
|
|
1041
|
+
}
|
|
908
1042
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
909
1043
|
// directory did — a socket push already applied this revision — the
|
|
910
1044
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -916,10 +1050,12 @@ export class SessionClient {
|
|
|
916
1050
|
}
|
|
917
1051
|
|
|
918
1052
|
async addCurrentAccount(): Promise<void> {
|
|
1053
|
+
const request = this.captureDeviceRequest();
|
|
919
1054
|
const res = await this.requestDevice<unknown>('POST', '/session/device/add', undefined, {
|
|
920
1055
|
cache: false,
|
|
921
1056
|
});
|
|
922
|
-
|
|
1057
|
+
this.assertCurrentDeviceRequest(request, res);
|
|
1058
|
+
await this.applySyncForMutation(res);
|
|
923
1059
|
this.postCommitPing();
|
|
924
1060
|
}
|
|
925
1061
|
|