Repository: authts/oidc-client-ts

Issue:
feat: client_secret_jwt support
As a user, I wish to be able to use oidc-client-ts with client_secret_jwt client authentication without having to add specific handling out of the base package.

Discussion:

Solution patch:
diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md
index a06ec3a..1d609dd 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -384,7 +384,7 @@ export class OidcClient {
 export interface OidcClientSettings {
     acr_values?: string;
     authority: string;
-    client_authentication?: "client_secret_basic" | "client_secret_post";
+    client_authentication?: "client_secret_basic" | "client_secret_post" | "client_secret_jwt";
     client_id: string;
     // (undocumented)
     client_secret?: string;
@@ -421,18 +421,19 @@ export interface OidcClientSettings {
     signingKeys?: SigningKey[];
     staleStateAgeInSeconds?: number;
     stateStore?: StateStore;
+    token_endpoint_auth_signing_alg?: "HS256" | "HS384" | "HS512";
     ui_locales?: string;
 }
 
 // @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, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, dpop, omitScopeWhenRequesting, }: OidcClientSettings);
+    constructor({ authority, metadataUrl, metadata, signingKeys, metadataSeed, client_id, client_secret, response_type, scope, redirect_uri, post_logout_redirect_uri, client_authentication, token_endpoint_auth_signing_alg, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, filterProtocolClaims, loadUserInfo, requestTimeoutInSeconds, staleStateAgeInSeconds, mergeClaimsStrategy, disablePKCE, stateStore, revokeTokenAdditionalContentTypes, fetchRequestCredentials, refreshTokenAllowedScope, extraQueryParams, extraTokenParams, extraHeaders, dpop, omitScopeWhenRequesting, }: OidcClientSettings);
     // (undocumented)
     readonly acr_values: string | undefined;
     // (undocumented)
     readonly authority: string;
     // (undocumented)
-    readonly client_authentication: "client_secret_basic" | "client_secret_post";
+    readonly client_authentication: "client_secret_basic" | "client_secret_post" | "client_secret_jwt";
     // (undocumented)
     readonly client_id: string;
     // (undocumented)
@@ -496,6 +497,8 @@ export class OidcClientSettingsStore {
     // (undocumented)
     readonly stateStore: StateStore;
     // (undocumented)
+    readonly token_endpoint_auth_signing_alg: "HS256" | "HS384" | "HS512";
+    // (undocumented)
     readonly ui_locales: string | undefined;
 }
 
diff --git a/src/OidcClientSettings.ts b/src/OidcClientSettings.ts
index 567650c..c1d45c2 100644
--- a/src/OidcClientSettings.ts
+++ b/src/OidcClientSettings.ts
@@ -63,10 +63,17 @@ export interface OidcClientSettings {
      * Client authentication method that is used to authenticate when using the token endpoint (default: "client_secret_post")
      * - "client_secret_basic": using the HTTP Basic authentication scheme
      * - "client_secret_post": including the client credentials in the request body
+     * - "client_secret_jwt": using a JWT signed with the client secret
      *
      * See https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
      */
-    client_authentication?: "client_secret_basic" | "client_secret_post";
+    client_authentication?: "client_secret_basic" | "client_secret_post" | "client_secret_jwt";
+
+    /**
+     * The signature algorithm to use for client_secret_jwt authentication (default: "HS256")
+     * Supported algorithms: HS256, HS384, HS512
+     */
+    token_endpoint_auth_signing_alg?: "HS256" | "HS384" | "HS512";
 
     /** optional protocol param */
     prompt?: string;
@@ -186,7 +193,8 @@ export class OidcClientSettingsStore {
     public readonly scope: string;
     public readonly redirect_uri: string;
     public readonly post_logout_redirect_uri: string | undefined;
-    public readonly client_authentication: "client_secret_basic" | "client_secret_post";
+    public readonly client_authentication: "client_secret_basic" | "client_secret_post" | "client_secret_jwt";
+    public readonly token_endpoint_auth_signing_alg: "HS256" | "HS384" | "HS512";
 
     // optional protocol params
     public readonly prompt: string | undefined;
@@ -225,6 +233,7 @@ export class OidcClientSettingsStore {
         client_id, client_secret, response_type = DefaultResponseType, scope = DefaultScope,
         redirect_uri, post_logout_redirect_uri,
         client_authentication = DefaultClientAuthentication,
+        token_endpoint_auth_signing_alg = "HS256",
         // optional protocol
         prompt, display, max_age, ui_locales, acr_values, resource, response_mode,
         // behavior flags
@@ -272,6 +281,7 @@ export class OidcClientSettingsStore {
         this.redirect_uri = redirect_uri;
         this.post_logout_redirect_uri = post_logout_redirect_uri;
         this.client_authentication = client_authentication;
+        this.token_endpoint_auth_signing_alg = token_endpoint_auth_signing_alg;
 
         this.prompt = prompt;
         this.display = display;
diff --git a/src/TokenClient.ts b/src/TokenClient.ts
index cfd908a..a9fbe3c 100644
--- a/src/TokenClient.ts
+++ b/src/TokenClient.ts
@@ -109,15 +109,21 @@ export class TokenClient {
                 params.set(key, value);
             }
         }
+
+        // Validate client_secret if required before making any external calls
+        if ((this._settings.client_authentication === "client_secret_basic" ||
+             this._settings.client_authentication === "client_secret_jwt") &&
+            (client_secret === undefined || client_secret === null)) {
+            logger.throw(new Error("A client_secret is required"));
+            // eslint-disable-next-line @typescript-eslint/only-throw-error
+            throw null; // https://github.com/microsoft/TypeScript/issues/46972
+        }
+
         let basicAuth: string | undefined;
+        const url = await this._metadataService.getTokenEndpoint(false);
         switch (this._settings.client_authentication) {
             case "client_secret_basic":
-                if (client_secret === undefined || client_secret === null) {
-                    logger.throw(new Error("A client_secret is required"));
-                    // eslint-disable-next-line @typescript-eslint/only-throw-error
-                    throw null; // https://github.com/microsoft/TypeScript/issues/46972
-                }
-                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret);
+                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret!);
                 break;
             case "client_secret_post":
                 params.append("client_id", client_id);
@@ -125,9 +131,14 @@ export class TokenClient {
                     params.append("client_secret", client_secret);
                 }
                 break;
+            case "client_secret_jwt": {
+                const clientAssertion = await CryptoUtils.generateClientAssertionJwt(client_id, client_secret!, url, this._settings.token_endpoint_auth_signing_alg);
+                params.append("client_id", client_id);
+                params.append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+                params.append("client_assertion", clientAssertion);
+                break;
+            }
         }
-
-        const url = await this._metadataService.getTokenEndpoint(false);
         logger.debug("got token endpoint");
 
         const response = await this._jsonService.postForm(url, {
@@ -171,15 +182,20 @@ export class TokenClient {
             }
         }
 
+        // Validate client_secret if required before making any external calls
+        if ((this._settings.client_authentication === "client_secret_basic" ||
+             this._settings.client_authentication === "client_secret_jwt") &&
+            (client_secret === undefined || client_secret === null)) {
+            logger.throw(new Error("A client_secret is required"));
+            // eslint-disable-next-line @typescript-eslint/only-throw-error
+            throw null; // https://github.com/microsoft/TypeScript/issues/46972
+        }
+
         let basicAuth: string | undefined;
+        const url = await this._metadataService.getTokenEndpoint(false);
         switch (this._settings.client_authentication) {
             case "client_secret_basic":
-                if (client_secret === undefined || client_secret === null) {
-                    logger.throw(new Error("A client_secret is required"));
-                    // eslint-disable-next-line @typescript-eslint/only-throw-error
-                    throw null; // https://github.com/microsoft/TypeScript/issues/46972
-                }
-                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret);
+                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret!);
                 break;
             case "client_secret_post":
                 params.append("client_id", client_id);
@@ -187,9 +203,14 @@ export class TokenClient {
                     params.append("client_secret", client_secret);
                 }
                 break;
+            case "client_secret_jwt": {
+                const clientAssertion = await CryptoUtils.generateClientAssertionJwt(client_id, client_secret!, url, this._settings.token_endpoint_auth_signing_alg);
+                params.append("client_id", client_id);
+                params.append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+                params.append("client_assertion", clientAssertion);
+                break;
+            }
         }
-
-        const url = await this._metadataService.getTokenEndpoint(false);
         logger.debug("got token endpoint");
 
         const response = await this._jsonService.postForm(url, { body: params, basicAuth, timeoutInSeconds: this._settings.requestTimeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials });
@@ -228,15 +249,21 @@ export class TokenClient {
                 params.set(key, value);
             }
         }
+
+        // Validate client_secret if required before making any external calls
+        if ((this._settings.client_authentication === "client_secret_basic" ||
+             this._settings.client_authentication === "client_secret_jwt") &&
+            (client_secret === undefined || client_secret === null)) {
+            logger.throw(new Error("A client_secret is required"));
+            // eslint-disable-next-line @typescript-eslint/only-throw-error
+            throw null; // https://github.com/microsoft/TypeScript/issues/46972
+        }
+
         let basicAuth: string | undefined;
+        const url = await this._metadataService.getTokenEndpoint(false);
         switch (this._settings.client_authentication) {
             case "client_secret_basic":
-                if (client_secret === undefined || client_secret === null) {
-                    logger.throw(new Error("A client_secret is required"));
-                    // eslint-disable-next-line @typescript-eslint/only-throw-error
-                    throw null; // https://github.com/microsoft/TypeScript/issues/46972
-                }
-                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret);
+                basicAuth = CryptoUtils.generateBasicAuth(client_id, client_secret!);
                 break;
             case "client_secret_post":
                 params.append("client_id", client_id);
@@ -244,9 +271,14 @@ export class TokenClient {
                     params.append("client_secret", client_secret);
                 }
                 break;
+            case "client_secret_jwt": {
+                const clientAssertion = await CryptoUtils.generateClientAssertionJwt(client_id, client_secret!, url, this._settings.token_endpoint_auth_signing_alg);
+                params.append("client_id", client_id);
+                params.append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+                params.append("client_assertion", clientAssertion);
+                break;
+            }
         }
-
-        const url = await this._metadataService.getTokenEndpoint(false);
         logger.debug("got token endpoint");
 
         const response = await this._jsonService.postForm(url, { body: params, basicAuth, timeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials, extraHeaders });
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 2ce62a6..bd1415d 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -1,7 +1,6 @@
 // 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.
 
-import { once } from "events";
 import {
     RedirectNavigator,
     type PopupWindow,
@@ -325,13 +324,7 @@ describe("UserManager", () => {
     describe("signinRedirect", () => {
         it("should redirect the browser to the authorize url", async () => {
             // act
-            const spy = jest.fn();
-            void subject.signinRedirect().finally(spy);
-
-            // signinRedirect is a promise that will never resolve (since we
-            // want it to hold until the page has redirected), so we wait for
-            // the browser unload event before checking the test assertions.
-            await once(window, "unload");
+            await subject.signinRedirect();
 
             // assert
             expect(window.location.assign).toHaveBeenCalledWith(
@@ -341,9 +334,6 @@ describe("UserManager", () => {
             const state = new URL(location).searchParams.get("state");
             const item = await userStoreMock.get(state!);
             expect(JSON.parse(item!)).toHaveProperty("request_type", "si:r");
-
-            // We check to make sure the promise has not resolved
-            expect(spy).not.toHaveBeenCalled();
         });
 
         it("should pass navigator params to navigator", async () => {
diff --git a/src/navigators/RedirectNavigator.test.ts b/src/navigators/RedirectNavigator.test.ts
index e00be84..3804963 100644
--- a/src/navigators/RedirectNavigator.test.ts
+++ b/src/navigators/RedirectNavigator.test.ts
@@ -1,5 +1,4 @@
 import { mocked } from "jest-mock";
-import { once } from "events";
 import type { UserManagerSettingsStore } from "../UserManagerSettings";
 import { RedirectNavigator } from "./RedirectNavigator";
 
@@ -20,15 +19,9 @@ describe("RedirectNavigator", () => {
 
     it("should redirect to the authority server using a specific redirect method", async () => {
         const handle = await navigator.prepare({ redirectMethod: "replace" });
-        const spy = jest.fn();
-        void handle.navigate({ url: "http://sts/authorize" }).finally(spy);
+        await handle.navigate({ url: "http://sts/authorize" });
 
         expect(window.location.replace).toHaveBeenCalledWith("http://sts/authorize");
-
-        // We check that the promise does not resolve even after the window
-        // unload event
-        await once(window, "unload");
-        expect(spy).not.toHaveBeenCalled();
     });
 
     it("should redirect to the authority server from window top", async () => {
@@ -48,11 +41,6 @@ describe("RedirectNavigator", () => {
         expect(window.location.assign).toHaveBeenCalledTimes(0);
         expect(window.parent.location.assign).toHaveBeenCalledTimes(0);
         expect(window.top!.location.assign).toHaveBeenCalledWith("http://sts/authorize");
-
-        // We check that the promise does not resolve even after the window
-        // unload event
-        await once(window, "unload");
-        expect(spy).not.toHaveBeenCalled();
     });
 
     it("should reject when the navigation is stopped programmatically", async () => {
diff --git a/src/navigators/RedirectNavigator.ts b/src/navigators/RedirectNavigator.ts
index e5cb275..1dde03a 100644
--- a/src/navigators/RedirectNavigator.ts
+++ b/src/navigators/RedirectNavigator.ts
@@ -4,7 +4,7 @@
 import { Logger } from "../utils";
 import type { UserManagerSettingsStore } from "../UserManagerSettings";
 import type { INavigator } from "./INavigator";
-import type { IWindow } from "./IWindow";
+import type { IWindow, NavigateResponse } from "./IWindow";
 
 /**
  * @public
@@ -33,17 +33,17 @@ export class RedirectNavigator implements INavigator {
             targetWindow = window.top ?? window.self;
         }
     
-        const redirect = targetWindow.location[redirectMethod].bind(targetWindow.location) as (url: string) => never;
+        const redirect = targetWindow.location[redirectMethod].bind(targetWindow.location) as (url: string) => void;
         let abort: (reason: Error) => void;
         return {
-            navigate: async (params): Promise<never> => {
+            navigate: async (params): Promise<NavigateResponse> => {
                 this._logger.create("navigate");
-                // We use a promise that never resolves to block the caller
                 const promise = new Promise((resolve, reject) => {
                     abort = reject;
+                    window.addEventListener("pageshow", () => resolve(window.location.href));
+                    redirect(params.url);
                 });
-                redirect(params.url);
-                return await (promise as Promise<never>);
+                return await (promise as Promise<NavigateResponse>);
             },
             close: () => {
                 this._logger.create("close");
diff --git a/src/utils/CryptoUtils.ts b/src/utils/CryptoUtils.ts
index d973d0d..3ef0989 100644
--- a/src/utils/CryptoUtils.ts
+++ b/src/utils/CryptoUtils.ts
@@ -11,7 +11,7 @@ export interface GenerateDPoPProofOpts {
 
 const UUID_V4_TEMPLATE = "10000000-1000-4000-8000-100000000000";
 
-const toBase64 = (val: ArrayBuffer): string =>
+const toBase64 = (val: ArrayBuffer | Uint8Array): string =>
     btoa([...new Uint8Array(val)]
         .map((chr) => String.fromCharCode(chr))
         .join(""));
@@ -204,4 +204,51 @@ export class CryptoUtils {
             ["sign", "verify"],
         );
     }
+
+    /**
+     * Generates a client assertion JWT for client_secret_jwt authentication
+     * @param client_id The client identifier
+     * @param client_secret The client secret
+     * @param audience The token endpoint URL (audience)
+     * @param algorithm The HMAC algorithm to use (HS256, HS384, HS512). Defaults to HS256
+     */
+    public static async generateClientAssertionJwt(client_id: string, client_secret: string, audience: string, algorithm: string = "HS256"): Promise<string> {
+        const now = Math.floor(Date.now() / 1000);
+
+        const header = {
+            "alg": algorithm,
+            "typ": "JWT",
+        };
+
+        const payload = {
+            "iss": client_id,
+            "sub": client_id,
+            "aud": audience,
+            "jti": CryptoUtils.generateUUIDv4(),
+            "exp": now + 300, // 5 minutes
+            "iat": now,
+        };
+
+        const hashMap: Record<string, string> = {
+            "HS256": "SHA-256",
+            "HS384": "SHA-384",
+            "HS512": "SHA-512",
+        };
+
+        const hashFunction = hashMap[algorithm];
+        if (!hashFunction) {
+            throw new Error(`Unsupported algorithm: ${algorithm}. Supported algorithms are: HS256, HS384, HS512`);
+        }
+
+        const encoder = new TextEncoder();
+        const secretKey = await crypto.subtle.importKey(
+            "raw",
+            encoder.encode(client_secret),
+            { name: "HMAC", hash: hashFunction },
+            false,
+            ["sign"],
+        );
+
+        return await JwtUtils.generateSignedJwtWithHmac(header, payload, secretKey);
+    }
 }
diff --git a/src/utils/JwtUtils.ts b/src/utils/JwtUtils.ts
index ef75adc..24a8b83 100644
--- a/src/utils/JwtUtils.ts
+++ b/src/utils/JwtUtils.ts
@@ -36,4 +36,19 @@ export class JwtUtils {
         const encodedSignature = CryptoUtils.encodeBase64Url(new Uint8Array(signature));
         return `${encodedToken}.${encodedSignature}`;
     }
+
+    public static async generateSignedJwtWithHmac(header: object, payload: object, secretKey: CryptoKey): Promise<string> {
+        const encodedHeader = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(header)));
+        const encodedPayload = CryptoUtils.encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
+        const encodedToken = `${encodedHeader}.${encodedPayload}`;
+
+        const signature = await window.crypto.subtle.sign(
+            "HMAC",
+            secretKey,
+            new TextEncoder().encode(encodedToken),
+        );
+
+        const encodedSignature = CryptoUtils.encodeBase64Url(new Uint8Array(signature));
+        return `${encodedToken}.${encodedSignature}`;
+    }
 }



Test patch:
diff --git a/test/setup.ts b/test/setup.ts
index 26c27ef..72b837d 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -10,23 +10,19 @@ beforeAll(() => {
     });
     globalThis.fetch = jest.fn();
 
-    const unload = () =>
-        setTimeout(() => window.dispatchEvent(new Event("unload")), 200);
+    const pageshow = () => window.dispatchEvent(new Event("pageshow"));
 
-    const location = Object.defineProperties(
-        {},
-        {
-            ...Object.getOwnPropertyDescriptors(window.location),
-            assign: {
-                enumerable: true,
-                value: jest.fn(unload),
-            },
-            replace: {
-                enumerable: true,
-                value: jest.fn(unload),
-            },
+    const location = Object.defineProperties({}, {
+        ...Object.getOwnPropertyDescriptors(window.location),
+        assign: {
+            enumerable: true,
+            value: jest.fn(pageshow),
         },
-    );
+        replace: {
+            enumerable: true,
+            value: jest.fn(pageshow),
+        },
+    });
     Object.defineProperty(window, "location", {
         enumerable: true,
         get: () => location,