Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/core/pkce/eviction.spec.ts
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);
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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);
});
});
77 changes: 77 additions & 0 deletions src/core/pkce/eviction.ts
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];
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export {
PKCE_COOKIE_PREFIX,
getPKCECookieNameForState,
} from './core/pkce/cookieName.js';
export {
DEFAULT_MAX_PENDING_PKCE_COOKIES,
isPKCEVerifierCookieName,
selectStalePKCEVerifierCookieNames,
} from './core/pkce/eviction.js';

// ============================================
// Encryption Fallback
Expand Down
47 changes: 47 additions & 0 deletions src/service/AuthService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,53 @@ describe('AuthService', () => {
});
});

describe('clearPendingVerifierByName()', () => {
let realStorage: ReturnType<typeof makeStorage>;
let realService: InstanceType<typeof AuthService>;

beforeEach(() => {
realStorage = makeStorage();
realService = new AuthService(
mockConfig as any,
realStorage as any,
makeClient() as any,
sessionEncryption,
);
});

it('clears the named cookie without needing the sealed state', async () => {
const staleName = 'wos-auth-verifier-deadbeef';
const result = await realService.clearPendingVerifierByName(undefined, {
cookieName: staleName,
});

expect(realStorage.lastClearOptions.get(staleName)?.path).toBe('/');
expect(result.headers?.['Set-Cookie']).toContain(`${staleName}=`);
});

it('rejects non-PKCE cookie names', async () => {
await expect(
realService.clearPendingVerifierByName(undefined, {
cookieName: 'wos-session',
}),
).rejects.toThrow('Refusing to clear non-PKCE cookie "wos-session"');
});

it('is what clearPendingVerifier delegates to (same options)', async () => {
const { cookieName } = await realService.createSignIn(undefined);
const sealedState = realStorage.cookies.get(cookieName)!;

const clearCookieSpy = vi.spyOn(realStorage, 'clearCookie');
await realService.clearPendingVerifier(undefined, { state: sealedState });

expect(clearCookieSpy).toHaveBeenCalledWith(
undefined,
cookieName,
expect.any(Object),
);
});
});

describe('getWorkOS()', () => {
it('returns WorkOS client', () => {
const result = service.getWorkOS();
Expand Down
35 changes: 33 additions & 2 deletions src/service/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AuthKitCore } from '../core/AuthKitCore.js';
import type { AuthKitConfig } from '../core/config/types.js';
import { getPKCECookieNameForState } from '../core/pkce/cookieName.js';
import { getPKCECookieOptions } from '../core/pkce/cookieOptions.js';
import { isPKCEVerifierCookieName } from '../core/pkce/eviction.js';
import { AuthOperations } from '../operations/AuthOperations.js';
import type {
AuthResult,
Expand Down Expand Up @@ -283,10 +284,40 @@ export class AuthService<TRequest, TResponse> {
response: TResponse | undefined,
options: { state: string; redirectUri?: string },
): Promise<{ response?: TResponse; headers?: HeadersBag }> {
const cookieName = getPKCECookieNameForState(options.state);
return this.clearPendingVerifierByName(response, {
cookieName: getPKCECookieNameForState(options.state),
redirectUri: options.redirectUri,
});
}

/**
* Emit a `Set-Cookie` header that clears a PKCE verifier cookie by its
* explicit name.
*
* Unlike {@link clearPendingVerifier}, this does not derive the name from
* a sealed `state` — it takes the cookie name directly. Use it to evict
* stale verifier cookies discovered by enumerating the request's cookies
* (e.g. via {@link selectStalePKCEVerifierCookieNames}), where the
* originating `state` is unknown and unrecoverable from the hashed name.
*
* The delete is computed with the same (name, path, domain) tuple the
* browser uses to match a cookie for replacement, so it clears the
* target regardless of the `redirectUri` the flow was created with. Pass
* `options.redirectUri` only when you need the `secure` attribute to match
* a per-request override.
*/
async clearPendingVerifierByName(
response: TResponse | undefined,
options: { cookieName: string; redirectUri?: string },
): Promise<{ response?: TResponse; headers?: HeadersBag }> {
if (!isPKCEVerifierCookieName(options.cookieName)) {
throw new Error(
`Refusing to clear non-PKCE cookie "${options.cookieName}"`,
);
}
return this.storage.clearCookie(
response,
cookieName,
options.cookieName,
getPKCECookieOptions(this.config, options.redirectUri),
);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down
Loading