feat: bound pending PKCE verifier cookies with eviction#42
Conversation
Each authorization-URL call mints a uniquely-named `wos-auth-verifier-*`
cookie. Per-flow naming exists so concurrent sign-ins (e.g. multiple tabs)
don't clobber each other, but the cost is that cookies never overwrite:
generating URLs without navigating to them — e.g. prefetching
`getSignInUrl()` in a route loader — accumulates orphan verifier cookies
until their 10-minute TTL, eventually bloating the `Cookie` request header
into an HTTP 431.
Add a framework-agnostic eviction primitive, mirroring authkit-nextjs:
- `selectStalePKCEVerifierCookieNames(names, { keep, max })`: pure, I/O-free.
Given the request's cookie names and the verifier just minted, returns the
stale verifier names to delete once the total would exceed `max`
(default 5). All-but-newest, since content-hashed names carry no ordering.
- `isPKCEVerifierCookieName()` and `DEFAULT_MAX_PENDING_PKCE_COOKIES`.
- `AuthService.clearPendingVerifierByName()`: clear a verifier by explicit
name (the hashed name can't be reversed to a sealed `state`).
`clearPendingVerifier` now delegates to it.
Adapters call these after writing a new verifier to GC stale ones through
their existing Set-Cookie channel; this PR is the shared half of the fix.
Refs workos/authkit-tanstack-start#76
Greptile SummaryThis PR introduces a PKCE verifier cookie eviction mechanism to prevent
Confidence Score: 5/5Safe to merge — the change is a pure additive utility with no modifications to existing auth flows beyond the delegation refactor in clearPendingVerifier. The eviction logic is I/O-free and well-isolated; clearPendingVerifier's delegation to clearPendingVerifierByName is semantically equivalent to the previous implementation; the isPKCEVerifierCookieName guard makes clearPendingVerifierByName fail-closed. No regressions to existing cookie handling are introduced. No files require special attention. The only open suggestion is adding a guard on the Important Files Changed
|
- Fix vacuous test: use verifier()-hashed names instead of raw strings in 'never evicts the cookie being kept' so isPKCEVerifierCookieName actually filters them - Add isPKCEVerifierCookieName guard to clearPendingVerifierByName to reject non-PKCE cookie names - Add partial over-cap eviction test (max=3, 3 existing + 1 new) to make the all-or-nothing cliff explicit Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Problem
Each authorization-URL call (
createSignIn/createSignUp/createAuthorization) mints a uniquely-namedwos-auth-verifier-<hash>cookie. The per-flow naming is deliberate — it lets concurrent sign-ins from multiple tabs coexist without clobbering each other (added in 0.5.x). The trade-off: because the name is derived from the sealed state (which embeds a fresh nonce + code verifier per call), cookies never overwrite. Generating URLs without navigating to them — e.g. prefetchinggetSignInUrl()in a routeloaderfor display — leaves an orphan verifier cookie on every call. They accumulate until their 10-minute TTL, eventually bloating theCookierequest header into an HTTP 431.This is the shared half of the fix for the consumer issue workos/authkit-tanstack-start#76.
Approach
Mirror what
authkit-nextjsalready does (src/pkce.ts,MAX_PKCE_COOKIES = 5): tolerate a small number of concurrent verifier cookies, then bound the pile-up by evicting stale ones. Kept as a pure, I/O-free primitive so any adapter can reuse it — the framework owns reading the request and emittingSet-Cookie.New API
selectStalePKCEVerifierCookieNames(cookieNames, { keep, max? })— given the request's cookie names and the verifier just minted (keep), returns the stale verifier names to delete once the total would exceedmax(default5). Policy is all-but-newest: content-hashed names carry no ordering, so "keep the newest N" isn't expressible — this matches the browser's own drop-at-limit behavior.isPKCEVerifierCookieName(name)andDEFAULT_MAX_PENDING_PKCE_COOKIES.AuthService.clearPendingVerifierByName(response, { cookieName, redirectUri? })— clears a verifier by explicit name.clearPendingVerifier(which takes a sealedstate) can't help here because the hashed name can't be reversed to a state; it now delegates to this method.All three symbols are exported from the package index.
Consumer wiring (for context, not in this PR)
The TanStack Start adapter calls these right after writing a new verifier: parse the incoming
Cookieheader,selectStalePKCEVerifierCookieNames(names, { keep: result.cookieName }), thenclearPendingVerifierByNameeach stale name through its existing pending-Set-Cookiechannel — best-effort, so a cleanup failure never breaks URL generation.Tests
src/core/pkce/eviction.spec.ts— 11 cases (within-cap no-op, over-cap evict-all-others, never evictskeep, ignores non-verifier cookies, custommax, prefix-boundary guard).src/service/AuthService.spec.ts—clearPendingVerifierByNameclears by name +clearPendingVerifierdelegates with the same options.format:check,lint,typecheckclean.Note for reviewers
Typed as
feat:because it adds public API surface (→ minor bump under release-please). If you'd rather this land as a patch so existing^0.5.xconsumers pick it up on install without a manual dep bump, retitle tofix:. The eviction cap is currently the hardcodedDEFAULT_MAX_PENDING_PKCE_COOKIES(matching authkit-nextjs); happy to thread it through as an option if you want it configurable.