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

Issue:
Silent signout
Is there a reason for not including a `signoutSilent` function on the `UserManager`? We're implementing this ourselves using the url created by `OidcClient.createSignoutRequest`, but our implementation looks very similar to the existing `signinSilent` that uses the hidden iframe navigator.

Pull request:
Add silent signout functionality
Closes #761

### Checklist

- [X] This PR makes changes to the public API
- [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 e650c94f..6d07989f 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -800,6 +800,9 @@ export class SignoutResponse {
     userState: unknown;
 }
 
+// @public (undocumented)
+export type SignoutSilentArgs = IFrameWindowParams & ExtraSignoutRequestArgs;
+
 // @public (undocumented)
 export type SilentRenewErrorCallback = (error: Error) => Promise<void> | void;
 
@@ -950,6 +953,8 @@ export class UserManager {
     signoutPopupCallback(url?: string, keepOpen?: boolean): Promise<void>;
     signoutRedirect(args?: SignoutRedirectArgs): Promise<void>;
     signoutRedirectCallback(url?: string): Promise<SignoutResponse>;
+    signoutSilent(args?: SignoutSilentArgs): Promise<void>;
+    signoutSilentCallback(url?: string): Promise<void>;
     // (undocumented)
     protected _signoutStart(args: CreateSignoutRequestArgs | undefined, handle: IWindow): Promise<NavigateResponse>;
     // Warning: (ae-forgotten-export) The symbol "SilentRenewService" needs to be exported by the entry point index.d.ts
@@ -1005,6 +1010,7 @@ export interface UserManagerSettings extends OidcClientSettings {
     iframeNotifyParentOrigin?: string;
     iframeScriptOrigin?: string;
     includeIdTokenInSilentRenew?: boolean;
+    includeIdTokenInSilentSignout?: boolean;
     // (undocumented)
     monitorAnonymousSession?: boolean;
     monitorSession?: boolean;
@@ -1043,6 +1049,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
     // (undocumented)
     readonly includeIdTokenInSilentRenew: boolean;
     // (undocumented)
+    readonly includeIdTokenInSilentSignout: boolean;
+    // (undocumented)
     readonly monitorAnonymousSession: boolean;
     // (undocumented)
     readonly monitorSession: boolean;
diff --git a/src/UserManager.ts b/src/UserManager.ts
index dd5f56ed..d70ccb94 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -66,6 +66,11 @@ export type SignoutRedirectArgs = RedirectParams & ExtraSignoutRequestArgs;
  */
 export type SignoutPopupArgs = PopupWindowParams & ExtraSignoutRequestArgs;
 
+/**
+ * @public
+ */
+export type SignoutSilentArgs = IFrameWindowParams & ExtraSignoutRequestArgs;
+
 /**
  * Provides a higher level API for signing a user in, signing out, managing the user's claims returned from the OIDC provider,
  * and managing an access token returned from the OIDC/OAuth2 provider.
@@ -340,6 +345,9 @@ export class UserManager {
             case "so:p":
                 await this.signoutPopupCallback(url, keepOpen);
                 break;
+            case "so:s":
+                await this.signoutSilentCallback(url);
+                break;
             default:
                 throw new Error("invalid response_type in state");
         }
@@ -566,6 +574,41 @@ export class UserManager {
         return signoutResponse;
     }
 
+    /**
+     * Returns promise to trigger a silent request (via an iframe) to the end session endpoint.
+     */
+    public async signoutSilent(args: SignoutSilentArgs = {}): Promise<void> {
+        const logger = this._logger.create("signoutSilent");
+        const {
+            silentRequestTimeoutInSeconds,
+            ...requestArgs
+        } = args;
+
+        const id_token_hint = this.settings.includeIdTokenInSilentSignout
+            ? (await this._loadUser())?.id_token
+            : undefined;
+
+        const url = this.settings.popup_post_logout_redirect_uri;
+        const handle = await this._iframeNavigator.prepare({ silentRequestTimeoutInSeconds });
+        await this._signout({
+            request_type: "so:s",
+            post_logout_redirect_uri: url,
+            id_token_hint: id_token_hint,
+            ...requestArgs,
+        }, handle);
+
+        logger.info("success");
+    }
+
+    /**
+     * Returns promise to notify the parent window of response from the end session endpoint.
+     */
+    public async signoutSilentCallback(url = window.location.href): Promise<void> {
+        const logger = this._logger.create("signoutSilentCallback");
+        await this._iframeNavigator.callback(url);
+        logger.info("success");
+    }
+
     public async revokeTokens(types?: RevokeTokensTypes): Promise<void> {
         const user = await this._loadUser();
         await this._revokeInternal(user, types);
diff --git a/src/UserManagerSettings.ts b/src/UserManagerSettings.ts
index e289474d..61cdf162 100644
--- a/src/UserManagerSettings.ts
+++ b/src/UserManagerSettings.ts
@@ -71,6 +71,9 @@ export interface UserManagerSettings extends OidcClientSettings {
     revokeTokenTypes?: ("access_token" | "refresh_token")[];
     /** Will invoke the revocation endpoint on signout if there is an access token for the user (default: false) */
     revokeTokensOnSignout?: boolean;
+    /** Flag to control if id_token is included as id_token_hint in silent signout calls (default: false) */
+    includeIdTokenInSilentSignout?: boolean;
+
     /** The number of seconds before an access token is to expire to raise the accessTokenExpiring event (default: 60) */
     accessTokenExpiringNotificationTimeInSeconds?: number;
 
@@ -112,6 +115,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
 
     public readonly revokeTokenTypes: ("access_token" | "refresh_token")[];
     public readonly revokeTokensOnSignout: boolean;
+    public readonly includeIdTokenInSilentSignout: boolean;
+
     public readonly accessTokenExpiringNotificationTimeInSeconds: number;
 
     public readonly userStore: WebStorageStateStore;
@@ -142,6 +147,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
 
             revokeTokenTypes = ["access_token", "refresh_token"],
             revokeTokensOnSignout = false,
+            includeIdTokenInSilentSignout = false,
+
             accessTokenExpiringNotificationTimeInSeconds = DefaultAccessTokenExpiringNotificationTimeInSeconds,
 
             userStore,
@@ -173,6 +180,8 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
 
         this.revokeTokenTypes = revokeTokenTypes;
         this.revokeTokensOnSignout = revokeTokensOnSignout;
+        this.includeIdTokenInSilentSignout = includeIdTokenInSilentSignout;
+
         this.accessTokenExpiringNotificationTimeInSeconds = accessTokenExpiringNotificationTimeInSeconds;
 
         if (userStore) {


Test patch:
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 049083f5..111f0961 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -5,7 +5,7 @@ import { once } from "events";
 import type { PopupWindow } from "./navigators";
 import type { SigninResponse } from "./SigninResponse";
 import type { SignoutResponse } from "./SignoutResponse";
-import { UserManager, SigninPopupArgs, SigninRedirectArgs, SigninSilentArgs } from "./UserManager";
+import { UserManager, SigninPopupArgs, SigninRedirectArgs, SigninSilentArgs, SignoutSilentArgs } from "./UserManager";
 import { UserManagerSettingsStore } from "./UserManagerSettings";
 import { User } from "./User";
 import type { UserProfile } from "./User";
@@ -657,6 +657,132 @@ describe("UserManager", () => {
         });
     });
 
+    describe("signoutSilent", () => {
+        it("should pass silentRequestTimeout from settings", async () => {
+            // arrange
+            Object.assign(subject.settings, {
+                silentRequestTimeoutInSeconds: 123,
+                silent_redirect_uri: "http://client/silent_callback",
+            });
+            subject["_signout"] = jest.fn();
+
+            // act
+            await subject.signoutSilent();
+            const [, navInstance] = mocked(subject["_signout"]).mock.calls[0];
+
+            // assert
+            expect(navInstance).toHaveProperty("_timeoutInSeconds", 123);
+        });
+
+        it("should pass navigator params to navigator", async () => {
+            // arrange
+            const prepareMock = jest.spyOn(subject["_iframeNavigator"], "prepare");
+            subject["_signout"] = jest.fn();
+            const navParams: SignoutSilentArgs = {
+                silentRequestTimeoutInSeconds: 234,
+            };
+
+            // act
+            await subject.signoutSilent(navParams);
+
+            // assert
+            expect(prepareMock).toBeCalledWith(navParams);
+        });
+
+        it("should pass extra args to _signoutStart", async () => {
+            // arrange
+            jest.spyOn(subject["_popupNavigator"], "prepare");
+            subject["_signout"] = jest.fn();
+            const extraArgs: SignoutSilentArgs = {
+                extraQueryParams: { q : "q" },
+                state: "state",
+                post_logout_redirect_uri: "http://app/extra_callback",
+            };
+
+            // act
+            await subject.signoutSilent(extraArgs);
+
+            // assert
+            expect(subject["_signout"]).toBeCalledWith(
+                {
+                    request_type: "so:s",
+                    id_token_hint: undefined,
+                    ...extraArgs,
+                },
+                expect.objectContaining({
+                    close: expect.any(Function),
+                    navigate: expect.any(Function),
+                }),
+            );
+        });
+
+        it("should pass id_token as id_token_hint when user present and setting enabled", async () => {
+            // arrange
+            const user = new User({
+                id_token: "id_token",
+                access_token: "access_token",
+                token_type: "token_type",
+                profile: {} as UserProfile,
+            });
+            subject["_loadUser"] = jest.fn().mockResolvedValue(user);
+            Object.assign(subject.settings, {
+                includeIdTokenInSilentSignout: true,
+            });
+            subject["_signout"] = jest.fn().mockResolvedValue(user);
+
+            // act
+            await subject.signoutSilent();
+
+            // assert
+            expect(subject["_signout"]).toBeCalledWith(
+                expect.objectContaining({
+                    id_token_hint: "id_token",
+                }),
+                expect.anything(),
+            );
+        });
+
+        it("should not pass id_token as id_token_hint when user present but setting disabled", async () => {
+            // arrange
+            const user = new User({
+                id_token: "id_token",
+                access_token: "access_token",
+                token_type: "token_type",
+                profile: {} as UserProfile,
+            });
+            subject["_loadUser"] = jest.fn().mockResolvedValue(user);
+            Object.assign(subject.settings, {
+                includeIdTokenInSilentSignout: false,
+            });
+            subject["_signout"] = jest.fn().mockResolvedValue(user);
+
+            // act
+            await subject.signoutSilent();
+
+            // assert
+            expect(subject["_signout"]).toBeCalledWith(
+                expect.objectContaining({
+                    id_token_hint: undefined,
+                }),
+                expect.anything(),
+            );
+        });
+    });
+
+    describe("signoutSilentCallback", () => {
+        it("should call navigator callback", async () => {
+            // arrange
+            const callbackMock = jest.spyOn(subject["_iframeNavigator"], "callback");
+            const url = "http://app/cb?state=test&code=code";
+
+            // act
+            await subject.signoutSilentCallback(url);
+
+            // assert
+            expect(callbackMock).toBeCalledWith(url);
+        });
+    });
+
     describe("storeUser", () => {
         it("should add user to store", async () => {
             // arrange