fix: evict stale PKCE verifier cookies to prevent HTTP 431 (#76)#104
Conversation
Calling getSignInUrl()/getSignUpUrl()/getAuthorizationUrl() to display a link — e.g. prefetching in a route loader — mints a uniquely-named wos-auth-verifier-* cookie on every call that is never navigated to. The per-flow naming means cookies never overwrite, so orphans accumulate until their 10-minute TTL and eventually bloat the Cookie header into an HTTP 431. Wire up the eviction primitives added in @workos/authkit-session@0.6.0: after minting a new verifier, parse the incoming Cookie header, compute the stale verifier names via selectStalePKCEVerifierCookieNames (keeping the one just created), and clear each through middleware's pending-Set-Cookie channel via clearPendingVerifierByName. Best-effort — a cleanup failure never breaks URL generation. Also document the side effect on the public helpers: each call starts an OAuth flow and sets a cookie, so they should be invoked on user action or from a redirect route, not prefetched in a loader for display. Bumps @workos/authkit-session to ^0.6.0 (required for the new exports). Closes #76
Greptile SummaryThis PR fixes HTTP 431 errors caused by unbounded accumulation of
Confidence Score: 5/5Safe to merge — the eviction path is purely additive and best-effort, so it cannot block URL generation or affect auth flow correctness. The fix is well-scoped, touching only the authorization URL pipeline and cookie name utility. The eviction is best-effort so failures leave orphaned cookies to expire naturally without breaking sign-in. The freshly minted cookie is always protected via the keep argument, and five dedicated test cases validate all boundary conditions. No files require special attention. Important Files Changed
|
Per PR review: the stale-verifier deletes are independent, so fire them together via Promise.allSettled rather than awaiting sequentially. allSettled isolates per-cookie failures, preserving the best-effort semantics (a failed clear just leaves an orphan to expire on its TTL) without the per-iteration try/catch.
Pass the already-resolved middleware context from forwardAuthorizationCookies into evictStalePendingVerifiers instead of re-deriving it. This collapses three getGlobalStartContext() reads per authorization request to one (the function re-fetched the context and separately called getRedirectUriFromContext, both of which resolve the same object the caller already holds), uses ctx.redirectUri directly, and drops a now-dead null guard. Fully explicit data flow; behavior unchanged. Also strengthen the "freshly minted cookie is never evicted" test to actually exercise the over-cap boundary with the kept cookie present on the request, rather than sitting under the cap where no eviction runs at all.
Two cleanups from a quality pass over the eviction change: - Collapse the three near-identical URL bodies (getAuthorizationUrlBody / getSignInUrlBody / getSignUpUrlBody) into a single `authorizationUrl(create)` pipeline parameterized by the create callback. This folds away the two-arg pass-through `forwardAuthorizationCookies(authkit, result)` (authkit was only threaded through to eviction), de-duplicates the resolve→create→forward→evict sequence and the string→returnPathname normalization (now `toAuthOptions`), and keeps the create calls explicit/greppable (no string dispatch). - Add `parseCookieNames` to cookie-utils and use it on the eviction path instead of `Object.keys(parseCookies(header))`. The verifier-eviction path exists for pathologically large Cookie headers; parsing names-only avoids allocating every (large, encrypted) cookie value just to discard it. Behavior unchanged; 232 tests pass, bundle-leak check clean.
Closes #76.
Problem
getSignInUrl()/getSignUpUrl()/getAuthorizationUrl()read like pure getters, but each call starts an OAuth flow and sets awos-auth-verifier-<hash>cookie. The per-flow hashed name (so concurrent sign-ins across tabs don't clobber each other) means cookies never overwrite. Using these helpers to display a link — e.g. prefetching in a routeloader, or calling unconditionally inbeforeLoadeven when already signed in — mints a fresh orphan cookie on every navigation. They pile up until the 10-minute PKCE TTL and eventually bloat theCookierequest header into an HTTP 431.This is the consumer half of the fix; the shared primitive shipped in
@workos/authkit-session@0.6.0(workos/authkit-session#42).Changes
@workos/authkit-session→^0.6.0(required for the new exports).server-fn-bodies.ts. After minting a verifier, parse the incomingCookieheader, compute stale verifier names viaselectStalePKCEVerifierCookieNames(names, { keep: result.cookieName })(cap 5, all-but-newest), and clear each viaAuthService.clearPendingVerifierByNamethrough middleware's existing pending-Set-Cookiechannel. Best-effort — wrapped so a cleanup failure can never break URL generation. Applies to all three helpers.server-functions.ts: they have a cookie side effect and should be called on user action / from a redirect route, not prefetched in a loader for display.The lazy body/shell boundary is preserved: the new
@workos/authkit-sessionvalue import lives inserver-fn-bodies.ts(a body file, already allowed to import server-only modules), reached only via the RPC-rewritten shell's dynamicimport().Why eviction (not just docs or "don't set a cookie")
codeVerifierbe persisted before the callback. The only cookie-free variant is the redirect-route pattern, which the example already uses.authkit-nextjs(MAX_PKCE_COOKIES = 5).Tests
server-functions.spec.ts— 5 new cases: no eviction within cap; evict all-but-newest over cap; never evicts the freshly minted cookie (even if already on the request); applies togetSignUpUrl/getAuthorizationUrl; URL still generated when eviction throws.typecheck,lint, SDK build, example build, andbuild:check(no server-only fingerprints in the client bundle) all clean against the published 0.6.0.The example app already uses the recommended redirect-route pattern (
/api/auth/sign-in), so no example change was needed.