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
14 changes: 10 additions & 4 deletions src/wrapper/SquidexClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,13 @@ export class SquidexClients {
const fetchCore = this.clientOptions.fetcher || fetch;
const fetchApi: FetchAPI = async (input, init) => {
init ||= {};
let accessToken: string | undefined;

addOptions(init, clientOptions);

if (!getHeader(init, "X-AuthRequest")) {
addHeader(init, "Authorization", `Bearer ${await this.tokenApi.getToken()}`);
accessToken = await this.tokenApi.getToken();
addHeader(init, "Authorization", `Bearer ${accessToken}`);
}

let response: Response;
Expand All @@ -290,7 +292,7 @@ export class SquidexClients {

if (response && response.status === 401 && !getHeader(init, "X-Retry")) {
addHeader(init, "X-Retry", "1");
this.clearToken();
this.clearToken(accessToken);
return await fetchApi(input, init);
}
} catch (error: unknown) {
Expand Down Expand Up @@ -323,8 +325,12 @@ export class SquidexClients {
/**
* Clears the current token in case it has been expired.
*/
clearToken() {
this.tokenStore.clear();
clearToken(accessToken?: string) {
const token = this.tokenStore.get();

if (accessToken === undefined || token?.accessToken === accessToken) {
this.tokenStore.clear();
}
}

/**
Expand Down
14 changes: 7 additions & 7 deletions src/wrapper/TokenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ export class TokenAPI extends BaseAPI {
}

public async getToken() {
const promise = (this.tokenPromise ||= (async () => {
const now = new Date().getTime();
try {
let token = this.tokenStore.get();
const now = new Date().getTime();
let token = this.tokenStore.get();

if (token != null && token.expiresAt > now) {
return token.accessToken;
}
if (token != null && token.expiresAt > now) {
return token.accessToken;
}

const promise = (this.tokenPromise ||= (async () => {
try {
const response = await this.request({
path: "/identity-server/connect/token",
headers: {
Expand Down
139 changes: 139 additions & 0 deletions tests/tokenRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { SquidexClient } from "../src";

describe("Token refresh", () => {
it("shares a token request between concurrent API requests", async () => {
let tokenRequestCount = 0;
let resolveTokenRequestStarted: () => void;
let resolvePendingTokenResponse: (response: Response) => void;

const tokenRequestStarted = new Promise<void>((resolve) => {
resolveTokenRequestStarted = resolve;
});

const pendingTokenResponse = new Promise<Response>((resolve) => {
resolvePendingTokenResponse = resolve;
});

const client = new SquidexClient({
appName: "my-app",
clientId: "client-id",
clientSecret: "client-secret",
url: "https://squidex.example",
fetcher: async (input) => {
if (input.toString().endsWith("/identity-server/connect/token")) {
tokenRequestCount++;

if (tokenRequestCount === 1) {
resolveTokenRequestStarted!();
}

return pendingTokenResponse;
}

return new Response(JSON.stringify([]));
},
});

const concurrentApiRequests = [
client.languages.getLanguages(),
client.languages.getLanguages(),
client.languages.getLanguages(),
];

await tokenRequestStarted;

expect(tokenRequestCount).toBe(1);

resolvePendingTokenResponse!(new Response(JSON.stringify({ access_token: "token-1", expires_in: 60_000 })));

await Promise.all(concurrentApiRequests);
});

it("does not discard a refreshed token after a delayed 401 response", async () => {
let tokenRequestCount = 0;
const tokenOneResponseResolvers: Array<(response: Response) => void> = [];
let resolveInitialApiRequestsReceived: () => void;

const initialApiRequestsReceived = new Promise<void>((resolve) => {
resolveInitialApiRequestsReceived = resolve;
});

const client = new SquidexClient({
appName: "my-app",
clientId: "client-id",
clientSecret: "client-secret",
url: "https://squidex.example",
fetcher: async (input, init) => {
if (input.toString().endsWith("/identity-server/connect/token")) {
tokenRequestCount++;
return new Response(JSON.stringify({ access_token: `token-${tokenRequestCount}`, expires_in: 60_000 }));
}

if (new Headers(init?.headers).get("Authorization") === "Bearer token-1") {
return new Promise<Response>((resolve) => {
tokenOneResponseResolvers.push(resolve);

if (tokenOneResponseResolvers.length === 2) {
resolveInitialApiRequestsReceived!();
}
});
}

return new Response(JSON.stringify([]));
},
});

const firstApiRequest = client.languages.getLanguages();
const secondApiRequest = client.languages.getLanguages();

await initialApiRequestsReceived;

tokenOneResponseResolvers[0](new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 }));
await firstApiRequest;

tokenOneResponseResolvers[1](new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 }));
await secondApiRequest;

expect(tokenRequestCount).toBe(2);
});

it("refreshes a cached token after a 401", async () => {
let tokenRequestCount = 0;
let rejectTokenOne = false;
let tokenOneWasRejected = false;

const client = new SquidexClient({
appName: "my-app",
clientId: "client-id",
clientSecret: "client-secret",
url: "https://squidex.example",
fetcher: async (input, init) => {
const url = input.toString();
const headers = new Headers(init?.headers);

if (url.endsWith("/identity-server/connect/token")) {
tokenRequestCount++;

return new Response(JSON.stringify({ access_token: `token-${tokenRequestCount}`, expires_in: 60_000 }));
}

const authorization = headers.get("Authorization");

if (rejectTokenOne && authorization === "Bearer token-1") {
tokenOneWasRejected = true;
return new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 });
}

return new Response(JSON.stringify([]));
},
});

await client.languages.getLanguages();

rejectTokenOne = true;
await client.languages.getLanguages();

expect(tokenRequestCount).toBe(2);
expect(tokenOneWasRejected).toBe(true);
});
});