From bd212a12b464c4703c0200deae77baf6ff8a63a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:24:58 +0000 Subject: [PATCH] Remove client-readable access-token cookie channel The client TokenStore read a plain, JavaScript-readable workos-access-token cookie and adopted its value as the session bearer access token, with no binding to the authenticated session. No server component in this SDK (or in @workos/authkit-session) ever writes this cookie, so it was a dead input channel: an attacker able to write a cookie on the app's registrable domain could plant their own valid token and cause the victim's client-side API calls to authenticate as the attacker. A Path mismatch in the consume-and-delete logic also let a Path=/ plant persist across page loads. Remove the cookie read/consume/delete paths entirely. Tokens are now sourced only from the session-bound server RPCs, which are the only legitimate source. Add a regression test asserting a planted cookie is ignored. --- src/client/tokenStore.spec.ts | 46 ++++++++++++++- src/client/tokenStore.ts | 107 +--------------------------------- 2 files changed, 46 insertions(+), 107 deletions(-) diff --git a/src/client/tokenStore.spec.ts b/src/client/tokenStore.spec.ts index d75d516..2533c27 100644 --- a/src/client/tokenStore.spec.ts +++ b/src/client/tokenStore.spec.ts @@ -350,10 +350,13 @@ describe('TokenStore', () => { }; const refreshedToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(refreshedPayload))}.mock-signature`; - document.cookie = `workos-access-token=${encodeURIComponent(initialToken)}`; + // Seed the initial token through the session-bound server RPC (the only + // legitimate token source) rather than a client-readable cookie. + vi.mocked(getAccessTokenAction).mockResolvedValue(initialToken); vi.mocked(refreshAccessTokenAction).mockResolvedValue(refreshedToken); const localStore = new TokenStore(); + await localStore.getAccessTokenSilently(); expect(refreshAccessTokenAction).not.toHaveBeenCalled(); // Advance past the originally scheduled fire time. If the schedule buffer @@ -366,4 +369,45 @@ describe('TokenStore', () => { localStore.reset(); }); }); + + describe('does not trust client-readable cookies (SEC-1348)', () => { + afterEach(() => { + document.cookie + .split(';') + .map((c) => c.split('=')[0].trim()) + .filter(Boolean) + .forEach((name) => { + document.cookie = `${name}=; Path=/; Max-Age=0`; + document.cookie = `${name}=; Max-Age=0`; + }); + }); + + it('ignores a planted workos-access-token cookie and uses the session-bound RPC instead', async () => { + const now = Math.floor(Date.now() / 1000); + const makeToken = (sub: string) => + `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa( + JSON.stringify({ sub, sid: `session_${sub}`, iat: now, exp: now + 3600 }), + )}.mock-signature`; + + const victimToken = makeToken('victim'); + const attackerToken = makeToken('attacker'); + + // Attacker plants their own token via a cookie-write primitive. + document.cookie = `workos-access-token=${encodeURIComponent(attackerToken)}; Path=/`; + + // The legitimate, session-bound token source returns the victim's token. + vi.mocked(getAccessTokenAction).mockResolvedValue(victimToken); + vi.mocked(refreshAccessTokenAction).mockResolvedValue(victimToken); + + const localStore = new TokenStore(); + // Construction must not adopt the cookie. + expect(localStore.getSnapshot().token).toBeUndefined(); + + const served = await localStore.getAccessToken(); + expect(served).toBe(victimToken); + expect(served).not.toBe(attackerToken); + + localStore.reset(); + }); + }); }); diff --git a/src/client/tokenStore.ts b/src/client/tokenStore.ts index d2a02f1..8298915 100644 --- a/src/client/tokenStore.ts +++ b/src/client/tokenStore.ts @@ -13,7 +13,6 @@ const SHORT_TOKEN_EXPIRY_BUFFER_SECONDS = 30; const MIN_REFRESH_DELAY_SECONDS = 15; const MAX_REFRESH_DELAY_SECONDS = 24 * 60 * 60; const RETRY_DELAY_SECONDS = 300; -const jwtCookieName = 'workos-access-token'; function getExpiryBuffer(totalTokenLifetime: number): number { return totalTokenLifetime <= SHORT_TOKEN_LIFETIME_SECONDS @@ -26,9 +25,8 @@ export class TokenStore { private serverSnapshot: TokenState; constructor() { - const initialToken = typeof window !== 'undefined' ? this.getInitialTokenFromCookie() : undefined; this.state = { - token: initialToken, + token: undefined, loading: false, error: null, }; @@ -38,20 +36,11 @@ export class TokenStore { loading: false, error: null, }; - - if (initialToken) { - this.fastCookieConsumed = true; - const tokenData = this.parseToken(initialToken); - if (tokenData) { - this.scheduleRefresh(tokenData); - } - } } private listeners = new Set<() => void>(); private refreshPromise: Promise | null = null; private refreshTimeout: ReturnType | undefined; - private fastCookieConsumed = false; subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -108,79 +97,6 @@ export class TokenStore { return Math.min(Math.max(idealDelay, MIN_REFRESH_DELAY_SECONDS * 1000), MAX_REFRESH_DELAY_SECONDS * 1000); } - private deleteCookie() { - const isSecure = window.location.protocol === 'https:'; - - const deletionString = isSecure - ? `${jwtCookieName}=; SameSite=Lax; Max-Age=0; Secure` - : `${jwtCookieName}=; SameSite=Lax; Max-Age=0`; - - document.cookie = deletionString; - } - - private getInitialTokenFromCookie(): string | undefined { - if (typeof document === 'undefined' || typeof document.cookie === 'undefined') { - return; - } - - const cookies = document.cookie.split(';').reduce( - (acc, cookie) => { - const [name, ...valueParts] = cookie.trim().split('='); - if (name && valueParts.length > 0) { - const value = valueParts.join('='); - acc[name.trim()] = decodeURIComponent(value); - } - return acc; - }, - {} as Record, - ); - - const token = cookies[jwtCookieName]; - if (!token) { - return; - } - - this.deleteCookie(); - - return token; - } - - private consumeFastCookie(): string | undefined { - if (this.fastCookieConsumed) { - return; - } - - if (typeof document === 'undefined' || typeof document.cookie === 'undefined') { - return; - } - - const cookies = document.cookie.split(';').reduce( - (acc, cookie) => { - const [name, ...valueParts] = cookie.trim().split('='); - if (name && valueParts.length > 0) { - const value = valueParts.join('='); - acc[name.trim()] = decodeURIComponent(value); - } - return acc; - }, - {} as Record, - ); - - const newToken = cookies[jwtCookieName]; - if (!newToken) { - this.fastCookieConsumed = true; - return; - } - - this.fastCookieConsumed = true; - - this.deleteCookie(); - - if (newToken !== this.state.token) { - return newToken; - } - } - parseToken(token: string | undefined) { if (!token) return null; @@ -223,13 +139,6 @@ export class TokenStore { } async getAccessToken(): Promise { - const fastToken = this.consumeFastCookie(); - - if (fastToken) { - this.setState({ token: fastToken, loading: false, error: null }); - return fastToken; - } - const tokenData = this.parseToken(this.state.token); if (tokenData && !tokenData.isExpiring) { @@ -244,19 +153,6 @@ export class TokenStore { } async getAccessTokenSilently(): Promise { - const fastToken = this.consumeFastCookie(); - - if (fastToken) { - this.setState({ token: fastToken, loading: false, error: null }); - - const tokenData = this.parseToken(fastToken); - if (tokenData) { - this.scheduleRefresh(tokenData); - } - - return fastToken; - } - const tokenData = this.parseToken(this.state.token); if (tokenData && !tokenData.isExpiring) { @@ -355,7 +251,6 @@ export class TokenStore { reset() { this.state = { token: undefined, loading: false, error: null }; this.refreshPromise = null; - this.fastCookieConsumed = false; if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined;