-
Notifications
You must be signed in to change notification settings - Fork 450
feat(clerk-js): Monotonic token replacement based on oiat #8097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
9ad4e55
65b4a93
91b664b
c4755d1
ee8afe8
f56f274
153e99d
6a144bb
be30dfd
d5f66c5
58ee789
00fa5c7
c22e898
d265058
78b3328
7efa1af
64ab4ae
7161778
46fbc01
67d38b2
daa86bc
20d47e6
e157d4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@clerk/clerk-js': patch | ||
| --- | ||
|
|
||
| Add monotonic token replacement based on `oiat` to prevent edge-minted tokens with stale claims from overwriting fresher DB-minted tokens in multi-tab scenarios. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import type { JWT, TokenResource } from '@clerk/shared/types'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { pickFreshestJwt } from '../tokenFreshness'; | ||
|
|
||
| function makeToken(opts: { oiat?: number; iat?: number } = {}): TokenResource { | ||
| return { | ||
| jwt: { | ||
| header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, | ||
| claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, | ||
| }, | ||
| getRawString: () => 'mock-jwt', | ||
| } as unknown as TokenResource; | ||
| } | ||
|
|
||
| function makeJwt(opts: { oiat?: number; iat?: number } = {}): JWT { | ||
| return { | ||
| header: { alg: 'RS256', kid: 'kid_1', ...(opts.oiat != null ? { oiat: opts.oiat } : {}) }, | ||
| claims: { ...(opts.iat != null ? { iat: opts.iat } : {}) }, | ||
| } as unknown as JWT; | ||
| } | ||
|
|
||
| describe('pickFreshestJwt', () => { | ||
| describe('both have oiat (the only reachable path post-rollout)', () => { | ||
| it('picks existing when existing oiat > incoming oiat', () => { | ||
| const existing = makeToken({ oiat: 100 }); | ||
| const incoming = makeToken({ oiat: 90 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(existing); | ||
| }); | ||
|
|
||
| it('picks incoming when existing oiat < incoming oiat', () => { | ||
| const existing = makeToken({ oiat: 90 }); | ||
| const incoming = makeToken({ oiat: 100 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(incoming); | ||
| }); | ||
|
|
||
| it('picks existing when oiat equal and existing iat > incoming iat', () => { | ||
| const existing = makeToken({ oiat: 100, iat: 200 }); | ||
| const incoming = makeToken({ oiat: 100, iat: 150 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(existing); | ||
| }); | ||
|
|
||
| it('picks incoming when oiat equal and existing iat < incoming iat', () => { | ||
| const existing = makeToken({ oiat: 100, iat: 150 }); | ||
| const incoming = makeToken({ oiat: 100, iat: 200 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(incoming); | ||
| }); | ||
|
|
||
| it('picks existing when oiat equal and iat equal (identical, no churn)', () => { | ||
| const existing = makeToken({ oiat: 100, iat: 150 }); | ||
| const incoming = makeToken({ oiat: 100, iat: 150 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(existing); | ||
| }); | ||
|
|
||
| it('picks existing when oiat equal and incoming iat missing (treated as 0)', () => { | ||
| const existing = makeToken({ oiat: 100, iat: 150 }); | ||
| const incoming = makeToken({ oiat: 100 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(existing); | ||
| }); | ||
| }); | ||
|
|
||
| describe('legacy (missing oiat) safety net', () => { | ||
| it('picks existing when incoming is legacy (no oiat) and existing has oiat', () => { | ||
| const existing = makeToken({ oiat: 100 }); | ||
| const incoming = makeToken({ iat: 9999 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(existing); | ||
| }); | ||
|
|
||
| it('picks incoming when existing is legacy and incoming has oiat', () => { | ||
| const existing = makeToken({ iat: 9999 }); | ||
| const incoming = makeToken({ oiat: 100 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(incoming); | ||
| }); | ||
|
|
||
| it('picks incoming when both sides are legacy (cannot rank, safe default)', () => { | ||
| const existing = makeToken({ iat: 200 }); | ||
| const incoming = makeToken({ iat: 100 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(incoming); | ||
| }); | ||
| }); | ||
|
|
||
| describe('same object reference', () => { | ||
| // When the cache hands back the same object that is already stored as | ||
| // lastActiveToken, callers use `pickFreshestJwt(a, b) === a` to detect | ||
| // "existing won, suppress redundant emit". This test documents that | ||
| // intentional behavior. | ||
| it('returns the same reference when both args are the same object', () => { | ||
| const token = makeToken({ oiat: 100, iat: 150 }); | ||
| expect(pickFreshestJwt(token, token)).toBe(token); | ||
| }); | ||
| }); | ||
|
|
||
| describe('JWT input (cookie path)', () => { | ||
| it('accepts raw decoded JWT for both arguments', () => { | ||
| const a = makeJwt({ oiat: 100 }); | ||
| const b = makeJwt({ oiat: 200 }); | ||
| expect(pickFreshestJwt(a, b)).toBe(b); | ||
| expect(pickFreshestJwt(b, a)).toBe(b); | ||
| }); | ||
|
|
||
| it('tie-breaks by iat on equal oiat for raw JWT inputs', () => { | ||
| const a = makeJwt({ oiat: 100, iat: 150 }); | ||
| const b = makeJwt({ oiat: 100, iat: 200 }); | ||
| expect(pickFreshestJwt(a, b)).toBe(b); | ||
| expect(pickFreshestJwt(b, a)).toBe(b); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,7 @@ import type { | |
| } from '@clerk/shared/types'; | ||
| import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; | ||
|
|
||
| import { pickFreshestJwt } from '@/core/tokenFreshness'; | ||
| import { unixEpochToDate } from '@/utils/date'; | ||
| import { debugLogger } from '@/utils/debug'; | ||
| import { TokenId } from '@/utils/tokenId'; | ||
|
|
@@ -458,7 +459,11 @@ export class Session extends BaseResource implements SessionResource { | |
| // Only emit token updates when we have an actual token — emitting with an empty | ||
| // token causes AuthCookieService to remove the __session cookie (looks like sign-out). | ||
| if (shouldDispatchTokenUpdate && cachedToken.getRawString()) { | ||
| eventBus.emit(events.TokenUpdate, { token: cachedToken }); | ||
| const isStaler = | ||
| this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, cachedToken) === this.lastActiveToken; | ||
| if (!isStaler) { | ||
| eventBus.emit(events.TokenUpdate, { token: cachedToken }); | ||
| } | ||
| } | ||
| result = cachedToken.getRawString() || null; | ||
| } else if (!isBrowserOnline()) { | ||
|
|
@@ -518,6 +523,10 @@ export class Session extends BaseResource implements SessionResource { | |
| return; | ||
| } | ||
|
|
||
| if (this.lastActiveToken && pickFreshestJwt(this.lastActiveToken, token) === this.lastActiveToken) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The suppression test at Session.test.ts:103 only proves the guard can suppress, not that it does so for the right reason. And every test that asserts We should probably add a test where |
||
| return; | ||
| } | ||
|
|
||
| eventBus.emit(events.TokenUpdate, { token }); | ||
|
|
||
| if (token.jwt) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import type { JWT, TokenResource } from '@clerk/shared/types'; | ||
|
|
||
| function asJwt(input: TokenResource | JWT): JWT | undefined { | ||
| return 'getRawString' in input ? input.jwt : input; | ||
| } | ||
|
|
||
| /** | ||
| * Picks the freshest of two tokens. Returns whichever argument has the more | ||
| * recent claim freshness; on a tie, returns `existing` (no churn). | ||
| * | ||
| * All origin-minted tokens carry the `oiat` JWT header (origin-issued-at; | ||
| * timestamp when claims were last assembled from the DB). A token without | ||
| * `oiat` is from a pre-feature codebase and is by definition staler than any | ||
| * token that has one. | ||
| * | ||
| * Coverage: invoked at /tokens responses, broadcast events, and cookie writes. | ||
| * Handshake-installed __session cookies are intentionally NOT gated: | ||
| * handshake is a redirect-based full auth state resync, the browser commits | ||
| * the Set-Cookie before any SDK code runs, and there is no in-flight race | ||
| * window for the gate to protect. | ||
| * | ||
| * @internal | ||
| */ | ||
| export function pickFreshestJwt<T extends TokenResource | JWT>(existing: T, incoming: T): T { | ||
| const existingOiat = asJwt(existing)?.header?.oiat; | ||
| const incomingOiat = asJwt(incoming)?.header?.oiat; | ||
|
|
||
| if (existingOiat == null && incomingOiat == null) { | ||
| return incoming; | ||
| } | ||
| if (incomingOiat == null) { | ||
| return existing; | ||
| } | ||
| if (existingOiat == null) { | ||
| return incoming; | ||
| } | ||
|
|
||
| if (existingOiat > incomingOiat) { | ||
| return existing; | ||
| } | ||
| if (existingOiat < incomingOiat) { | ||
| return incoming; | ||
| } | ||
|
|
||
| const existingIat = asJwt(existing)?.claims?.iat ?? 0; | ||
| const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; | ||
| return existingIat >= incomingIat ? existing : incoming; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.