Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/monotonic-session-token-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Apply session tokens monotonically by origin-issued-at on a single tab, so a stale edge-minted token can no longer overwrite a fresher one in the `__session` cookie, the session's last active token, or the token cache.
146 changes: 146 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,152 @@ describe('Clerk singleton', () => {
);
});

describe('updateSessionCookie monotonic backstop', () => {
const sessionId = 'sess_active';

const createJwtWithOiat = (
iat: number,
oiat: number | undefined,
opts: { sid?: string; org?: string; ttl?: number } = {},
): string => {
const { sid = sessionId, org, ttl = 60 } = opts;
const header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' };
if (oiat !== undefined) {
header.oiat = oiat;
}
const payload: Record<string, unknown> = { sid, iat, exp: iat + ttl };
if (org) {
payload.org_id = org;
}
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return `${b64(header)}.${b64(payload)}.test-signature`;
};

const loadClerkWithSession = async () => {
const mockSession = {
id: sessionId,
status: 'active',
user: {},
getToken: vi.fn(),
lastActiveToken: { getRawString: () => mockJwt },
};
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
const sut = new Clerk(productionPublishableKey);
await sut.load();
return sut;
};

const emitToken = (raw: string | null) => {
eventBus.emit(events.TokenUpdate, {
token: raw === null ? null : ({ jwt: {}, getRawString: () => raw } as any),
});
};

it('drops a strictly-staler same-context token and keeps the fresher cookie', async () => {
await loadClerkWithSession();

const fresh = createJwtWithOiat(1000, 200);
emitToken(fresh);
expect(document.cookie).toContain(fresh);

const stale = createJwtWithOiat(900, 100);
emitToken(stale);
expect(document.cookie).not.toContain(stale);
expect(document.cookie).toContain(fresh);
});

it('applies a fresher same-context token', async () => {
await loadClerkWithSession();

const older = createJwtWithOiat(1000, 100);
emitToken(older);
expect(document.cookie).toContain(older);

const newer = createJwtWithOiat(1100, 200);
emitToken(newer);
expect(document.cookie).toContain(newer);
});

it('applies a token with equal oiat and iat (publish on tie)', async () => {
await loadClerkWithSession();

const first = createJwtWithOiat(1000, 100, { ttl: 60 });
emitToken(first);
expect(document.cookie).toContain(first);

const second = createJwtWithOiat(1000, 100, { ttl: 120 });
emitToken(second);
expect(document.cookie).toContain(second);
});

it('writes a token for a different session (cross-context cookies are not compared)', async () => {
await loadClerkWithSession();

const otherSession = createJwtWithOiat(1000, 200, { sid: 'sess_other' });
emitToken(otherSession);
expect(document.cookie).toContain(otherSession);
});

it('writes a token for a different organization (cross-context cookies are not compared)', async () => {
await loadClerkWithSession();

const otherOrg = createJwtWithOiat(1000, 200, { org: 'org_other' });
emitToken(otherOrg);
expect(document.cookie).toContain(otherOrg);
});

it('applies a personal-workspace token (no org) for the active personal workspace', async () => {
await loadClerkWithSession();

const personal = createJwtWithOiat(1000, 200);
emitToken(personal);
expect(document.cookie).toContain(personal);
});

it('applies an active-context token even when the current cookie is a different session with higher oiat', async () => {
const sut = await loadClerkWithSession();

// Plant a different-session, higher-oiat cookie by temporarily making it the active context.
(sut.session as any).id = 'sess_other';
const otherContext = createJwtWithOiat(2000, 999, { sid: 'sess_other' });
emitToken(otherContext);
expect(document.cookie).toContain(otherContext);

// Restore the active session; a lower-oiat active-context token must still apply,
// because the different-session cookie is not a valid freshness baseline.
(sut.session as any).id = sessionId;
const active = createJwtWithOiat(1000, 100, { sid: sessionId });
emitToken(active);
expect(document.cookie).toContain(active);
});

it('applies a token without an oiat header (fail open)', async () => {
await loadClerkWithSession();

const noOiat = createJwtWithOiat(1000, undefined);
emitToken(noOiat);
expect(document.cookie).toContain(noOiat);
});

it('applies a malformed token (fail open)', async () => {
await loadClerkWithSession();

emitToken('garbage.token');
expect(document.cookie).toContain('garbage.token');
});

it('removes the cookie when the token is null', async () => {
await loadClerkWithSession();

const fresh = createJwtWithOiat(1000, 200);
emitToken(fresh);
expect(document.cookie).toContain(fresh);

emitToken(null);
expect(document.cookie).not.toContain(fresh);
});
});

describe('.signOut()', () => {
const mockClientDestroy = vi.fn();
const mockClientRemoveSessions = vi.fn();
Expand Down
207 changes: 207 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,58 @@ describe('SessionTokenCache', () => {
expect(resultAfterNewer?.entry.createdAt).toBe(1666648250);
});

it('ignores a broadcast staler than a carried-forward resolvedToken even when the resolver is staler', async () => {
// resolvedToken carry-forward can leave the live entry's resolvedToken FRESHER than
// the token its tokenResolver resolves to. The broadcast guard must compare against
// resolvedToken (the freshest known), not the staler resolver, otherwise a broadcast
// that is staler than resolvedToken slips past the guard and runs setInternal, which
// clears the refresh timer without reinstalling one.
const tokenId = 'session_123';
const tick = async () => {
await Promise.resolve();
await Promise.resolve();
};
const makeToken = (raw: string) => new Token({ id: tokenId, jwt: raw, object: 'token' }) as TokenResource;

const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);
const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120);

// Cache the high-oiat token and let it resolve so resolvedToken = high.
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) });
await tick();
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);

// Overwrite with a resolver that resolves to a LOWER-oiat token. Carry-forward keeps the
// live entry's resolvedToken = high while its tokenResolver resolves to low.
SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) });
await tick();
const beforeEntry = SessionTokenCache.get({ tokenId })?.entry;
expect(beforeEntry?.resolvedToken?.getRawString()).toBe(highRaw);
const beforeCreatedAt = beforeEntry?.createdAt;

// Broadcast a token staler than high (but fresher than low, so the staler resolver would
// have let it through under the bug).
const stalerRaw = createJwtWithOiat(1666648220, 1666648220, 120);
const stalerEvent: MessageEvent<SessionTokenEvent> = {
data: {
organizationId: null,
sessionId: 'session_123',
template: undefined,
tokenId,
tokenRaw: stalerRaw,
traceId: 'test_trace_carry_forward',
},
} as MessageEvent<SessionTokenEvent>;

await broadcastListener(stalerEvent);

const afterEntry = SessionTokenCache.get({ tokenId })?.entry;
expect(afterEntry?.resolvedToken?.getRawString()).toBe(highRaw);
// createdAt unchanged proves the broadcast was dropped before setInternal ran; the bug
// would have replaced the entry, stamping it with the broadcast's iat (1666648220).
expect(afterEntry?.createdAt).toBe(beforeCreatedAt);
});

it('successfully updates cache with valid token', () => {
const event: MessageEvent<SessionTokenEvent> = {
data: {
Expand Down Expand Up @@ -370,6 +422,161 @@ describe('SessionTokenCache', () => {
});
});

describe('same-tab monotonic resolve', () => {
const tokenId = 'session_123';

// Flush enough microtasks for setInternal's tokenResolver.then handler to run.
const tick = async () => {
await Promise.resolve();
await Promise.resolve();
};

const deferred = () => {
let resolve!: (token: TokenResource) => void;
const promise = new Promise<TokenResource>(r => {
resolve = r;
});
return { promise, resolve };
};

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

it('keeps the fresher token when a staler set overwrites it, resolving high then low', async () => {
const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);
const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120);

const high = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined });

const low = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined });

high.resolve(makeToken(highRaw));
await tick();
low.resolve(makeToken(lowRaw));
await tick();

expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);
});

it('keeps the fresher token when a staler set overwrites it, resolving low then high', async () => {
const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);
const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120);

const high = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined });

const low = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined });

low.resolve(makeToken(lowRaw));
await tick();
high.resolve(makeToken(highRaw));
await tick();

expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);
});

it('publishes the later token on a full oiat+iat tie with different raw payloads', async () => {
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
const header = { alg: 'HS256', typ: 'JWT', oiat: 1666648250 };
const firstRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_A', exp: 1666648370, iat: 1666648250 })}.sig`;
const laterRaw = `${b64(header)}.${b64({ sid: tokenId, sub: 'user_B', exp: 1666648370, iat: 1666648250 })}.sig`;

const first = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: first.promise, onRefresh: undefined });

const later = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: later.promise, onRefresh: undefined });

first.resolve(makeToken(firstRaw));
await tick();
later.resolve(makeToken(laterRaw));
await tick();

expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(laterRaw);
});

it('carries the prior resolved token forward into a new pending entry', async () => {
const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);

const high = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined });
high.resolve(makeToken(highRaw));
await tick();

expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);

const pending = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined });

expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);
});

it('does not resurrect a cleared key when its pending resolver settles', async () => {
const raw = createJwtWithOiat(1666648250, 1666648250, 120);

const pending = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: pending.promise, onRefresh: undefined });

SessionTokenCache.clear();

pending.resolve(makeToken(raw));
await tick();

expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
});

it('derives the deletion timer from the winner, not from a later staler resolve', async () => {
// high: longer ttl AND fresher oiat; low: short ttl AND staler oiat.
const highRaw = createJwtWithOiat(1666648250, 1666648260, 300);
const lowRaw = createJwtWithOiat(1666648255, 1666648250, 60);

const high = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined });

const low = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: low.promise, onRefresh: undefined });

// Winner resolves first, staler resolves second: the staler resolve must not
// replace the winner's deletion timer with its own short-ttl timer.
high.resolve(makeToken(highRaw));
await tick();
low.resolve(makeToken(lowRaw));
await tick();

// Past low's 60s ttl but well before high's 300s ttl.
vi.advanceTimersByTime(120 * 1000);

const result = SessionTokenCache.get({ tokenId });
expect(result).toBeDefined();
expect(result?.entry.resolvedToken?.getRawString()).toBe(highRaw);
});

it('expires the carried token by its real ttl while the replacement resolver stays pending', async () => {
const highRaw = createJwtWithOiat(1666648250, 1666648250, 120);

const high = deferred();
SessionTokenCache.set({ tokenId, tokenResolver: high.promise, onRefresh: undefined });
high.resolve(makeToken(highRaw));
await tick();

// Cache holds high with its real 120s ttl (iat 1666648250, now 1666648260).
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);

// Replacement resolver never settles; high is carried forward into the pending entry.
const neverSettles = new Promise<TokenResource>(() => {});
SessionTokenCache.set({ tokenId, tokenResolver: neverSettles, onRefresh: undefined });

// The carry still serves high synchronously while the replacement is pending.
expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw);

// Past high's real 120s ttl: get() must evict the carried token, not serve it forever.
vi.advanceTimersByTime(130 * 1000);
expect(SessionTokenCache.get({ tokenId })).toBeUndefined();
});
});

describe('token expiration with absolute time', () => {
it('returns token when expiresAt is far in the future', async () => {
const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
Expand Down
Loading
Loading