-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
9ad4e55
feat(shared): Add oiat field to JwtHeader type
nikosdouvlis 65b4a93
feat(clerk-js): Monotonic token replacement based on oiat
nikosdouvlis 91b664b
chore: Add changesets and plan doc for Session Minter SDK changes
nikosdouvlis c4755d1
chore(clerk-js): merge main into session-minter-sdk-changes
nikosdouvlis ee8afe8
fix(clerk-js): use shouldRejectToken at cookie write path
nikosdouvlis f56f274
test(clerk-js): cover JWT-input path and oiat:0 legacy in tokenFreshness
nikosdouvlis 153e99d
test(clerk-js): fix misleading oiat: 0 test label
nikosdouvlis 6a144bb
refactor(clerk-js): simplify shouldRejectToken; oiat is now universal
nikosdouvlis be30dfd
chore(clerk-js): drop redundant comment in shouldRejectToken
nikosdouvlis d5f66c5
test(clerk-js): update broadcast monotonicity test to use oiat tokens
nikosdouvlis 58ee789
refactor(clerk-js): rename shouldRejectToken -> pickFreshestJwt
nikosdouvlis 00fa5c7
test(clerk-js): document same-object behavior in pickFreshestJwt
nikosdouvlis c22e898
style(clerk-js): format tokenCache.test.ts per prettier
nikosdouvlis d265058
style(clerk-js): fix import sort order in AuthCookieService
nikosdouvlis 78b3328
revert(clerk-js): remove cookie write monotonic guard
nikosdouvlis 7efa1af
fix(clerk-js): pickFreshestJwt returns incoming on full tie
nikosdouvlis 64ab4ae
revert(clerk-js): drop Session.ts monotonic guards
nikosdouvlis 7161778
test(clerk-js): broadcast handler accepts fresher-oiat token over old…
nikosdouvlis 46fbc01
test(clerk-js): await broadcast handler in monotonicity test
nikosdouvlis 67d38b2
docs(clerk-js): update pickFreshestJwt doc to match incoming-on-tie b…
nikosdouvlis daa86bc
Merge branch 'main' into nikos/session-minter-sdk-changes
nikosdouvlis 20d47e6
test(clerk-js): use ttl=120 for monotonicity test tokens
nikosdouvlis e157d4d
Merge branch 'nikos/session-minter-sdk-changes' of github.com:clerk/j…
nikosdouvlis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
111 changes: 111 additions & 0 deletions
111
packages/clerk-js/src/core/__tests__/tokenFreshness.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| 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 incoming when oiat equal and iat equal (other claims may differ)', () => { | ||
| // Two tokens with identical oiat+iat may still differ in other claims | ||
| // (azp, org_id, etc.) during a token-format rollout. Only suppress when | ||
| // existing is strictly fresher; on full ties, let incoming through. | ||
| const existing = makeToken({ oiat: 100, iat: 150 }); | ||
| const incoming = makeToken({ oiat: 100, iat: 150 }); | ||
| expect(pickFreshestJwt(existing, incoming)).toBe(incoming); | ||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| 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 full tie (same oiat AND same iat) it returns | ||
| * `incoming`, since two tokens with identical timestamps can still differ in | ||
| * other claims (azp, org_id, etc.) during a token-format rollout, so the | ||
| * guard should only suppress when existing is strictly fresher. | ||
| * | ||
| * 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. | ||
| * | ||
| * Used by the cross-tab broadcast handler in tokenCache to drop stale | ||
| * edge-minted tokens that would otherwise clobber a fresher cached entry. | ||
| * | ||
| * @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; | ||
| } | ||
|
|
||
| // Equal oiat: tie-break by iat (more recent mint wins). On a full tie, | ||
| // return incoming: two tokens with identical oiat+iat may still differ | ||
| // in other claims (azp, org_id, etc.) added in a token-format rollout, | ||
| // so we only suppress when existing is strictly fresher. | ||
| const existingIat = asJwt(existing)?.claims?.iat ?? 0; | ||
| const incomingIat = asJwt(incoming)?.claims?.iat ?? 0; | ||
| return existingIat > incomingIat ? existing : incoming; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.