From 139b298e33de003e28f63ebd9c31a61799742ed2 Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:17:28 +0000 Subject: [PATCH] fix: stop session refresh after sign-out --- src/create-client.test.ts | 162 ++++++++++++++++++++++++++++++++++++++ src/create-client.ts | 17 +++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/create-client.test.ts b/src/create-client.test.ts index f6d2e02..0560474 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -533,6 +533,168 @@ describe("create-client", () => { restoreLocation(); }); + describe("refresh cleanup", () => { + let fetchMock: jest.SpiedFunction; + let onRefresh: jest.Mock; + let onRefreshFailure: jest.Mock; + const refreshTokenKey = `${storageKeys.refreshToken}:client_123abc`; + const refreshResponse = () => + new Response( + JSON.stringify({ + user: { id: "user_123abc" }, + access_token: mockAccessToken(), + refresh_token: "refresh_token", + }), + ); + + beforeEach(async () => { + jest.useFakeTimers(); + Object.defineProperty(navigator, "locks", { + configurable: true, + value: { + request: (_name: string, _options: any, callback: any) => + callback(), + }, + }); + jest.spyOn(console, "debug").mockImplementation(); + fetchMock = jest + .spyOn(global, "fetch") + .mockImplementation(async () => refreshResponse()); + onRefresh = jest.fn(); + onRefreshFailure = jest.fn(); + client = await createClient("client_123abc", { + redirectUri: "https://example.com/", + devMode: true, + onBeforeAutoRefresh: () => true, + onRefresh, + onRefreshFailure, + }); + fetchMock.mockClear(); + onRefresh.mockClear(); + }); + + afterEach(() => { + client?.dispose(); + jest.useRealTimers(); + localStorage.removeItem(refreshTokenKey); + }); + + it("still refreshes automatically before logout", async () => { + jest.setSystemTime(Date.now() + 3600_000); + await jest.advanceTimersByTimeAsync(1000); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(client.getUser()).toMatchObject({ id: "user_123abc" }); + expect(jest.getTimerCount()).toBe(1); + }); + + it.each([true, false])( + "stops the refresh timer with navigate=%s", + async (navigate) => { + if (navigate) { + client.signOut(); + } else { + await client.signOut({ navigate: false }); + } + fetchMock.mockClear(); + + await jest.advanceTimersByTimeAsync(5000); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(client.getUser()).toBeNull(); + expect(localStorage.getItem(refreshTokenKey)).toBeNull(); + expect(onRefresh).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }, + ); + + it("rejects explicit refreshes after logout without making requests", async () => { + client.signOut(); + + await expect(client.getAccessToken()).rejects.toThrow( + LoginRequiredError, + ); + await expect( + client.getAccessToken({ forceRefresh: true }), + ).rejects.toThrow(LoginRequiredError); + await expect( + client.switchToOrganization({ organizationId: "org_123abc" }), + ).rejects.toThrow(LoginRequiredError); + + expect(fetchMock).not.toHaveBeenCalled(); + expect( + sessionStorage.getItem("workos-org-id:client_123abc"), + ).toBeNull(); + }); + + it.each([200, 400, 503])( + "does not restore the session or timer when an in-flight refresh returns %i after logout", + async (status) => { + let completeRefresh!: (response: Response) => void; + fetchMock.mockReturnValueOnce( + new Promise((resolve) => { + completeRefresh = resolve; + }), + ); + jest.setSystemTime(Date.now() + 3600_000); + await jest.advanceTimersByTimeAsync(1000); + expect(fetchMock).toHaveBeenCalledTimes(1); + + client.signOut(); + completeRefresh( + status === 200 + ? refreshResponse() + : new Response( + JSON.stringify({ error_description: "Refresh failed" }), + { status }, + ), + ); + await jest.advanceTimersByTimeAsync(5000); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(client.getUser()).toBeNull(); + expect(localStorage.getItem(refreshTokenKey)).toBeNull(); + expect(onRefresh).not.toHaveBeenCalled(); + expect(onRefreshFailure).not.toHaveBeenCalled(); + expect(console.debug).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + await expect( + client.getAccessToken({ forceRefresh: true }), + ).rejects.toThrow(LoginRequiredError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); + + it.each([false, true])( + "does not resume a refresh waiting for the lock after logout (timeout=%s)", + async (timeout) => { + let releaseLock!: () => void; + jest.spyOn(navigator.locks, "request").mockImplementationOnce( + (_name, _options, callback: any) => + new Promise((resolve, reject) => { + releaseLock = () => + timeout + ? reject(new DOMException("Timed out", "AbortError")) + : resolve(callback()); + }), + ); + const refresh = expect( + client.getAccessToken({ forceRefresh: true }), + ).rejects.toThrow(LoginRequiredError); + + client.signOut(); + releaseLock(); + await refresh; + await jest.advanceTimersByTimeAsync(5000); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(client.getUser()).toBeNull(); + expect(jest.getTimerCount()).toBe(0); + }, + ); + }); + it("redirects to the logout URL", async () => { const { scope } = nockRefresh(); diff --git a/src/create-client.ts b/src/create-client.ts index f520dd8..2935e0f 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -42,6 +42,7 @@ type State = | { tag: "INITIAL" } | { tag: "AUTHENTICATING"; response: Promise } | { tag: "AUTHENTICATED" } + | { tag: "SIGNED_OUT" } | { tag: "ERROR" }; const LEGACY_ORG_ID_KEY = "workos_organization_id"; @@ -200,6 +201,8 @@ export class Client { }); if (url) { + this.#state = { tag: "SIGNED_OUT" }; + clearTimeout(this.#refreshTimer); removeSessionData({ devMode: this.#devMode, clientId: this.#clientId }); sessionStorage.removeItem(orgIdKey(this.#clientId)); sessionStorage.removeItem(LEGACY_ORG_ID_KEY); @@ -362,11 +365,13 @@ An authorization_code was supplied for a login which did not originate at the ap } async #scheduleAutomaticRefresh() { + if (this.#state.tag === "SIGNED_OUT") return; + this.#refreshTimer = setTimeout(() => { if (this.#shouldRefresh() && this.#onBeforeAutoRefresh()) { this.#refreshSession() .catch((e) => { - console.debug(e); + if (this.#state.tag !== "SIGNED_OUT") console.debug(e); }) .finally(() => this.#scheduleAutomaticRefresh()); } else { @@ -409,6 +414,7 @@ An authorization_code was supplied for a login which did not originate at the ap } async #refreshSession({ organizationId }: { organizationId?: string } = {}) { + this.#assertNotSignedOut(); if (this.#state.tag === "AUTHENTICATING") { await this.#state.response; return; @@ -433,6 +439,7 @@ An authorization_code was supplied for a login which did not originate at the ap }): Promise { try { return await withLock(REFRESH_LOCK_NAME, async () => { + this.#assertNotSignedOut(); if (organizationId) { sessionStorage.setItem(orgIdKey(this.#clientId), organizationId); } else { @@ -458,6 +465,8 @@ An authorization_code was supplied for a login which did not originate at the ap useCookie: this.#useCookie, }); + // Logout may have happened while the refresh request was in flight. + this.#assertNotSignedOut(); this.#state = { tag: "AUTHENTICATED" }; setSessionData(authenticationResponse, { devMode: this.#devMode, @@ -467,6 +476,7 @@ An authorization_code was supplied for a login which did not originate at the ap return authenticationResponse; }); } catch (error) { + this.#assertNotSignedOut(); if ( error instanceof LockError && error.name === "AcquisitionTimeoutError" @@ -508,11 +518,16 @@ An authorization_code was supplied for a login which did not originate at the ap } } + #assertNotSignedOut() { + if (this.#state.tag === "SIGNED_OUT") throw new LoginRequiredError(); + } + #shouldRefresh() { switch (this.#state.tag) { case "INITIAL": case "AUTHENTICATING": return true; + case "SIGNED_OUT": case "ERROR": return false; case "AUTHENTICATED":