From fb01459bcb37b38b73f9d13bc36781f21969055a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:36:06 +0100 Subject: [PATCH] Add registration and execution of request middlewares (#19421) Co-authored-by: Daniel James Smith --- libs/common/src/abstractions/api.service.ts | 6 ++ libs/common/src/services/api.service.spec.ts | 73 ++++++++++++++++++++ libs/common/src/services/api.service.ts | 16 +++++ 3 files changed, 95 insertions(+) diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 679798b51d9..b55e5d77e3b 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -459,6 +459,12 @@ export abstract class ApiService { abstract fetch(request: Request): Promise; abstract nativeFetch(request: Request): Promise; + /** + * Adds a middleware function that will be called with the Request object before each API call. This allows for dynamic header manipulation, such as adding cookies for SSO authentication. + * @param middleware The middleware function to add + */ + abstract addMiddleware(middleware: (request: Request) => Promise): void; + abstract preValidateSso(identifier: string): Promise; abstract postCreateSponsorship( diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index b5e72dfd899..14fce5b9620 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -1245,4 +1245,77 @@ describe("ApiService", () => { expect(logoutCallback).not.toHaveBeenCalled(); }); }); + + describe("fetch", () => { + it("does not execute any middlewares when none are registered", async () => { + const nativeFetch = jest.fn, [request: Request]>(); + nativeFetch.mockResolvedValue({ + ok: true, + status: 200, + } satisfies Partial as Response); + sut.nativeFetch = nativeFetch; + + const request = { + url: "https://example.com/api", + method: "POST", + headers: { set: jest.fn() }, + } as unknown as Request; + await sut.fetch(request); + + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it("executes a registered middleware before sending the request", async () => { + const middleware = jest.fn, [Request]>().mockResolvedValue(undefined); + sut.addMiddleware(middleware); + + const nativeFetch = jest.fn, [request: Request]>(); + nativeFetch.mockResolvedValue({ + ok: true, + status: 200, + } satisfies Partial as Response); + sut.nativeFetch = nativeFetch; + + const request = { + url: "https://example.com/api", + method: "POST", + headers: { set: jest.fn() }, + } as unknown as Request; + await sut.fetch(request); + + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith(request); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it("executes all registered middlewares before sending the request", async () => { + const callOrder: number[] = []; + const middleware1 = jest.fn, [Request]>().mockImplementation(async () => { + callOrder.push(1); + }); + const middleware2 = jest.fn, [Request]>().mockImplementation(async () => { + callOrder.push(2); + }); + sut.addMiddleware(middleware1); + sut.addMiddleware(middleware2); + + const nativeFetch = jest.fn, [request: Request]>(); + nativeFetch.mockResolvedValue({ + ok: true, + status: 200, + } satisfies Partial as Response); + sut.nativeFetch = nativeFetch; + + const request = { + url: "https://example.com/api", + method: "POST", + headers: { set: jest.fn() }, + } as unknown as Request; + await sut.fetch(request); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 66c343a1930..d1d0af1e3bd 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -138,6 +138,11 @@ export class ApiService implements ApiServiceAbstraction { private static readonly NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE = "new device verification required"; + /** + * Middlewares are functions that take a Request and return a Promise that resolves when the middleware is done processing the request. Middlewares are executed in the order they are added, and can modify the request before it is sent. This is used for things like adding cookies to requests for SSO authentication. + */ + private middlewares: Array<(request: Request) => Promise> = []; + constructor( private tokenService: TokenService, private platformUtilsService: PlatformUtilsService, @@ -1319,6 +1324,10 @@ export class ApiService implements ApiServiceAbstraction { return accessToken; } + addMiddleware(middleware: (request: Request) => Promise): void { + this.middlewares.push(middleware); + } + async fetch(request: Request): Promise { if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) { throw new InsecureUrlNotAllowedError(); @@ -1338,6 +1347,13 @@ export class ApiService implements ApiServiceAbstraction { if (packageType != null) { request.headers.set("Bitwarden-Package-Type", packageType); } + + await Promise.all( + this.middlewares.map((middleware) => { + return middleware(request); + }), + ); + return this.nativeFetch(request); }