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
162 changes: 162 additions & 0 deletions src/create-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,168 @@ describe("create-client", () => {
restoreLocation();
});

describe("refresh cleanup", () => {
let fetchMock: jest.SpiedFunction<typeof fetch>;
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();

Expand Down
17 changes: 16 additions & 1 deletion src/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type State =
| { tag: "INITIAL" }
| { tag: "AUTHENTICATING"; response: Promise<AuthenticationResponse> }
| { tag: "AUTHENTICATED" }
| { tag: "SIGNED_OUT" }
| { tag: "ERROR" };

const LEGACY_ORG_ID_KEY = "workos_organization_id";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -433,6 +439,7 @@ An authorization_code was supplied for a login which did not originate at the ap
}): Promise<AuthenticationResponse> {
try {
return await withLock(REFRESH_LOCK_NAME, async () => {
this.#assertNotSignedOut();
if (organizationId) {
sessionStorage.setItem(orgIdKey(this.#clientId), organizationId);
} else {
Expand All @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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":
Expand Down
Loading