diff --git a/src/create-client.test.ts b/src/create-client.test.ts index ab87b50..273baa0 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -3,7 +3,12 @@ */ import { createClient } from "./create-client"; -import { LoginRequiredError, NoSessionError, RefreshError } from "./errors"; +import { + LoginRequiredError, + NoSessionError, + RefreshError, + RefreshTimeoutError, +} from "./errors"; import { mockLocation, restoreLocation } from "./testing/mock-location"; import { getClaims } from "./utils/session-data"; import { storageKeys } from "./utils/storage-keys"; @@ -35,6 +40,8 @@ describe("create-client", () => { "https://example.com/callback?code=code_123", ); + delete (navigator as any).locks; + nock.cleanAll(); }); @@ -247,6 +254,16 @@ describe("create-client", () => { return { scope }; } + function installLockTimeout() { + Object.defineProperty(navigator, "locks", { + value: { + request: () => + Promise.reject(new DOMException("Signal timed out.", "AbortError")), + }, + configurable: true, + }); + } + describe("signIn", () => { beforeEach(() => { mockLocation(); @@ -936,6 +953,87 @@ describe("create-client", () => { scope.done(); }); + it("returns the existing token when lock times out and token is unexpired", async () => { + const now = Date.now(); + const { scope } = nockRefresh({ + accessTokenClaims: { + iat: now, + exp: now + 60, + }, + }); + + client = await createClient("client_123abc", { + redirectUri: "https://example.com/", + onBeforeAutoRefresh: () => false, + refreshBufferInterval: 120, + }); + scope.done(); + + installLockTimeout(); + + const accessToken = await client.getAccessToken(); + expect(accessToken).toMatch(/^eyJ/); + }); + + it("throws RefreshTimeoutError when lock times out twice and token is expired", async () => { + const client = await clientWithExpiredAccessToken(); + + installLockTimeout(); + + await expect(client.getAccessToken()).rejects.toThrow( + RefreshTimeoutError, + ); + }); + + it("retries and succeeds when lock times out once then resolves", async () => { + const client = await clientWithExpiredAccessToken(); + + let callCount = 0; + Object.defineProperty(navigator, "locks", { + value: { + request: (_name: string, _opts: any, cb: any) => { + callCount++; + if (callCount === 1) { + return Promise.reject( + new DOMException("Signal timed out.", "AbortError"), + ); + } + return cb({ name: _name, mode: "exclusive" }); + }, + }, + configurable: true, + }); + + const { scope } = nockRefresh(); + const accessToken = await client.getAccessToken(); + expect(accessToken).toMatch(/^eyJ/); + expect(callCount).toBe(2); + scope.done(); + }); + + it("throws RefreshTimeoutError on forceRefresh even with unexpired token", async () => { + const now = Date.now(); + const { scope } = nockRefresh({ + accessTokenClaims: { + iat: now, + exp: now + 60, + }, + }); + + client = await createClient("client_123abc", { + redirectUri: "https://example.com/", + onBeforeAutoRefresh: () => false, + refreshBufferInterval: 120, + }); + scope.done(); + + installLockTimeout(); + + await expect( + client.getAccessToken({ forceRefresh: true }), + ).rejects.toThrow(RefreshTimeoutError); + }); + it("throws an error if the fetch fails", async () => { const consoleDebugSpy = jest .spyOn(console, "debug") @@ -1020,6 +1118,26 @@ describe("create-client", () => { state: { returnTo: "/somewhere" }, }); }); + + it("does not throw when lock acquisition times out", async () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + const { scope: createClientScope } = nockRefresh(); + client = await createClient("client_123abc", { + redirectUri: "https://example.com/", + onBeforeAutoRefresh: () => false, + }); + createClientScope.done(); + + installLockTimeout(); + + await expect( + client.switchToOrganization({ organizationId: "org_123abc" }), + ).resolves.toBeUndefined(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Couldn't switch organization: lock acquisition timed out.", + ); + }); }); }); }); diff --git a/src/create-client.ts b/src/create-client.ts index a3a9e9f..c2914c2 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -15,7 +15,12 @@ import { } from "./utils"; import { getRefreshToken, getClaims } from "./utils/session-data"; import { RedirectParams } from "./interfaces/create-client-options.interface"; -import { LoginRequiredError, NoSessionError, RefreshError } from "./errors"; +import { + LoginRequiredError, + NoSessionError, + RefreshError, + RefreshTimeoutError, +} from "./errors"; import { withLock, LockError } from "./utils/locking"; import { HttpClient } from "./http-client"; @@ -211,7 +216,21 @@ export class Client { try { await this.#refreshSession(); } catch (err) { - if (err instanceof RefreshError) { + if (err instanceof RefreshTimeoutError) { + const token = !options?.forceRefresh + ? this.#getUnexpiredAccessToken() + : undefined; + if (token) return token; + + try { + await this.#refreshSession(); + } catch (retryErr) { + if (retryErr instanceof RefreshTimeoutError) throw retryErr; + if (retryErr instanceof RefreshError) + throw new LoginRequiredError(); + throw retryErr; + } + } else if (err instanceof RefreshError) { throw new LoginRequiredError(); } else { throw err; @@ -327,7 +346,11 @@ An authorization_code was supplied for a login which did not originate at the ap try { await this.#refreshSession({ organizationId }); } catch (error) { - if (error instanceof RefreshError) { + if (error instanceof RefreshTimeoutError) { + console.warn( + "Couldn't switch organization: lock acquisition timed out.", + ); + } else if (error instanceof RefreshError) { this.signIn({ ...signInOpts, organizationId }); } else { throw error; @@ -402,7 +425,7 @@ An authorization_code was supplied for a login which did not originate at the ap // preserving the original state so that we can try again next time this.#state = beginningState; - throw error; + throw new RefreshTimeoutError(undefined, { cause: error }); } if (beginningState.tag !== "INITIAL") { @@ -490,6 +513,16 @@ An authorization_code was supplied for a login which did not originate at the ap return memoryStorage.getItem(storageKeys.accessToken) as string | undefined; } + #getUnexpiredAccessToken(): string | undefined { + const accessToken = this.#getAccessToken(); + const expiresAt = memoryStorage.getItem(storageKeys.expiresAt) as + | number + | undefined; + return accessToken && expiresAt && expiresAt > Date.now() + ? accessToken + : undefined; + } + get #useCookie() { return !this.#devMode; } diff --git a/src/errors.ts b/src/errors.ts index cde4927..620a1a8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -5,6 +5,16 @@ export class LoginRequiredError extends AuthKitError { readonly message: string = "No access token available"; } +export class RefreshTimeoutError extends RefreshError { + constructor( + message = "Timed out waiting to refresh the session.", + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = "RefreshTimeoutError"; + } +} + export class NoSessionError extends AuthKitError { readonly message = "SignOut() called without an active session. Provide a returnTo URL to redirect anyway."; diff --git a/src/index.ts b/src/index.ts index dced975..207a871 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,4 +7,9 @@ export { OnRefreshResponse, JWTPayload, } from "./interfaces"; -export { AuthKitError, LoginRequiredError, NoSessionError } from "./errors"; +export { + AuthKitError, + LoginRequiredError, + NoSessionError, + RefreshTimeoutError, +} from "./errors"; diff --git a/src/utils/locking.ts b/src/utils/locking.ts index 6d2502f..073fb33 100644 --- a/src/utils/locking.ts +++ b/src/utils/locking.ts @@ -30,6 +30,7 @@ async function withNativeLock( } catch (error) { if (error instanceof DOMException) { switch (error.name) { + case "TimeoutError": case "AbortError": throw new LockError("AcquisitionTimeoutError", lockName, "Native");