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

Issue:
Adding support for custom navigators?
Would the maintainers be open to another PR focused at adding support for custom navigators? (Kinda did ok on the storage engine one, working well in a product with Electron!)

Scenario:

Building a React + Ionic + Capacitor app for iOS. It kinda works ok if you let it leave the app and redirect back.

Ideal solution is to use the Capacitor Browser plug-in to open it without leaving the app.

My idea:

Add 3 new settings to "UserManagerSettings". 

That either get defaulted to the existing if not defined like here:

https://github.com/authts/oidc-client-ts/blob/main/src/UserManagerSettings.ts#L187

Then alter here to use the values from "UserManagerSettings":

https://github.com/authts/oidc-client-ts/blob/main/src/UserManager.ts#L98

Pull request description:
Feature: overriding Navigators for non Window browser envs
<!-- Please link relevant issue numbers or provide context for this change -->
Fixes #1147 

### Checklist

- [X] 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 a7412c74..7668296e 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -128,6 +128,14 @@ export interface ILogger {
     warn(...args: unknown[]): void;
 }
 
+// @public (undocumented)
+export interface INavigator {
+    // (undocumented)
+    callback(url: string, params?: unknown): Promise<void>;
+    // (undocumented)
+    prepare(params: unknown): Promise<IWindow>;
+}
+
 // @public (undocumented)
 export class InMemoryWebStorage implements Storage {
     // (undocumented)
@@ -144,6 +152,14 @@ export class InMemoryWebStorage implements Storage {
     setItem(key: string, value: string): void;
 }
 
+// @public (undocumented)
+export interface IWindow {
+    // (undocumented)
+    close(): void;
+    // (undocumented)
+    navigate(params: NavigateParams): Promise<NavigateResponse>;
+}
+
 // @public
 export interface JwtClaims {
     // (undocumented)
@@ -243,6 +259,24 @@ export class MetadataService {
     resetSigningKeys(): void;
 }
 
+// @public (undocumented)
+export interface NavigateParams {
+    nonce?: string;
+    // (undocumented)
+    response_mode?: "query" | "fragment";
+    // (undocumented)
+    scriptOrigin?: string;
+    state?: string;
+    // (undocumented)
+    url: string;
+}
+
+// @public (undocumented)
+export interface NavigateResponse {
+    // (undocumented)
+    url: string;
+}
+
 // @public
 export interface OidcAddressClaim {
     country?: string;
@@ -480,6 +514,7 @@ export interface OidcStandardClaims {
 export interface PopupWindowFeatures {
     // (undocumented)
     [k: string]: boolean | string | number | undefined;
+    closePopupWindowAfterInSeconds?: number;
     // (undocumented)
     height?: number;
     // (undocumented)
@@ -847,7 +882,7 @@ export type UserLoadedCallback = (user: User) => Promise<void> | void;
 
 // @public
 export class UserManager {
-    constructor(settings: UserManagerSettings);
+    constructor(settings: UserManagerSettings, redirectNavigator?: INavigator, popupNavigator?: INavigator, iframeNavigator?: INavigator);
     // (undocumented)
     protected _buildUser(signinResponse: SigninResponse, verifySub?: string): Promise<User>;
     clearStaleState(): Promise<void>;
@@ -857,24 +892,18 @@ export class UserManager {
     // (undocumented)
     protected readonly _events: UserManagerEvents;
     getUser(): Promise<User | null>;
-    // Warning: (ae-forgotten-export) The symbol "IFrameNavigator" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
-    protected readonly _iframeNavigator: IFrameNavigator;
+    protected readonly _iframeNavigator: INavigator;
     // (undocumented)
     protected _loadUser(): Promise<User | null>;
     // (undocumented)
     protected readonly _logger: Logger;
     get metadataService(): MetadataService;
-    // Warning: (ae-forgotten-export) The symbol "PopupNavigator" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
-    protected readonly _popupNavigator: PopupNavigator;
+    protected readonly _popupNavigator: INavigator;
     querySessionStatus(args?: QuerySessionStatusArgs): Promise<SessionStatus | null>;
-    // Warning: (ae-forgotten-export) The symbol "RedirectNavigator" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
-    protected readonly _redirectNavigator: RedirectNavigator;
+    protected readonly _redirectNavigator: INavigator;
     removeUser(): Promise<void>;
     // (undocumented)
     protected _revokeInternal(user: User | null, types?: ("access_token" | "refresh_token")[]): Promise<void>;
@@ -883,8 +912,6 @@ export class UserManager {
     // (undocumented)
     protected readonly _sessionMonitor: SessionMonitor | null;
     readonly settings: UserManagerSettingsStore;
-    // Warning: (ae-forgotten-export) The symbol "IWindow" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
     protected _signin(args: CreateSigninRequestArgs, handle: IWindow, verifySub?: string): Promise<User>;
     // (undocumented)
@@ -898,8 +925,6 @@ export class UserManager {
     signinResourceOwnerCredentials({ username, password, skipUserInfo, }: SigninResourceOwnerCredentialsArgs): Promise<User>;
     signinSilent(args?: SigninSilentArgs): Promise<User | null>;
     signinSilentCallback(url?: string): Promise<void>;
-    // Warning: (ae-forgotten-export) The symbol "NavigateResponse" needs to be exported by the entry point index.d.ts
-    //
     // (undocumented)
     protected _signinStart(args: CreateSigninRequestArgs, handle: IWindow): Promise<NavigateResponse>;
     // (undocumented)
diff --git a/package-lock.json b/package-lock.json
index 393ab8f8..ab456b86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,7 @@
         "jest-mock": "^29.3.1",
         "lint-staged": "^14.0.0",
         "ts-jest": "^29.0.3",
-        "typedoc": "^0.24.1",
+        "typedoc": "^0.25.0",
         "typescript": "~5.0.4",
         "yn": "^5.0.0"
       },
@@ -1075,9 +1075,9 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.47.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.47.0.tgz",
-      "integrity": "sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==",
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz",
+      "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -3378,15 +3378,15 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.47.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz",
-      "integrity": "sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==",
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz",
+      "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "^8.47.0",
+        "@eslint/js": "8.48.0",
         "@humanwhocodes/config-array": "^0.11.10",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
@@ -3432,9 +3432,9 @@
       }
     },
     "node_modules/eslint-plugin-testing-library": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-6.0.0.tgz",
-      "integrity": "sha512-SHeisSxG1f6UV2wKdMuQBiTTmGEQ858H7LMyz1LG+30TxPQEcXXQauC4E6sfEdcrJ/nIOUxNmlFZdlL6PNK4mg==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-6.0.1.tgz",
+      "integrity": "sha512-CEYtjpcF3hAaQtYsTZqciR7s5z+T0LCMTwJeW+pz6kBnGtc866wAKmhaiK2Gsjc2jWNP7Gt6zhNr2DE1ZW4e+g==",
       "dev": true,
       "dependencies": {
         "@typescript-eslint/utils": "^5.58.0"
@@ -6840,24 +6840,24 @@
       }
     },
     "node_modules/typedoc": {
-      "version": "0.24.8",
-      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.8.tgz",
-      "integrity": "sha512-ahJ6Cpcvxwaxfu4KtjA8qZNqS43wYt6JL27wYiIgl1vd38WW/KWX11YuAeZhuz9v+ttrutSsgK+XO1CjL1kA3w==",
+      "version": "0.25.0",
+      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.0.tgz",
+      "integrity": "sha512-FvCYWhO1n5jACE0C32qg6b3dSfQ8f2VzExnnRboowHtqUD6ARzM2r8YJeZFYXhcm2hI4C2oCRDgNPk/yaQUN9g==",
       "dev": true,
       "dependencies": {
         "lunr": "^2.3.9",
         "marked": "^4.3.0",
-        "minimatch": "^9.0.0",
+        "minimatch": "^9.0.3",
         "shiki": "^0.14.1"
       },
       "bin": {
         "typedoc": "bin/typedoc"
       },
       "engines": {
-        "node": ">= 14.14"
+        "node": ">= 16"
       },
       "peerDependencies": {
-        "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x"
+        "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x"
       }
     },
     "node_modules/typedoc/node_modules/brace-expansion": {
@@ -7922,9 +7922,9 @@
       }
     },
     "@eslint/js": {
-      "version": "8.47.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.47.0.tgz",
-      "integrity": "sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==",
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz",
+      "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==",
       "dev": true
     },
     "@humanwhocodes/config-array": {
@@ -9648,15 +9648,15 @@
       }
     },
     "eslint": {
-      "version": "8.47.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz",
-      "integrity": "sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==",
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz",
+      "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==",
       "dev": true,
       "requires": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "^8.47.0",
+        "@eslint/js": "8.48.0",
         "@humanwhocodes/config-array": "^0.11.10",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
@@ -9822,9 +9822,9 @@
       }
     },
     "eslint-plugin-testing-library": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-6.0.0.tgz",
-      "integrity": "sha512-SHeisSxG1f6UV2wKdMuQBiTTmGEQ858H7LMyz1LG+30TxPQEcXXQauC4E6sfEdcrJ/nIOUxNmlFZdlL6PNK4mg==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-6.0.1.tgz",
+      "integrity": "sha512-CEYtjpcF3hAaQtYsTZqciR7s5z+T0LCMTwJeW+pz6kBnGtc866wAKmhaiK2Gsjc2jWNP7Gt6zhNr2DE1ZW4e+g==",
       "dev": true,
       "requires": {
         "@typescript-eslint/utils": "^5.58.0"
@@ -12114,14 +12114,14 @@
       "dev": true
     },
     "typedoc": {
-      "version": "0.24.8",
-      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.8.tgz",
-      "integrity": "sha512-ahJ6Cpcvxwaxfu4KtjA8qZNqS43wYt6JL27wYiIgl1vd38WW/KWX11YuAeZhuz9v+ttrutSsgK+XO1CjL1kA3w==",
+      "version": "0.25.0",
+      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.0.tgz",
+      "integrity": "sha512-FvCYWhO1n5jACE0C32qg6b3dSfQ8f2VzExnnRboowHtqUD6ARzM2r8YJeZFYXhcm2hI4C2oCRDgNPk/yaQUN9g==",
       "dev": true,
       "requires": {
         "lunr": "^2.3.9",
         "marked": "^4.3.0",
-        "minimatch": "^9.0.0",
+        "minimatch": "^9.0.3",
         "shiki": "^0.14.1"
       },
       "dependencies": {
diff --git a/package.json b/package.json
index 7e923425..b1c88f39 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
     "jest-mock": "^29.3.1",
     "lint-staged": "^14.0.0",
     "ts-jest": "^29.0.3",
-    "typedoc": "^0.24.1",
+    "typedoc": "^0.25.0",
     "typescript": "~5.0.4",
     "yn": "^5.0.0"
   },
diff --git a/src/UserManager.ts b/src/UserManager.ts
index 810c6f8c..35ab80cc 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -3,8 +3,7 @@
 
 import { Logger } from "./utils";
 import { ErrorResponse } from "./errors";
-import { IFrameNavigator, type NavigateResponse, PopupNavigator, RedirectNavigator, type PopupWindowParams,
-    type IWindow, type IFrameWindowParams, type RedirectParams } from "./navigators";
+import { type NavigateResponse, type PopupWindowParams, type IWindow, type IFrameWindowParams, type RedirectParams, RedirectNavigator, PopupNavigator, IFrameNavigator, type INavigator } from "./navigators";
 import { OidcClient, type CreateSigninRequestArgs, type CreateSignoutRequestArgs, type ProcessResourceOwnerPasswordCredentialsArgs } from "./OidcClient";
 import { type UserManagerSettings, UserManagerSettingsStore } from "./UserManagerSettings";
 import { User } from "./User";
@@ -83,21 +82,21 @@ export class UserManager {
     protected readonly _logger = new Logger("UserManager");
 
     protected readonly _client: OidcClient;
-    protected readonly _redirectNavigator: RedirectNavigator;
-    protected readonly _popupNavigator: PopupNavigator;
-    protected readonly _iframeNavigator: IFrameNavigator;
+    protected readonly _redirectNavigator: INavigator;
+    protected readonly _popupNavigator: INavigator;
+    protected readonly _iframeNavigator: INavigator;
     protected readonly _events: UserManagerEvents;
     protected readonly _silentRenewService: SilentRenewService;
     protected readonly _sessionMonitor: SessionMonitor | null;
 
-    public constructor(settings: UserManagerSettings) {
+    public constructor(settings: UserManagerSettings, redirectNavigator?: INavigator, popupNavigator?: INavigator, iframeNavigator?: INavigator) {
         this.settings = new UserManagerSettingsStore(settings);
 
         this._client = new OidcClient(settings);
 
-        this._redirectNavigator = new RedirectNavigator(this.settings);
-        this._popupNavigator = new PopupNavigator(this.settings);
-        this._iframeNavigator = new IFrameNavigator(this.settings);
+        this._redirectNavigator = redirectNavigator ?? new RedirectNavigator(this.settings);
+        this._popupNavigator = popupNavigator ?? new PopupNavigator(this.settings);
+        this._iframeNavigator = iframeNavigator ?? new IFrameNavigator(this.settings);
 
         this._events = new UserManagerEvents(this.settings);
         this._silentRenewService = new SilentRenewService(this);
@@ -244,7 +243,7 @@ export class UserManager {
      */
     public async signinPopupCallback(url = window.location.href, keepOpen = false): Promise<void> {
         const logger = this._logger.create("signinPopupCallback");
-        await this._popupNavigator.callback(url, keepOpen);
+        await this._popupNavigator.callback(url, { keepOpen });
         logger.info("success");
     }
 
@@ -525,7 +524,7 @@ export class UserManager {
      */
     public async signoutPopupCallback(url = window.location.href, keepOpen = false): Promise<void> {
         const logger = this._logger.create("signoutPopupCallback");
-        await this._popupNavigator.callback(url, keepOpen);
+        await this._popupNavigator.callback(url, { keepOpen });
         logger.info("success");
     }
 
diff --git a/src/UserManagerSettings.ts b/src/UserManagerSettings.ts
index e2c9045a..cf6ae366 100644
--- a/src/UserManagerSettings.ts
+++ b/src/UserManagerSettings.ts
@@ -10,6 +10,7 @@ export const DefaultPopupWindowFeatures: PopupWindowFeatures = {
     location: false,
     toolbar: false,
     height: 640,
+    closePopupWindowAfterInSeconds: -1,
 };
 export const DefaultPopupTarget = "_blank";
 const DefaultAccessTokenExpiringNotificationTimeInSeconds = 60;
@@ -28,7 +29,7 @@ export interface UserManagerSettings extends OidcClientSettings {
     /**
      * The features parameter to window.open for the popup signin window. By default, the popup is
      * placed centered in front of the window opener.
-     * (default: \{ location: false, menubar: false, height: 640 \})
+     * (default: \{ location: false, menubar: false, height: 640, closePopupWindowAfterInSeconds: -1 \})
      */
     popupWindowFeatures?: PopupWindowFeatures;
     /** The target parameter to window.open for the popup signin window (default: "_blank") */
diff --git a/src/index.ts b/src/index.ts
index e69e2d55..ffafeb36 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 
 export { ErrorResponse, ErrorTimeout } from "./errors";
-export type { IFrameWindowParams, PopupWindowParams, RedirectParams } from "./navigators";
+export type { INavigator, IFrameWindowParams, IWindow, NavigateParams, NavigateResponse, PopupWindowParams, RedirectParams } from "./navigators";
 export { Log, Logger } from "./utils";
 export type { ILogger, PopupWindowFeatures } from "./utils";
 export type { OidcAddressClaim, OidcStandardClaims, IdTokenClaims, JwtClaims } from "./Claims";
diff --git a/src/navigators/INavigator.ts b/src/navigators/INavigator.ts
index 09b24827..b99696dd 100644
--- a/src/navigators/INavigator.ts
+++ b/src/navigators/INavigator.ts
@@ -4,8 +4,10 @@
 import type { IWindow } from "./IWindow";
 
 /**
- * @internal
+ * @public
  */
 export interface INavigator {
     prepare(params: unknown): Promise<IWindow>;
+
+    callback(url: string, params?: unknown): Promise<void>;
 }
diff --git a/src/navigators/IWindow.ts b/src/navigators/IWindow.ts
index 0975aa63..5792205c 100644
--- a/src/navigators/IWindow.ts
+++ b/src/navigators/IWindow.ts
@@ -1,7 +1,7 @@
 // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 /**
- * @internal
+ * @public
  */
 export interface NavigateParams {
     url: string;
@@ -14,14 +14,14 @@ export interface NavigateParams {
 }
 
 /**
- * @internal
+ * @public
  */
 export interface NavigateResponse {
     url: string;
 }
 
 /**
- * @internal
+ * @public
  */
 export interface IWindow {
     navigate(params: NavigateParams): Promise<NavigateResponse>;
diff --git a/src/navigators/PopupNavigator.ts b/src/navigators/PopupNavigator.ts
index b2666ec0..11c80fce 100644
--- a/src/navigators/PopupNavigator.ts
+++ b/src/navigators/PopupNavigator.ts
@@ -21,7 +21,7 @@ export class PopupNavigator implements INavigator {
         return new PopupWindow({ popupWindowFeatures, popupWindowTarget });
     }
 
-    public async callback(url: string, keepOpen = false): Promise<void> {
+    public async callback(url: string, { keepOpen = false }): Promise<void> {
         this._logger.create("callback");
 
         PopupWindow.notifyOpener(url, keepOpen);
diff --git a/src/navigators/PopupWindow.ts b/src/navigators/PopupWindow.ts
index f79dd786..38b08d6b 100644
--- a/src/navigators/PopupWindow.ts
+++ b/src/navigators/PopupWindow.ts
@@ -7,6 +7,7 @@ import { AbstractChildWindow } from "./AbstractChildWindow";
 import type { NavigateParams, NavigateResponse } from "./IWindow";
 
 const checkForPopupClosedInterval = 500;
+const second = 1000;
 
 /**
  * @public
@@ -31,6 +32,16 @@ export class PopupWindow extends AbstractChildWindow {
         super();
         const centeredPopup = PopupUtils.center({ ...DefaultPopupWindowFeatures, ...popupWindowFeatures });
         this._window = window.open(undefined, popupWindowTarget, PopupUtils.serialize(centeredPopup));
+        if (popupWindowFeatures.closePopupWindowAfterInSeconds && popupWindowFeatures.closePopupWindowAfterInSeconds > 0) {
+            setTimeout(() => { 
+                if (!this._window || typeof this._window.closed !== "boolean" || this._window.closed) {
+                    this._abort.raise(new Error("Popup blocked by user"));
+                    return;
+                }
+
+                this.close();
+            }, popupWindowFeatures.closePopupWindowAfterInSeconds * second);
+        }
     }
 
     public async navigate(params: NavigateParams): Promise<NavigateResponse> {
diff --git a/src/navigators/RedirectNavigator.ts b/src/navigators/RedirectNavigator.ts
index 2c16b9ed..e5cb2757 100644
--- a/src/navigators/RedirectNavigator.ts
+++ b/src/navigators/RedirectNavigator.ts
@@ -52,4 +52,8 @@ export class RedirectNavigator implements INavigator {
             },
         };
     }
+
+    public async callback(): Promise<void> {
+        return;
+    }
 }
diff --git a/src/utils/PopupUtils.ts b/src/utils/PopupUtils.ts
index fcd2335a..e1123ad8 100644
--- a/src/utils/PopupUtils.ts
+++ b/src/utils/PopupUtils.ts
@@ -14,6 +14,8 @@ export interface PopupWindowFeatures {
     status?: boolean | string;
     resizable?: boolean | string;
     scrollbars?: boolean | string;
+    /** Close popup window after time in seconds, by default it is -1. To enable this feature set value greater than 0 */
+    closePopupWindowAfterInSeconds?: number;
 
     [k: string]: boolean | string | number | undefined;
 }


Test patch:
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 25fe9646..30ef5c5c 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -2,7 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 
 import { once } from "events";
-import type { PopupWindow } from "./navigators";
+import { RedirectNavigator, type PopupWindow, PopupNavigator, IFrameNavigator } from "./navigators";
 import type { SigninResponse } from "./SigninResponse";
 import type { SignoutResponse } from "./SignoutResponse";
 import { UserManager, type SigninPopupArgs, type SigninRedirectArgs, type SigninSilentArgs, type SignoutSilentArgs } from "./UserManager";
@@ -59,6 +59,30 @@ describe("UserManager", () => {
                 expect(sessionMonitor).toBeNull();
             }
         });
+
+        it("should accept redirectNavigator", () => {
+            const customRedirectNavigator = new RedirectNavigator(subject.settings);
+
+            const userManager = new UserManager(subject.settings, customRedirectNavigator);
+
+            expect(userManager["_redirectNavigator"]).toBe(customRedirectNavigator);
+        });
+
+        it("should accept popupNavigator", () => {
+            const customPopupNavigator = new PopupNavigator(subject.settings);
+
+            const userManager = new UserManager(subject.settings, undefined, customPopupNavigator);
+
+            expect(userManager["_popupNavigator"]).toBe(customPopupNavigator);
+        });
+
+        it("should accept iframeNavigator", () => {
+            const customiframeNavigator = new IFrameNavigator(subject.settings);
+
+            const userManager = new UserManager(subject.settings, undefined, undefined, customiframeNavigator);
+
+            expect(userManager["_iframeNavigator"]).toBe(customiframeNavigator);
+        });
     });
 
     describe("settings", () => {
@@ -248,11 +272,6 @@ describe("UserManager", () => {
             // arrange
             const spy = jest.spyOn(subject["_client"], "processSigninResponse")
                 .mockResolvedValue({} as SigninResponse);
-            await userStoreMock.set("test", JSON.stringify({
-                id: "test",
-                request_type: "si:r",
-                ...subject.settings,
-            }));
 
             // act
             const user = await subject.signinRedirectCallback("http://app/cb?state=test&code=code");
@@ -375,7 +394,7 @@ describe("UserManager", () => {
             await subject.signinPopupCallback(url, keepOpen);
 
             // assert
-            expect(callbackMock).toBeCalledWith(url, keepOpen);
+            expect(callbackMock).toBeCalledWith(url, { keepOpen });
         });
     });
 
diff --git a/src/UserManagerSettings.test.ts b/src/UserManagerSettings.test.ts
index 310dc3c2..e1e3cb39 100644
--- a/src/UserManagerSettings.test.ts
+++ b/src/UserManagerSettings.test.ts
@@ -53,6 +53,19 @@ describe("UserManagerSettings", () => {
             expect(subject.popupWindowFeatures).toEqual({ status: true });
         });
 
+        it("should set closePopupWindowAfterInMilliseconds", () => {
+            // act
+            const closePopupWindowAfterInSeconds = 100;
+            const subject = new UserManagerSettingsStore({
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+                popupWindowFeatures: { status: true, closePopupWindowAfterInSeconds },
+            });
+
+            // assert
+            expect(subject.popupWindowFeatures).toEqual({ status: true, closePopupWindowAfterInSeconds });
+        });
     });
 
     describe("popupWindowTarget", () => {
diff --git a/src/navigators/PopupWindow.test.ts b/src/navigators/PopupWindow.test.ts
index 32211b80..f010d3b0 100644
--- a/src/navigators/PopupWindow.test.ts
+++ b/src/navigators/PopupWindow.test.ts
@@ -144,4 +144,34 @@ describe("PopupWindow", () => {
             keepOpen: false,
         }, window.location.origin);
     });
+
+    it("should close the window after closePopupWindowAfter is greater than 0", async () => {
+        jest.spyOn(global, "setTimeout");
+        
+        new PopupWindow({ popupWindowFeatures: { closePopupWindowAfterInSeconds: 1 } });
+
+        jest.runOnlyPendingTimers();
+        expect(setTimeout).toHaveBeenCalledTimes(1);
+        jest.runAllTimers();
+    });
+
+    it("shouldnt close the window after closePopupWindowAfter is equal to 0", async () => {
+        jest.spyOn(global, "setTimeout");
+        
+        new PopupWindow({ popupWindowFeatures: { closePopupWindowAfterInSeconds: 0 } });
+
+        jest.runOnlyPendingTimers();
+        expect(setTimeout).toHaveBeenCalledTimes(0);
+        jest.runAllTimers();
+    });
+
+    it("shouldnt close the window after closePopupWindowAfter is less than 0", async () => {
+        jest.spyOn(global, "setTimeout");
+        
+        new PopupWindow({ popupWindowFeatures: { closePopupWindowAfterInSeconds: -120 } });
+
+        jest.runOnlyPendingTimers();
+        expect(setTimeout).toHaveBeenCalledTimes(0);
+        jest.runAllTimers();
+    });
 });