Repository: authts/oidc-client-ts (ts)

Issue:
Add support to define JsonService timeout
Given the following use case:
1. App starts
2. User is not logged in
3. A signin silent with iframe is triggered (and awaited because if it fails, user won't be allowed to go further)
4. Down the road a [call](https://github.com/authts/oidc-client-ts/blob/main/src/OidcClient.ts#L119) to `metadataService._getMetadataProperty("authorization_endpoint")` is made
5. Then a [call](https://github.com/authts/oidc-client-ts/blob/main/src/JsonService.ts#L95) to `jsonService.fetchWithTimeout()`

From there, if the identity provider is not reachable, the `fetch` call will timed out only after 90s (depends on web browser implementation). But because 3. was awaited, the app is also frozen for 90s - nothing happens, maybe a white screen, and user is left in the unknown..

In such a case it would be nice to be able to define the timeout ourselves, to reduce the awaiting time.
(something like `silentRequestTimeoutInSeconds`)

Pull request:
Add global requestTimeoutInSeconds setting to OidcClientSettings
<!-- Please link relevant issue numbers or provide context for this change -->
Closes #1414 

This adds a `requestTimeoutInSeconds` setting. When this is defined, it applies a retry timeout to all fetch requests made to the authorization server (e.g. /token, /userinfo, /.well-known/openid-configuration) except for requests made with  `silentRequestTimeoutInSeconds` (if it is defined). 

So the logic goes

1.  requestTimeoutInSeconds is set -> all requests have the same timeout
2. silentRequestTimeoutInSeconds is set -> only silent request have a specific timeout
3. both are set -> all requests have the same timeout except silent requests are set to silentRequestTimeoutInSeconds

### Checklist

- [ ] This PR makes changes to the public API <!-- was the API report (docs/oidc-client-ts.api.md) updated by this PR? -->
- [x] I have included links for closing relevant issue numbers


Patch:
diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md
index 17a02f00..6cd73dab 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -369,6 +369,7 @@ export interface OidcClientSettings {
     prompt?: string;
     redirect_uri: string;
     refreshTokenAllowedScope?: string | undefined;
+    requestTimeoutInSeconds?: number | undefined;
     resource?: string | string[];
     response_mode?: "query" | "fragment";
     response_type?: string;
@@ -382,7 +383,7 @@ export interface OidcClientSettings {
 
 // @public
 export class OidcClientSettingsStore {
-    constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, }: OidcClientSettings);
+    constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, }: OidcClientSettings);
     // (undocumented)
     readonly acr_values: string | undefined;
     // (undocumented)
@@ -430,6 +431,8 @@ export class OidcClientSettingsStore {
     // (undocumented)
     readonly refreshTokenAllowedScope: string | undefined;
     // (undocumented)
+    readonly requestTimeoutInSeconds: number | undefined;
+    // (undocumented)
     readonly resource: string | string[] | undefined;
     // (undocumented)
     readonly response_mode: "query" | "fragment" | undefined;
diff --git a/src/JsonService.ts b/src/JsonService.ts
index 00b001c2..d7561178 100644
--- a/src/JsonService.ts
+++ b/src/JsonService.ts
@@ -16,6 +16,7 @@ export type JwtHandler = (text: string) => Promise<Record<string, unknown>>;
 export interface GetJsonOpts {
     token?: string;
     credentials?: RequestCredentials;
+    timeoutInSeconds?: number;
 }
 
 /**
@@ -78,6 +79,7 @@ export class JsonService {
     public async getJson(url: string, {
         token,
         credentials,
+        timeoutInSeconds,
     }: GetJsonOpts = {}): Promise<Record<string, unknown>> {
         const logger = this._logger.create("getJson");
         const headers: HeadersInit = {
@@ -93,7 +95,7 @@ export class JsonService {
         let response: Response;
         try {
             logger.debug("url:", url);
-            response = await this.fetchWithTimeout(url, { method: "GET", headers, credentials });
+            response = await this.fetchWithTimeout(url, { method: "GET", headers, timeoutInSeconds, credentials });
         }
         catch (err) {
             logger.error("Network Error");
diff --git a/src/MetadataService.ts b/src/MetadataService.ts
index d9e56d2b..51a22610 100644
--- a/src/MetadataService.ts
+++ b/src/MetadataService.ts
@@ -60,7 +60,7 @@ export class MetadataService {
         }
 
         logger.debug("getting metadata from", this._metadataUrl);
-        const metadata = await this._jsonService.getJson(this._metadataUrl, { credentials: this._fetchRequestCredentials });
+        const metadata = await this._jsonService.getJson(this._metadataUrl, { credentials: this._fetchRequestCredentials, timeoutInSeconds: this._settings.requestTimeoutInSeconds });
 
         logger.debug("merging remote JSON with seed metadata");
         this._metadata = Object.assign({}, this._settings.metadataSeed, metadata);
@@ -133,7 +133,7 @@ export class MetadataService {
         const jwks_uri = await this.getKeysEndpoint(false);
         logger.debug("got jwks_uri", jwks_uri);
 
-        const keySet = await this._jsonService.getJson(jwks_uri);
+        const keySet = await this._jsonService.getJson(jwks_uri, { timeoutInSeconds: this._settings.requestTimeoutInSeconds });
         logger.debug("got key set", keySet);
 
         if (!Array.isArray(keySet.keys)) {
diff --git a/src/OidcClientSettings.ts b/src/OidcClientSettings.ts
index 5e915ef1..0c90c5fb 100644
--- a/src/OidcClientSettings.ts
+++ b/src/OidcClientSettings.ts
@@ -137,6 +137,11 @@ export interface OidcClientSettings {
      * Only scopes in this list will be passed in the token refresh request.
      */
     refreshTokenAllowedScope?: string | undefined;
+
+    /**
+     * Defines request timeouts globally across all requests made to the authorisation server
+     */
+    requestTimeoutInSeconds?: number | undefined;
 }
 
 /**
@@ -188,6 +193,7 @@ export class OidcClientSettingsStore {
     public readonly fetchRequestCredentials: RequestCredentials;
     public readonly refreshTokenAllowedScope: string | undefined;
     public readonly disablePKCE: boolean;
+    public readonly requestTimeoutInSeconds: number | undefined;
 
     public constructor({
         // metadata related
@@ -201,6 +207,7 @@ export class OidcClientSettingsStore {
         // behavior flags
         filterProtocolClaims = true,
         loadUserInfo = false,
+        requestTimeoutInSeconds,
         staleStateAgeInSeconds = DefaultStaleStateAgeInSeconds,
         mergeClaimsStrategy = { array: "replace" },
         disablePKCE = false,
@@ -257,6 +264,7 @@ export class OidcClientSettingsStore {
         this.revokeTokenAdditionalContentTypes = revokeTokenAdditionalContentTypes;
 
         this.fetchRequestCredentials = fetchRequestCredentials ? fetchRequestCredentials : "same-origin";
+        this.requestTimeoutInSeconds = requestTimeoutInSeconds;
 
         if (stateStore) {
             this.stateStore = stateStore;
diff --git a/src/TokenClient.ts b/src/TokenClient.ts
index 6df06b19..ff94e4ad 100644
--- a/src/TokenClient.ts
+++ b/src/TokenClient.ts
@@ -129,7 +129,14 @@ export class TokenClient {
         const url = await this._metadataService.getTokenEndpoint(false);
         logger.debug("got token endpoint");
 
-        const response = await this._jsonService.postForm(url, { body: params, basicAuth, initCredentials: this._settings.fetchRequestCredentials, extraHeaders });
+        const response = await this._jsonService.postForm(url, {
+            body: params,
+            basicAuth,
+            timeoutInSeconds: this._settings.requestTimeoutInSeconds,
+            initCredentials: this._settings.fetchRequestCredentials,
+            extraHeaders,
+        });
+
         logger.debug("got response");
 
         return response;
@@ -180,7 +187,7 @@ export class TokenClient {
         const url = await this._metadataService.getTokenEndpoint(false);
         logger.debug("got token endpoint");
 
-        const response = await this._jsonService.postForm(url, { body: params, basicAuth, initCredentials: this._settings.fetchRequestCredentials });
+        const response = await this._jsonService.postForm(url, { body: params, basicAuth, timeoutInSeconds: this._settings.requestTimeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials });
         logger.debug("got response");
 
         return response;
@@ -268,7 +275,7 @@ export class TokenClient {
             params.set("client_secret", this._settings.client_secret);
         }
 
-        await this._jsonService.postForm(url, { body: params });
+        await this._jsonService.postForm(url, { body: params, timeoutInSeconds: this._settings.requestTimeoutInSeconds });
         logger.debug("got response");
     }
 }
diff --git a/src/UserInfoService.ts b/src/UserInfoService.ts
index ed3ad6e6..a610bdd5 100644
--- a/src/UserInfoService.ts
+++ b/src/UserInfoService.ts
@@ -36,6 +36,7 @@ export class UserInfoService {
         const claims = await this._jsonService.getJson(url, {
             token,
             credentials: this._settings.fetchRequestCredentials,
+            timeoutInSeconds: this._settings.requestTimeoutInSeconds,
         });
         logger.debug("got claims", claims);
 
diff --git a/src/UserManager.ts b/src/UserManager.ts
index be5b33ef..51b0df07 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -331,8 +331,8 @@ export class UserManager {
 
     protected async _useRefreshToken(args: UseRefreshTokenArgs): Promise<User> {
         const response = await this._client.useRefreshToken({
-            ...args,
             timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds,
+            ...args,
         });
         const user = new User({ ...args.state, ...response });
 
diff --git a/src/UserManagerSettings.ts b/src/UserManagerSettings.ts
index cf6ae366..92124c77 100644
--- a/src/UserManagerSettings.ts
+++ b/src/UserManagerSettings.ts
@@ -134,8 +134,9 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
             iframeNotifyParentOrigin = args.iframeNotifyParentOrigin,
             iframeScriptOrigin = args.iframeScriptOrigin,
 
+            requestTimeoutInSeconds,
             silent_redirect_uri = args.redirect_uri,
-            silentRequestTimeoutInSeconds = DefaultSilentRequestTimeoutInSeconds,
+            silentRequestTimeoutInSeconds,
             automaticSilentRenew = true,
             validateSubOnSilentRenew = true,
             includeIdTokenInSilentRenew = false,
@@ -168,7 +169,7 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
         this.iframeScriptOrigin = iframeScriptOrigin;
 
         this.silent_redirect_uri = silent_redirect_uri;
-        this.silentRequestTimeoutInSeconds = silentRequestTimeoutInSeconds;
+        this.silentRequestTimeoutInSeconds = silentRequestTimeoutInSeconds || requestTimeoutInSeconds || DefaultSilentRequestTimeoutInSeconds;
         this.automaticSilentRenew = automaticSilentRenew;
         this.validateSubOnSilentRenew = validateSubOnSilentRenew;
         this.includeIdTokenInSilentRenew = includeIdTokenInSilentRenew;


Test patch:
diff --git a/src/MetadataService.test.ts b/src/MetadataService.test.ts
index 53f1318e..fef212b5 100644
--- a/src/MetadataService.test.ts
+++ b/src/MetadataService.test.ts
@@ -58,7 +58,7 @@ describe("MetadataService", () => {
             await subject.getMetadata();
 
             // assert
-            expect(getJsonMock).toHaveBeenCalledWith("authority/.well-known/openid-configuration", { credentials: "same-origin" });
+            expect(getJsonMock).toHaveBeenCalledWith("authority/.well-known/openid-configuration", { credentials: "same-origin", timeoutInSeconds: undefined } );
         });
 
         it("should fail when no authority or metadataUrl configured", async () => {
@@ -93,7 +93,7 @@ describe("MetadataService", () => {
             await subject.getMetadata();
 
             // assert
-            expect(getJsonMock).toHaveBeenCalledWith("http://sts/metadata", { credentials: "same-origin" });
+            expect(getJsonMock).toHaveBeenCalledWith("http://sts/metadata", { credentials: "same-origin", timeoutInSeconds: undefined });
         });
 
         it("should return metadata from json call", async () => {
@@ -196,7 +196,7 @@ describe("MetadataService", () => {
             await subject.getMetadata();
 
             // assert
-            expect(getJsonMock).toHaveBeenCalledWith("http://sts/metadata", { credentials: "include" });
+            expect(getJsonMock).toHaveBeenCalledWith("http://sts/metadata", { credentials: "include", timeoutInSeconds: undefined });
         });
     });
 
@@ -535,7 +535,7 @@ describe("MetadataService", () => {
             await subject.getSigningKeys();
 
             // assert
-            expect(getJsonMock).toHaveBeenCalledWith("http://sts/metadata/keys");
+            expect(getJsonMock).toHaveBeenCalledWith("http://sts/metadata/keys", { "timeoutInSeconds": undefined });
         });
 
         it("should return keys from jwks_uri", async () => {
diff --git a/src/UserManagerSettings.test.ts b/src/UserManagerSettings.test.ts
index e1e3cb39..7cc5c764 100644
--- a/src/UserManagerSettings.test.ts
+++ b/src/UserManagerSettings.test.ts
@@ -376,4 +376,48 @@ describe("UserManagerSettings", () => {
             expect(subject.stopCheckSessionOnError).toEqual(true);
         });
     });
+
+    describe("silentRequestTimeoutInSeconds", () => {
+        it("should set if defined in the constructor", () => {
+            const temp = 100;
+
+            // act
+            const subject = new UserManagerSettingsStore({
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+                silentRequestTimeoutInSeconds : temp,
+            });
+
+            // assert
+            expect(subject.silentRequestTimeoutInSeconds).toEqual(temp);
+        });
+
+        it("should set to requestTimeoutInSeconds if defined in the constructor", () => {
+            const temp = 100;
+
+            // act
+            const subject = new UserManagerSettingsStore({
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+                requestTimeoutInSeconds : temp,
+            });
+
+            // assert
+            expect(subject.silentRequestTimeoutInSeconds).toEqual(temp);
+        });
+
+        it("should set to the default if neither requestTimeoutInSeconds are defined", () => {
+            // act
+            const subject = new UserManagerSettingsStore({
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+            });
+
+            // assert
+            expect(subject.silentRequestTimeoutInSeconds).toEqual(10);
+        });
+    });
 });