From 21666f38f8729c213ad8b6e7f023b00d2e703a63 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:04:10 -0500 Subject: [PATCH 1/5] fix: catch lock timeout errors instead of leaking to app After laptop sleep, AbortSignal.timeout on navigator.locks.request fires before the browser event loop resumes, causing a raw LockError to escape to the application. This converts the error to a typed RefreshTimeoutError (extends RefreshError) and applies an expiry-aware fallback: return the existing token if still valid, throw RefreshTimeoutError if expired or forceRefresh requested. - Add RefreshTimeoutError class extending RefreshError - Convert LockError to RefreshTimeoutError in #doRefresh - Expiry-aware fallback in getAccessToken (return unexpired token) - Handle RefreshTimeoutError in switchToOrganization (warn, don't redirect) - Catch TimeoutError DOMException alongside AbortError in withNativeLock - Export RefreshTimeoutError for consumers who need to catch it --- src/create-client.test.ts | 109 +++++++++++++++++++++++++++++++++++++- src/create-client.ts | 31 +++++++++-- src/errors.ts | 11 ++++ src/index.ts | 7 ++- src/utils/locking.ts | 1 + 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/src/create-client.test.ts b/src/create-client.test.ts index ab87b50..cee11ea 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -3,10 +3,17 @@ */ 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"; +import { LockError } from "./utils/locking"; +import { MockWebLocks } from "./testing/mock-web-locks"; import nock from "nock"; describe("create-client", () => { @@ -35,6 +42,9 @@ describe("create-client", () => { "https://example.com/callback?code=code_123", ); + // @ts-ignore — clean up any navigator.locks mock + delete navigator.locks; + nock.cleanAll(); }); @@ -936,6 +946,73 @@ describe("create-client", () => { scope.done(); }); + const installLockTimeout = () => { + Object.defineProperty(navigator, "locks", { + value: { + request: () => + Promise.reject( + new DOMException("Signal timed out.", "AbortError"), + ), + }, + configurable: true, + }); + }; + + 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 and token is expired", async () => { + const client = await clientWithExpiredAccessToken(); + + installLockTimeout(); + + await expect(client.getAccessToken()).rejects.toThrow( + RefreshTimeoutError, + ); + }); + + 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 +1097,36 @@ 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(); + + Object.defineProperty(navigator, "locks", { + value: { + request: () => + Promise.reject( + new DOMException("Signal timed out.", "AbortError"), + ), + }, + configurable: true, + }); + + 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..322a162 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,13 @@ 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; + throw err; + } else if (err instanceof RefreshError) { throw new LoginRequiredError(); } else { throw err; @@ -327,7 +338,9 @@ 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 +415,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 +503,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..9d23f8c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -5,6 +5,17 @@ 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); + this.name = "RefreshTimeoutError"; + if (options?.cause) this.cause = options.cause; + } +} + 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"); From 184cc47220f8ca75e2d255bfb7fa0897c3f88a2e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:05:51 -0500 Subject: [PATCH 2/5] chore: formatting --- src/create-client.test.ts | 4 +--- src/create-client.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/create-client.test.ts b/src/create-client.test.ts index cee11ea..eaac13d 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -1099,9 +1099,7 @@ describe("create-client", () => { }); it("does not throw when lock acquisition times out", async () => { - const consoleWarnSpy = jest - .spyOn(console, "warn") - .mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); const { scope: createClientScope } = nockRefresh(); client = await createClient("client_123abc", { redirectUri: "https://example.com/", diff --git a/src/create-client.ts b/src/create-client.ts index 322a162..e50c125 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -339,7 +339,9 @@ An authorization_code was supplied for a login which did not originate at the ap await this.#refreshSession({ organizationId }); } catch (error) { if (error instanceof RefreshTimeoutError) { - console.warn("Couldn't switch organization: lock acquisition timed out."); + console.warn( + "Couldn't switch organization: lock acquisition timed out.", + ); } else if (error instanceof RefreshError) { this.signIn({ ...signInOpts, organizationId }); } else { From b60abb7b76f6ec623c862c310860e697027bc3ff Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:27:32 -0500 Subject: [PATCH 3/5] refactor: clean up lock timeout implementation - Remove unused LockError and MockWebLocks imports from tests - Use native Error cause via super() instead of manual assignment - Replace @ts-ignore with cast for navigator.locks cleanup - Hoist installLockTimeout helper to shared scope, remove duplicate --- src/create-client.test.ts | 39 ++++++++++++++------------------------- src/errors.ts | 3 +-- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/src/create-client.test.ts b/src/create-client.test.ts index eaac13d..10bbe8d 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -12,8 +12,6 @@ import { import { mockLocation, restoreLocation } from "./testing/mock-location"; import { getClaims } from "./utils/session-data"; import { storageKeys } from "./utils/storage-keys"; -import { LockError } from "./utils/locking"; -import { MockWebLocks } from "./testing/mock-web-locks"; import nock from "nock"; describe("create-client", () => { @@ -42,8 +40,7 @@ describe("create-client", () => { "https://example.com/callback?code=code_123", ); - // @ts-ignore — clean up any navigator.locks mock - delete navigator.locks; + delete (navigator as any).locks; nock.cleanAll(); }); @@ -257,6 +254,18 @@ 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(); @@ -946,18 +955,6 @@ describe("create-client", () => { scope.done(); }); - const installLockTimeout = () => { - Object.defineProperty(navigator, "locks", { - value: { - request: () => - Promise.reject( - new DOMException("Signal timed out.", "AbortError"), - ), - }, - configurable: true, - }); - }; - it("returns the existing token when lock times out and token is unexpired", async () => { const now = Date.now(); const { scope } = nockRefresh({ @@ -1107,15 +1104,7 @@ describe("create-client", () => { }); createClientScope.done(); - Object.defineProperty(navigator, "locks", { - value: { - request: () => - Promise.reject( - new DOMException("Signal timed out.", "AbortError"), - ), - }, - configurable: true, - }); + installLockTimeout(); await expect( client.switchToOrganization({ organizationId: "org_123abc" }), diff --git a/src/errors.ts b/src/errors.ts index 9d23f8c..620a1a8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,9 +10,8 @@ export class RefreshTimeoutError extends RefreshError { message = "Timed out waiting to refresh the session.", options?: { cause?: unknown }, ) { - super(message); + super(message, options); this.name = "RefreshTimeoutError"; - if (options?.cause) this.cause = options.cause; } } From 849313f6157fe2ced58a705fd244a3124dd24524 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:27:49 -0500 Subject: [PATCH 4/5] chore: formatting --- src/create-client.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/create-client.test.ts b/src/create-client.test.ts index 10bbe8d..d1c905c 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -258,9 +258,7 @@ describe("create-client", () => { Object.defineProperty(navigator, "locks", { value: { request: () => - Promise.reject( - new DOMException("Signal timed out.", "AbortError"), - ), + Promise.reject(new DOMException("Signal timed out.", "AbortError")), }, configurable: true, }); From 803683c3a9b44232afef381aa39e85a1289d5e62 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:29:20 -0500 Subject: [PATCH 5/5] fix: retry refresh once on lock timeout before throwing When lock acquisition times out and the token is expired (or forceRefresh requested), retry the refresh once before giving up. This handles the common post-sleep case where the first attempt times out but the lock clears on retry. --- src/create-client.test.ts | 28 +++++++++++++++++++++++++++- src/create-client.ts | 10 +++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/create-client.test.ts b/src/create-client.test.ts index d1c905c..273baa0 100644 --- a/src/create-client.test.ts +++ b/src/create-client.test.ts @@ -975,7 +975,7 @@ describe("create-client", () => { expect(accessToken).toMatch(/^eyJ/); }); - it("throws RefreshTimeoutError when lock times out and token is expired", async () => { + it("throws RefreshTimeoutError when lock times out twice and token is expired", async () => { const client = await clientWithExpiredAccessToken(); installLockTimeout(); @@ -985,6 +985,32 @@ describe("create-client", () => { ); }); + 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({ diff --git a/src/create-client.ts b/src/create-client.ts index e50c125..c2914c2 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -221,7 +221,15 @@ export class Client { ? this.#getUnexpiredAccessToken() : undefined; if (token) return token; - throw err; + + 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 {