Skip to content

Commit 50103c3

Browse files
committed
fix(clerk-js): store token cache expiry on the token, not the slot
The device-clock expiry, min(exp, cachedAt + lifetime), was stored per cache slot and computed from that slot's cachedAt. A slot can serve a token it did not fetch, though: a fresher token carried over from an earlier slot, or the same JWT cached again via hydration or a broadcast. Its lifetime was then counted from the later slot, and on a device clock running behind, the token was served after the server stopped accepting it. Compute the expiry once, when a resolver delivers the token, from that resolver's own slot, and keep it with the token as it moves between slots. When the same JWT is seen again, the earlier expiry wins.
1 parent e264249 commit 50103c3

2 files changed

Lines changed: 212 additions & 49 deletions

File tree

‎packages/clerk-js/src/core/__tests__/tokenCache.test.ts‎

Lines changed: 166 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ const tick = async () => {
5151

5252
const makeToken = (raw: string, id = 'session_123') => new Token({ id, jwt: raw, object: 'token' }) as TokenResource;
5353

54+
const deferred = () => {
55+
let resolve!: (token: TokenResource) => void;
56+
const promise = new Promise<TokenResource>(r => {
57+
resolve = r;
58+
});
59+
return { promise, resolve };
60+
};
61+
5462
describe('SessionTokenCache', () => {
5563
let mockBroadcastChannel: {
5664
addEventListener: ReturnType<typeof vi.fn>;
@@ -429,21 +437,13 @@ describe('SessionTokenCache', () => {
429437
describe('same-tab monotonic resolve', () => {
430438
const tokenId = 'session_123';
431439

432-
const deferred = () => {
433-
let resolve!: (token: TokenResource) => void;
434-
const promise = new Promise<TokenResource>(r => {
435-
resolve = r;
436-
});
437-
return { promise, resolve };
438-
};
439-
440440
it('keeps the fresher token when a staler set resolves into the slot after it', async () => {
441441
const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);
442442
const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120);
443443

444444
// A fresher token is cached and resolved, then a staler set() replaces the slot and resolves.
445445
// The staler resolve reconciles against the previous token (carried onto the new slot's
446-
// baseline at set time) and loses.
446+
// freshest token at set time) and loses.
447447
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) });
448448
await tick();
449449
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) });
@@ -536,8 +536,8 @@ describe('SessionTokenCache', () => {
536536

537537
// Two concurrent sets, both pending. The first (fresher) is replaced by the second (staler)
538538
// before either resolves. When the fresher one resolves it is now foreign, so it only raises
539-
// the live slot's baseline, leaving resolvedToken undefined. The staler live resolve then
540-
// reconciles against that baseline and the slot publishes the fresher token.
539+
// the live slot's freshest token, leaving resolvedToken undefined. The staler live resolve then
540+
// reconciles against that freshest token and the slot publishes the fresher token.
541541
const dHigh = deferred();
542542
const dLow = deferred();
543543
SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise });
@@ -564,7 +564,7 @@ describe('SessionTokenCache', () => {
564564
const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120);
565565

566566
// Inverse ordering of the previous test: the staler set is replaced by the fresher one before
567-
// either resolves. The staler foreign resolve lands first and only advances the baseline; the
567+
// either resolves. The staler foreign resolve lands first and only advances the freshest token; the
568568
// fresher live resolve then publishes high directly.
569569
const dLow = deferred();
570570
const dHigh = deferred();
@@ -588,7 +588,7 @@ describe('SessionTokenCache', () => {
588588
// The live slot (d2) resolves staler first and publishes low. The replaced resolver (d1)
589589
// resolves fresher afterward. Because the slot is no longer pending, the fresher foreign token
590590
// advances resolvedToken to high and re-derives the slot's timers, rather than only raising the
591-
// baseline.
591+
// freshest token.
592592
const dHigh = deferred();
593593
const dLow = deferred();
594594
SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise });
@@ -606,22 +606,22 @@ describe('SessionTokenCache', () => {
606606
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);
607607
});
608608

609-
it('never exposes the baseline while a replacement is pending, then publishes high once it resolves', async () => {
609+
it('never exposes the freshest token while a replacement is pending, then publishes high once it resolves', async () => {
610610
const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);
611611
const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120);
612612

613-
// The first set resolves to high, so the slot's resolvedToken and baseline are both high.
613+
// The first set resolves to high, so the slot's resolvedToken and freshest token are both high.
614614
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) });
615615
await tick();
616616
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);
617617

618-
// A replacement whose resolver never settles must not expose the carried baseline: the slot
618+
// A replacement whose resolver never settles must not expose the carried freshest token: the slot
619619
// reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch.
620620
const pending = deferred();
621621
SessionTokenCache.set({ tokenId, tokenResolver: pending.promise });
622622
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined();
623623

624-
// When it finally resolves staler, the own resolve reconciles against the baseline and the slot
624+
// When it finally resolves staler, the own resolve reconciles against the freshest token and the slot
625625
// publishes high.
626626
pending.resolve(makeToken(lowRaw));
627627
await tick();
@@ -824,6 +824,155 @@ describe('SessionTokenCache', () => {
824824
vi.advanceTimersByTime(11 * 1000);
825825
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
826826
});
827+
828+
it('keeps the fresher token expiry when it is carried into a later slot', async () => {
829+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
830+
const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60);
831+
832+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) });
833+
await tick();
834+
835+
vi.advanceTimersByTime(30 * 1000);
836+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)) });
837+
await tick();
838+
839+
vi.advanceTimersByTime(20 * 1000);
840+
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw);
841+
842+
vi.advanceTimersByTime(6 * 1000);
843+
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
844+
});
845+
846+
it('does not extend expiry when the same token is cached again as a new Token', async () => {
847+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
848+
849+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) });
850+
await tick();
851+
852+
vi.advanceTimersByTime(30 * 1000);
853+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) });
854+
await tick();
855+
856+
vi.advanceTimersByTime(20 * 1000);
857+
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw);
858+
859+
vi.advanceTimersByTime(6 * 1000);
860+
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
861+
});
862+
863+
it('does not extend expiry when the same token is received by broadcast after a local cache', async () => {
864+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
865+
866+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) });
867+
await tick();
868+
869+
vi.advanceTimersByTime(30 * 1000);
870+
await broadcastListener({
871+
data: { organizationId: null, sessionId: tokenId, tokenId, tokenRaw: freshRaw, traceId: 'skew' },
872+
} as MessageEvent<SessionTokenEvent>);
873+
await tick();
874+
875+
vi.advanceTimersByTime(20 * 1000);
876+
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw);
877+
878+
vi.advanceTimersByTime(6 * 1000);
879+
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
880+
});
881+
882+
it('counts a replaced resolver token lifetime from its own set, not the live slot', async () => {
883+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
884+
const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60);
885+
const dFresh = deferred();
886+
const dStaler = deferred();
887+
888+
SessionTokenCache.set({ tokenId, tokenResolver: dFresh.promise });
889+
890+
vi.advanceTimersByTime(10 * 1000);
891+
SessionTokenCache.set({ tokenId, tokenResolver: dStaler.promise });
892+
893+
dFresh.resolve(makeToken(freshRaw));
894+
await tick();
895+
dStaler.resolve(makeToken(stalerRaw));
896+
await tick();
897+
898+
vi.advanceTimersByTime(40 * 1000);
899+
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw);
900+
901+
vi.advanceTimersByTime(6 * 1000);
902+
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
903+
});
904+
905+
it('gives a fresher token with a different raw string its own lifetime', async () => {
906+
const firstRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
907+
const fresherRaw = createJwtWithOiat(deviceNow + 30 + deviceLag, deviceNow + 30 + deviceLag, 60);
908+
909+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)) });
910+
await tick();
911+
912+
vi.advanceTimersByTime(30 * 1000);
913+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(fresherRaw)) });
914+
await tick();
915+
916+
vi.advanceTimersByTime(26 * 1000);
917+
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(fresherRaw);
918+
919+
vi.advanceTimersByTime(30 * 1000);
920+
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
921+
});
922+
923+
it('fires the proactive refresh from the carried token expiry, not the later slot', async () => {
924+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
925+
const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60);
926+
const onRefresh = vi.fn();
927+
928+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)), onRefresh });
929+
await tick();
930+
931+
vi.advanceTimersByTime(30 * 1000);
932+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)), onRefresh });
933+
await tick();
934+
935+
vi.advanceTimersByTime(12 * 1000);
936+
expect(onRefresh).not.toHaveBeenCalled();
937+
938+
vi.advanceTimersByTime(1 * 1000);
939+
expect(onRefresh).toHaveBeenCalledTimes(1);
940+
});
941+
942+
it('takes the earlier expiry when a replaced resolver delivers the same token after the slot resolved', async () => {
943+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
944+
const dFirst = deferred();
945+
946+
SessionTokenCache.set({ tokenId, tokenResolver: dFirst.promise });
947+
948+
vi.advanceTimersByTime(10 * 1000);
949+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) });
950+
await tick();
951+
952+
dFirst.resolve(makeToken(freshRaw));
953+
await tick();
954+
955+
vi.advanceTimersByTime(40 * 1000);
956+
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw);
957+
958+
vi.advanceTimersByTime(6 * 1000);
959+
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
960+
});
961+
962+
it('keeps a pending slot when the token carried into it is close to expiry', async () => {
963+
const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60);
964+
965+
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) });
966+
await tick();
967+
968+
vi.advanceTimersByTime(50 * 1000);
969+
SessionTokenCache.set({ tokenId, tokenResolver: deferred().promise });
970+
971+
vi.advanceTimersByTime(8 * 1000);
972+
const result = SessionTokenCache.get({ tokenId });
973+
expect(result).toBeDefined();
974+
expect(result?.entry.resolvedToken).toBeUndefined();
975+
});
827976
});
828977

829978
describe('broadcast sending', () => {

0 commit comments

Comments
 (0)