Skip to content
Open
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
120 changes: 119 additions & 1 deletion src/create-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -35,6 +40,8 @@ describe("create-client", () => {
"https://example.com/callback?code=code_123",
);

delete (navigator as any).locks;

nock.cleanAll();
});

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.",
);
});
});
});
});
41 changes: 37 additions & 4 deletions src/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like throwing a RefreshError is still going to result in the user being erroneously logged out? (i don't remember this codebase well maybe i'm wrong) The token in the example cited here is definitely expired (laptop has been suspended). What do you think about retrying N time(s) instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought about just silently catching. Retrying a few times and then throwing this more specific error might be best. Will update!

throw new LoginRequiredError();
} else {
throw err;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ export {
OnRefreshResponse,
JWTPayload,
} from "./interfaces";
export { AuthKitError, LoginRequiredError, NoSessionError } from "./errors";
export {
AuthKitError,
LoginRequiredError,
NoSessionError,
RefreshTimeoutError,
} from "./errors";
1 change: 1 addition & 0 deletions src/utils/locking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ async function withNativeLock<T>(
} catch (error) {
if (error instanceof DOMException) {
switch (error.name) {
case "TimeoutError":
case "AbortError":
throw new LockError("AcquisitionTimeoutError", lockName, "Native");

Expand Down
Loading