Repository: authts/oidc-client-ts
Pull request #1457 (2024-04-02 06:54:30)

Add extra headers
This adds the extraHeaders plumbing that will be required to implement DPoP, re: #1361 as per [this request](https://github.com/authts/oidc-client-ts/pull/1361#issuecomment-2011905006).

Decided not to both adding extraHeaders to methods relating to the Resource Owner Password Credentials grant as it Is actively discouraged in the latest version of the [OAuth2 BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-resource-owner-password-cre).

### Checklist

- [ ] This PR makes changes to the public API <!-- was the API report (docs/oidc-client-ts.api.md) updated by this PR? -->
- [ ] 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 fc28ae36..0cdf486b 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -311,7 +311,7 @@ export class OidcClient {
     // (undocumented)
     processResourceOwnerPasswordCredentials({ username, password, skipUserInfo, extraTokenParams, }: ProcessResourceOwnerPasswordCredentialsArgs): Promise<SigninResponse>;
     // (undocumented)
-    processSigninResponse(url: string): Promise<SigninResponse>;
+    processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>): Promise<SigninResponse>;
     // (undocumented)
     processSignoutResponse(url: string): Promise<SignoutResponse>;
     // (undocumented)
@@ -333,7 +333,7 @@ export class OidcClient {
     // (undocumented)
     protected readonly _tokenClient: TokenClient;
     // (undocumented)
-    useRefreshToken({ state, redirect_uri, resource, timeoutInSeconds, extraTokenParams, }: UseRefreshTokenArgs): Promise<SigninResponse>;
+    useRefreshToken({ state, redirect_uri, resource, timeoutInSeconds, extraHeaders, extraTokenParams, }: UseRefreshTokenArgs): Promise<SigninResponse>;
     // Warning: (ae-forgotten-export) The symbol "ResponseValidator" needs to be exported by the entry point index.d.ts
     //
     // (undocumented)
@@ -924,6 +924,8 @@ export class User {
 
 // @public (undocumented)
 export interface UseRefreshTokenArgs {
+    // (undocumented)
+    extraHeaders?: Record<string, ExtraHeader>;
     // (undocumented)
     extraTokenParams?: Record<string, unknown>;
     // (undocumented)
@@ -1007,7 +1009,7 @@ export class UserManager {
     // (undocumented)
     storeUser(user: User | null): Promise<void>;
     // (undocumented)
-    protected _useRefreshToken(args: UseRefreshTokenArgs): Promise<User>;
+    protected _useRefreshToken(args: UseRefreshTokenArgs, extraHeaders?: Record<string, ExtraHeader>): Promise<User>;
     // (undocumented)
     protected get _userStoreKey(): string;
 }
diff --git a/src/JsonService.ts b/src/JsonService.ts
index 5d0f0f91..00b001c2 100644
--- a/src/JsonService.ts
+++ b/src/JsonService.ts
@@ -26,6 +26,7 @@ export interface PostFormOpts {
     basicAuth?: string;
     timeoutInSeconds?: number;
     initCredentials?: "same-origin" | "include" | "omit";
+    extraHeaders?: Record<string, ExtraHeader>;
 }
 
 /**
@@ -131,11 +132,13 @@ export class JsonService {
         basicAuth,
         timeoutInSeconds,
         initCredentials,
+        extraHeaders,
     }: PostFormOpts): Promise<Record<string, unknown>> {
         const logger = this._logger.create("postForm");
         const headers: HeadersInit = {
             "Accept": this._contentTypes.join(", "),
             "Content-Type": "application/x-www-form-urlencoded",
+            ...extraHeaders,
         };
         if (basicAuth !== undefined) {
             headers["Authorization"] = "Basic " + basicAuth;
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index 3ab42db5..1793ec2b 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -3,7 +3,7 @@
 
 import { Logger, UrlUtils } from "./utils";
 import { ErrorResponse } from "./errors";
-import { type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
+import { type ExtraHeader, type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
 import { ResponseValidator } from "./ResponseValidator";
 import { MetadataService } from "./MetadataService";
 import type { RefreshState } from "./RefreshState";
@@ -39,6 +39,8 @@ export interface UseRefreshTokenArgs {
     timeoutInSeconds?: number;
 
     state: RefreshState;
+
+    extraHeaders?: Record<string, ExtraHeader>;
 }
 
 /**
@@ -164,12 +166,12 @@ export class OidcClient {
         return { state, response };
     }
 
-    public async processSigninResponse(url: string): Promise<SigninResponse> {
+    public async processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>): Promise<SigninResponse> {
         const logger = this._logger.create("processSigninResponse");
 
         const { state, response } = await this.readSigninResponseState(url, true);
         logger.debug("received state from storage; validating response");
-        await this._validator.validateSigninResponse(response, state);
+        await this._validator.validateSigninResponse(response, state, extraHeaders);
         return response;
     }
 
@@ -191,6 +193,7 @@ export class OidcClient {
         redirect_uri,
         resource,
         timeoutInSeconds,
+        extraHeaders,
         extraTokenParams,
     }: UseRefreshTokenArgs): Promise<SigninResponse> {
         const logger = this._logger.create("useRefreshToken");
@@ -215,6 +218,7 @@ export class OidcClient {
             redirect_uri,
             resource,
             timeoutInSeconds,
+            extraHeaders,
             ...extraTokenParams,
         });
         const response = new SigninResponse(new URLSearchParams());
diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts
index 1c1f845e..f0077b31 100644
--- a/src/ResponseValidator.ts
+++ b/src/ResponseValidator.ts
@@ -6,7 +6,7 @@ import { ErrorResponse } from "./errors";
 import type { MetadataService } from "./MetadataService";
 import { UserInfoService } from "./UserInfoService";
 import { TokenClient } from "./TokenClient";
-import type { OidcClientSettingsStore } from "./OidcClientSettings";
+import type { ExtraHeader, OidcClientSettingsStore } from "./OidcClientSettings";
 import type { SigninState } from "./SigninState";
 import type { SigninResponse } from "./SigninResponse";
 import type { State } from "./State";
@@ -30,13 +30,13 @@ export class ResponseValidator {
         protected readonly _claimsService: ClaimsService,
     ) {}
 
-    public async validateSigninResponse(response: SigninResponse, state: SigninState): Promise<void> {
+    public async validateSigninResponse(response: SigninResponse, state: SigninState, extraHeaders?: Record<string, ExtraHeader>): Promise<void> {
         const logger = this._logger.create("validateSigninResponse");
 
         this._processSigninState(response, state);
         logger.debug("state processed");
 
-        await this._processCode(response, state);
+        await this._processCode(response, state, extraHeaders);
         logger.debug("code processed");
 
         if (response.isOpenId) {
@@ -169,7 +169,7 @@ export class ResponseValidator {
         logger.debug("user info claims received, updated profile:", response.profile);
     }
 
-    protected async _processCode(response: SigninResponse, state: SigninState): Promise<void> {
+    protected async _processCode(response: SigninResponse, state: SigninState, extraHeaders?: Record<string, ExtraHeader>): Promise<void> {
         const logger = this._logger.create("_processCode");
         if (response.code) {
             logger.debug("Validating code");
@@ -179,6 +179,7 @@ export class ResponseValidator {
                 code: response.code,
                 redirect_uri: state.redirect_uri,
                 code_verifier: state.code_verifier,
+                extraHeaders: extraHeaders,
                 ...state.extraTokenParams,
             });
             Object.assign(response, tokenResponse);
diff --git a/src/TokenClient.ts b/src/TokenClient.ts
index a56558a2..6df06b19 100644
--- a/src/TokenClient.ts
+++ b/src/TokenClient.ts
@@ -4,7 +4,7 @@
 import { CryptoUtils, Logger } from "./utils";
 import { JsonService } from "./JsonService";
 import type { MetadataService } from "./MetadataService";
-import type { OidcClientSettingsStore } from "./OidcClientSettings";
+import type { ExtraHeader, OidcClientSettingsStore } from "./OidcClientSettings";
 
 /**
  * @internal
@@ -17,6 +17,8 @@ export interface ExchangeCodeArgs {
     grant_type?: string;
     code: string;
     code_verifier?: string;
+
+    extraHeaders?: Record<string, ExtraHeader>;
 }
 
 /**
@@ -47,6 +49,8 @@ export interface ExchangeRefreshTokenArgs {
     resource?: string | string[];
 
     timeoutInSeconds?: number;
+
+    extraHeaders?: Record<string, ExtraHeader>;
 }
 
 /**
@@ -85,6 +89,7 @@ export class TokenClient {
         redirect_uri = this._settings.redirect_uri,
         client_id = this._settings.client_id,
         client_secret = this._settings.client_secret,
+        extraHeaders,
         ...args
     }: ExchangeCodeArgs): Promise<Record<string, unknown>> {
         const logger = this._logger.create("exchangeCode");
@@ -124,7 +129,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, initCredentials: this._settings.fetchRequestCredentials, extraHeaders });
         logger.debug("got response");
 
         return response;
@@ -191,6 +196,7 @@ export class TokenClient {
         client_id = this._settings.client_id,
         client_secret = this._settings.client_secret,
         timeoutInSeconds,
+        extraHeaders,
         ...args
     }: ExchangeRefreshTokenArgs): Promise<Record<string, unknown>> {
         const logger = this._logger.create("exchangeRefreshToken");
@@ -230,7 +236,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, timeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials });
+        const response = await this._jsonService.postForm(url, { body: params, basicAuth, timeoutInSeconds, initCredentials: this._settings.fetchRequestCredentials, extraHeaders });
         logger.debug("got response");
 
         return response;
diff --git a/src/UserManager.ts b/src/UserManager.ts
index be5b33ef..c683e1bf 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -15,6 +15,7 @@ import type { SignoutResponse } from "./SignoutResponse";
 import type { MetadataService } from "./MetadataService";
 import { RefreshState } from "./RefreshState";
 import type { SigninResponse } from "./SigninResponse";
+import type { ExtraHeader } from "./OidcClientSettings";
 
 /**
  * @public
@@ -329,10 +330,11 @@ export class UserManager {
         return user;
     }
 
-    protected async _useRefreshToken(args: UseRefreshTokenArgs): Promise<User> {
+    protected async _useRefreshToken(args: UseRefreshTokenArgs, extraHeaders?: Record<string, ExtraHeader>): Promise<User> {
         const response = await this._client.useRefreshToken({
             ...args,
             timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds,
+            extraHeaders,
         });
         const user = new User({ ...args.state, ...response });
 


Test patch:
diff --git a/src/JsonService.test.ts b/src/JsonService.test.ts
index 800fec10..8030bd3f 100644
--- a/src/JsonService.test.ts
+++ b/src/JsonService.test.ts
@@ -5,6 +5,7 @@ import { ErrorResponse } from "./errors";
 import { JsonService } from "./JsonService";
 
 import { mocked } from "jest-mock";
+import type { ExtraHeader } from "./OidcClientSettings";
 
 describe("JsonService", () => {
     let subject: JsonService;
@@ -339,6 +340,31 @@ describe("JsonService", () => {
             );
         });
 
+        it("should fetch with extraHeaders if supplied", async () => {
+            // act
+            const extraHeaders: Record<string, ExtraHeader> = {
+                "extraHeader": "some random header value",
+            };
+            await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy"), extraHeaders })).rejects.toThrow();
+            await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy"), basicAuth: "basicAuth", extraHeaders })).rejects.toThrow();
+
+            // assert
+            expect(fetch).toBeCalledTimes(2);
+            expect(fetch).toHaveBeenLastCalledWith(
+                "http://test",
+                expect.objectContaining({
+                    headers: {
+                        Accept: "application/json",
+                        Authorization: "Basic basicAuth",
+                        "Content-Type": "application/x-www-form-urlencoded",
+                        extraHeader: expect.any(String),
+                    },
+                    method: "POST",
+                    body: new URLSearchParams(),
+                }),
+            );
+        });
+
         it("should set payload as body", async () => {
             // act
             await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy") })).rejects.toThrow();
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 76154e52..8999e7d4 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -5,7 +5,7 @@ import { JwtUtils } from "./utils";
 import type { ErrorResponse } from "./errors";
 import type { JwtClaims } from "./Claims";
 import { OidcClient } from "./OidcClient";
-import { type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
+import { type ExtraHeader, type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
 import { SigninState } from "./SigninState";
 import { State } from "./State";
 import { SigninRequest } from "./SigninRequest";
@@ -335,7 +335,32 @@ describe("OidcClient", () => {
             const response = await subject.processSigninResponse("http://app/cb?state=1");
 
             // assert
-            expect(validateSigninResponseMock).toHaveBeenCalledWith(response, item);
+            expect(validateSigninResponseMock).toHaveBeenCalledWith(response, item, undefined);
+        });
+
+        it("should pass on extraHeaders if supplied", async () => {
+            // arrange
+            const item = await SigninState.create({
+                id: "1",
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "http://app/cb",
+                scope: "scope",
+                request_type: "type",
+            });
+
+            const extraHeaders: Record<string, ExtraHeader> = { "foo": "bar" };
+
+            jest.spyOn(subject.settings.stateStore, "remove")
+                .mockImplementation(async () => item.toStorageString());
+            const validateSigninResponseMock = jest.spyOn(subject["_validator"], "validateSigninResponse")
+                .mockResolvedValue();
+
+            // act
+            const response = await subject.processSigninResponse("http://app/cb?state=1", extraHeaders);
+
+            // assert
+            expect(validateSigninResponseMock).toHaveBeenCalledWith(response, item, extraHeaders);
         });
     });
 
@@ -514,6 +539,41 @@ describe("OidcClient", () => {
                 // assert
                 .rejects.toThrow("sub in id_token does not match current sub");
         });
+
+        it("should pass extraHeaders to tokenClient.exchangeRefreshToken if supplied", async () => {
+            // arrange
+            const tokenResponse = {
+                access_token: "new_access_token",
+            };
+            const exchangeRefreshTokenMock =
+                jest.spyOn(subject["_tokenClient"], "exchangeRefreshToken")
+                    .mockResolvedValue(tokenResponse);
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "sub" });
+            const state = new RefreshState({
+                refresh_token: "refresh_token",
+                id_token: "id_token",
+                session_state: "session_state",
+                scope: "openid",
+                profile: {} as UserProfile,
+            });
+            const extraHeaders: Record<string, ExtraHeader> = { "foo": "bar" };
+
+            // act
+            const response = await subject.useRefreshToken({ state, resource: "resource", extraHeaders: extraHeaders });
+
+            // assert
+            expect(exchangeRefreshTokenMock).toHaveBeenCalledWith( {
+                refresh_token: "refresh_token",
+                scope: "openid",
+                timeoutInSeconds: undefined,
+                resource: "resource",
+                extraHeaders: extraHeaders,
+            });
+            expect(response).toBeInstanceOf(SigninResponse);
+            expect(response).toMatchObject(tokenResponse);
+            expect(response).toHaveProperty("session_state", state.session_state);
+            expect(response).toHaveProperty("scope", state.scope);
+        });
     });
 
     describe("createSignoutRequest", () => {
diff --git a/src/TokenClient.test.ts b/src/TokenClient.test.ts
index d861116e..a3990bfd 100644
--- a/src/TokenClient.test.ts
+++ b/src/TokenClient.test.ts
@@ -1,7 +1,7 @@
 import { CryptoUtils } from "./utils";
 import { TokenClient } from "./TokenClient";
 import { MetadataService } from "./MetadataService";
-import { type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
+import { type ExtraHeader, type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSettings";
 
 describe("TokenClient", () => {
     let settings: OidcClientSettings;
@@ -142,6 +142,28 @@ describe("TokenClient", () => {
                 }),
             );
         });
+
+        it("should call postForm with extraHeaders if extraHeaders are supplied", async () => {
+            // arrange
+            const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
+                .mockResolvedValue("http://sts/token_endpoint");
+            const postFormMock = jest.spyOn(subject["_jsonService"], "postForm")
+                .mockResolvedValue({});
+            const extraHeaders: Record<string, ExtraHeader> = { "foo": "bar" };
+            // act
+            await subject.exchangeCode({ code: "code", code_verifier: "code_verifier", extraHeaders: extraHeaders });
+
+            // assert
+            expect(getTokenEndpointMock).toHaveBeenCalledWith(false);
+            expect(postFormMock).toHaveBeenCalledWith(
+                "http://sts/token_endpoint",
+                expect.objectContaining({
+                    body: expect.any(URLSearchParams),
+                    basicAuth: undefined,
+                    extraHeaders: extraHeaders,
+                }),
+            );
+        });
     });
 
     describe("exchangeCredentials", () => {
@@ -360,6 +382,29 @@ describe("TokenClient", () => {
                 }),
             );
         });
+
+        it("should call postForm with extraHeaders if extraHeaders are supplied", async () => {
+            // arrange
+            const getTokenEndpointMock = jest.spyOn(subject["_metadataService"], "getTokenEndpoint")
+                .mockResolvedValue("http://sts/token_endpoint");
+            const postFormMock = jest.spyOn(subject["_jsonService"], "postForm")
+                .mockResolvedValue({});
+            const extraHeaders: Record<string, ExtraHeader> = { "foo": "bar" };
+            // act
+            await subject.exchangeRefreshToken({ refresh_token: "refresh_token", extraHeaders: extraHeaders });
+
+            // assert
+            expect(getTokenEndpointMock).toHaveBeenCalledWith(false);
+            expect(postFormMock).toHaveBeenCalledWith(
+                "http://sts/token_endpoint",
+                expect.objectContaining({
+                    body: expect.any(URLSearchParams),
+                    basicAuth: undefined,
+                    timeoutInSeconds: undefined,
+                    extraHeaders: extraHeaders,
+                }),
+            );
+        });
     });
 
     describe("revoke", () => {