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;