-
Notifications
You must be signed in to change notification settings - Fork 1
feat: bound pending PKCE verifier cookies with eviction #42
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
3 commits
Select commit
Hold shift + click to select a range
f75a9a4
feat: bound pending PKCE verifier cookies with eviction
nicknisi b31acfd
Fix review bugs: verifier test, PKCE cookie guard, over-cap test
devin-ai-integration[bot] 41425c1
Refactor clearPendingVerifierByName tests to use beforeEach
devin-ai-integration[bot] 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,91 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { | ||
| DEFAULT_MAX_PENDING_PKCE_COOKIES, | ||
| isPKCEVerifierCookieName, | ||
| selectStalePKCEVerifierCookieNames, | ||
| } from './eviction.js'; | ||
| import { PKCE_COOKIE_PREFIX, getPKCECookieNameForState } from './cookieName.js'; | ||
|
|
||
| const verifier = (state: string) => getPKCECookieNameForState(state); | ||
|
|
||
| describe('isPKCEVerifierCookieName', () => { | ||
| it('matches the per-flow hashed name', () => { | ||
| expect(isPKCEVerifierCookieName(verifier('abc'))).toBe(true); | ||
| }); | ||
|
|
||
| it('matches the legacy unsuffixed name', () => { | ||
| expect(isPKCEVerifierCookieName(PKCE_COOKIE_PREFIX)).toBe(true); | ||
| }); | ||
|
|
||
| it('rejects unrelated cookies', () => { | ||
| expect(isPKCEVerifierCookieName('wos-session')).toBe(false); | ||
| expect(isPKCEVerifierCookieName('foo')).toBe(false); | ||
| }); | ||
|
|
||
| it('rejects names that share the prefix without the hyphen boundary', () => { | ||
| // Guards against `wos-auth-verifierEVIL` being treated as a verifier. | ||
| expect(isPKCEVerifierCookieName(`${PKCE_COOKIE_PREFIX}EVIL`)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('selectStalePKCEVerifierCookieNames', () => { | ||
| const keep = verifier('new-flow'); | ||
|
|
||
| it('evicts nothing when the new cookie is the only verifier', () => { | ||
| expect(selectStalePKCEVerifierCookieNames([], { keep })).toEqual([]); | ||
| }); | ||
|
|
||
| it('evicts nothing while total verifier count stays within the cap', () => { | ||
| // 4 existing + the new one = 5 = DEFAULT cap. | ||
| const existing = ['a', 'b', 'c', 'd'].map(verifier); | ||
| expect(selectStalePKCEVerifierCookieNames(existing, { keep })).toEqual([]); | ||
| }); | ||
|
|
||
| it('evicts every other verifier once the new cookie tips it over the cap', () => { | ||
| // 5 existing + the new one = 6 > 5. | ||
| const existing = ['a', 'b', 'c', 'd', 'e'].map(verifier); | ||
| const stale = selectStalePKCEVerifierCookieNames(existing, { keep }); | ||
| expect(new Set(stale)).toEqual(new Set(existing)); | ||
| expect(stale).not.toContain(keep); | ||
| }); | ||
|
|
||
| it('never evicts the cookie being kept, even if it is already on the request', () => { | ||
| const existing = [...['a', 'b', 'c', 'd'].map(verifier), keep]; | ||
| // existing verifiers other than keep = 4; +1 new (keep, already counted) → within cap. | ||
| expect(selectStalePKCEVerifierCookieNames(existing, { keep })).toEqual([]); | ||
| }); | ||
|
|
||
| it('ignores non-verifier cookies when counting', () => { | ||
| const existing = [ | ||
| 'wos-session', | ||
| 'theme', | ||
| ...['a', 'b', 'c', 'd'].map(verifier), | ||
| ]; | ||
| expect(selectStalePKCEVerifierCookieNames(existing, { keep })).toEqual([]); | ||
| }); | ||
|
|
||
| it('honors a custom max', () => { | ||
| const existing = ['a', 'b'].map(verifier); | ||
| // 2 existing + 1 new = 3 > max:2 → evict both existing. | ||
| const stale = selectStalePKCEVerifierCookieNames(existing, { | ||
| keep, | ||
| max: 2, | ||
| }); | ||
| expect(new Set(stale)).toEqual(new Set(existing)); | ||
| }); | ||
|
|
||
| it('evicts all others even when only slightly over the cap (partial over-cap)', () => { | ||
| // max=3, 3 existing + 1 new = 4 > 3 → evicts all 3 existing (not just the surplus). | ||
| const existing = ['a', 'b', 'c'].map(verifier); | ||
| const stale = selectStalePKCEVerifierCookieNames(existing, { | ||
| keep, | ||
| max: 3, | ||
| }); | ||
| expect(new Set(stale)).toEqual(new Set(existing)); | ||
| expect(stale).not.toContain(keep); | ||
| }); | ||
|
|
||
| it('exposes a sane default cap', () => { | ||
| expect(DEFAULT_MAX_PENDING_PKCE_COOKIES).toBe(5); | ||
| }); | ||
| }); | ||
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,77 @@ | ||
| import { PKCE_COOKIE_PREFIX } from './cookieName.js'; | ||
|
|
||
| /** | ||
| * Default ceiling on simultaneous pending PKCE verifier cookies before | ||
| * eviction kicks in. Mirrors `authkit-nextjs`'s `MAX_PKCE_COOKIES`. | ||
| * | ||
| * A handful of concurrent flows is normal — e.g. several tabs each | ||
| * mid-sign-in, each holding its own verifier. Eviction only triggers | ||
| * once accumulation risks an oversized `Cookie` request header | ||
| * (HTTP 431), which happens when an integration generates authorization | ||
| * URLs it never navigates to (prefetching `getSignInUrl()` in a loader). | ||
| */ | ||
| export const DEFAULT_MAX_PENDING_PKCE_COOKIES = 5; | ||
|
|
||
| /** | ||
| * True when `name` is a PKCE verifier cookie — either the per-flow hashed | ||
| * name (`wos-auth-verifier-<hash>`) or the legacy unsuffixed name. | ||
| * | ||
| * The hyphen boundary is required so a lookalike like | ||
| * `wos-auth-verifierXYZ` is NOT treated as a verifier. | ||
| */ | ||
| export function isPKCEVerifierCookieName(name: string): boolean { | ||
| return ( | ||
| name === PKCE_COOKIE_PREFIX || name.startsWith(`${PKCE_COOKIE_PREFIX}-`) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Decide which stale PKCE verifier cookies to evict on the request that | ||
| * mints `keep`. | ||
| * | ||
| * The per-flow cookie naming (each `getSignInUrl()`/`getSignUpUrl()` call | ||
| * derives a unique name from its sealed state) means cookies never | ||
| * overwrite — they accumulate until their 10-minute TTL. Generating URLs | ||
| * without navigating to them (e.g. prefetching in a loader) can pile up | ||
| * enough verifier cookies to exceed the server's request-header limit and | ||
| * surface as HTTP 431. | ||
| * | ||
| * Policy (matches `authkit-nextjs`): tolerate up to `max` simultaneous | ||
| * verifier cookies. Once the cookie being created this request would push | ||
| * the total past `max`, evict *every* other verifier — keeping only the | ||
| * one just minted. Because cookie names are content hashes with no | ||
| * embedded ordering, "keep the newest N" is not expressible; all-but-newest | ||
| * is the only well-defined bounded policy, and it mirrors the browser's own | ||
| * drop-oldest behavior at the cookie-jar limit. | ||
| * | ||
| * Pure and I/O-free: callers supply the incoming cookie names and emit the | ||
| * delete `Set-Cookie` headers for the returned names themselves. | ||
| * | ||
| * @param cookieNames - Names of all cookies on the incoming request. | ||
| * @param keep - Name of the verifier cookie being written this request; | ||
| * never included in the result. | ||
| * @param max - Maximum simultaneous verifier cookies tolerated before | ||
| * eviction. Defaults to {@link DEFAULT_MAX_PENDING_PKCE_COOKIES}. | ||
| * @returns Verifier cookie names to delete. Empty when within budget. | ||
| */ | ||
| export function selectStalePKCEVerifierCookieNames( | ||
| cookieNames: Iterable<string>, | ||
| { | ||
| keep, | ||
| max = DEFAULT_MAX_PENDING_PKCE_COOKIES, | ||
| }: { keep: string; max?: number }, | ||
| ): string[] { | ||
| const others = new Set<string>(); | ||
| for (const name of cookieNames) { | ||
| if (name !== keep && isPKCEVerifierCookieName(name)) { | ||
| others.add(name); | ||
| } | ||
| } | ||
|
|
||
| // +1 accounts for `keep`, which may not be on the incoming request yet. | ||
| if (others.size + 1 <= max) { | ||
| return []; | ||
| } | ||
|
|
||
| return [...others]; | ||
| } |
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
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
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.