diff --git a/apps/web/utils/outlook/client.emulator.test.ts b/apps/web/utils/outlook/client.emulator.test.ts new file mode 100644 index 0000000000..a1d75322c0 --- /dev/null +++ b/apps/web/utils/outlook/client.emulator.test.ts @@ -0,0 +1,65 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("OutlookClient with Microsoft emulator", () => { + let server: Server | undefined; + + afterEach(async () => { + vi.doUnmock("@/env"); + vi.doUnmock("@/utils/auth/save-tokens"); + vi.doUnmock("@/utils/auth/cleanup-invalid-tokens"); + vi.resetModules(); + + if (server) { + await new Promise((resolve, reject) => { + server?.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + }); + + it("authorizes requests to an HTTP Microsoft emulator", async () => { + let authorizationHeader: string | undefined; + + server = createServer((request, response) => { + authorizationHeader = request.headers.authorization; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ id: "user-id" })); + }); + + const port = await new Promise((resolve, reject) => { + server?.listen(0, "127.0.0.1", () => { + const address = server?.address(); + if (typeof address === "object" && address) resolve(address.port); + else reject(new Error("Failed to start test server")); + }); + }); + + vi.doMock("@/env", () => ({ + env: { + MICROSOFT_BASE_URL: `http://127.0.0.1:${port}`, + NODE_ENV: "test", + }, + })); + vi.doMock("@/utils/auth/save-tokens", () => ({ + saveTokens: vi.fn(), + })); + vi.doMock("@/utils/auth/cleanup-invalid-tokens", () => ({ + cleanupInvalidTokens: vi.fn(), + })); + + const [{ createOutlookClient }, { createScopedLogger }] = await Promise.all( + [import("./client"), import("@/utils/logger")], + ); + + const client = createOutlookClient( + "emulator-token", + createScopedLogger("test"), + ); + await client.getClient().api("/me").get(); + + expect(authorizationHeader).toBe("Bearer emulator-token"); + }); +}); diff --git a/apps/web/utils/outlook/client.test.ts b/apps/web/utils/outlook/client.test.ts index 70a4151fa9..807654d03f 100644 --- a/apps/web/utils/outlook/client.test.ts +++ b/apps/web/utils/outlook/client.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@microsoft/microsoft-graph-client"; +import { Client, MiddlewareFactory } from "@microsoft/microsoft-graph-client"; import { saveTokens } from "@/utils/auth/save-tokens"; import { createTestLogger } from "@/__tests__/helpers"; import { @@ -16,6 +16,10 @@ import { vi.mock("@microsoft/microsoft-graph-client", () => ({ Client: { init: vi.fn(), + initWithMiddleware: vi.fn(), + }, + MiddlewareFactory: { + getDefaultMiddlewareChain: vi.fn(() => [{}]), }, })); @@ -66,8 +70,10 @@ describe("outlook client emulator configuration", () => { expect(getMicrosoftGraphClientOptions).toHaveBeenCalledWith( "emulator-token", ); - expect(Client.init).toHaveBeenCalledWith({ - authProvider: expect.any(Function), + expect(MiddlewareFactory.getDefaultMiddlewareChain).toHaveBeenCalledWith({ + getAccessToken: expect.any(Function), + }); + expect(Client.initWithMiddleware).toHaveBeenCalledWith({ baseUrl: "http://localhost:4003/", customHosts: new Set(["localhost"]), defaultVersion: "v1.0", @@ -77,6 +83,7 @@ describe("outlook client emulator configuration", () => { Prefer: 'IdType="ImmutableId"', }, }, + middleware: [expect.any(Object)], }); }); diff --git a/apps/web/utils/outlook/client.ts b/apps/web/utils/outlook/client.ts index ba7c6bcfea..cdc108e738 100644 --- a/apps/web/utils/outlook/client.ts +++ b/apps/web/utils/outlook/client.ts @@ -1,4 +1,9 @@ -import { Client } from "@microsoft/microsoft-graph-client"; +import { + Client, + MiddlewareFactory, + type Context, + type Middleware, +} from "@microsoft/microsoft-graph-client"; import type { User } from "@microsoft/microsoft-graph-types"; import { saveTokens } from "@/utils/auth/save-tokens"; import { cleanupInvalidTokens } from "@/utils/auth/cleanup-invalid-tokens"; @@ -27,11 +32,7 @@ export class OutlookClient { this.accessToken = accessToken; this.logger = logger; const graphClientOptions = getMicrosoftGraphClientOptions(accessToken); - this.client = Client.init({ - authProvider: (done) => { - done(null, this.accessToken); - }, - defaultVersion: "v1.0", + const clientOptions = { ...graphClientOptions, // Use immutable IDs to ensure message IDs remain stable // https://learn.microsoft.com/en-us/graph/outlook-immutable-id @@ -41,6 +42,22 @@ export class OutlookClient { Prefer: 'IdType="ImmutableId"', }, }, + }; + + if (graphClientOptions.baseUrl) { + this.client = Client.initWithMiddleware({ + ...clientOptions, + middleware: getMicrosoftEmulatorMiddleware(accessToken), + }); + return; + } + + this.client = Client.init({ + authProvider: (done) => { + done(null, this.accessToken); + }, + defaultVersion: "v1.0", + ...clientOptions, }); } @@ -258,3 +275,41 @@ export function getLinkingOAuth2Url() { // Helper types for common Microsoft Graph operations export type { Client as GraphClient }; + +function getMicrosoftEmulatorMiddleware(accessToken: string) { + const middleware = MiddlewareFactory.getDefaultMiddlewareChain({ + getAccessToken: async () => accessToken, + }); + + // Graph SDK 3.x only authenticates HTTPS custom hosts, while emulate.dev + // intentionally serves its local Microsoft endpoint over HTTP. + middleware[0] = new MicrosoftEmulatorAuthenticationHandler(accessToken); + + return middleware; +} + +class MicrosoftEmulatorAuthenticationHandler implements Middleware { + private readonly accessToken: string; + private nextMiddleware: Middleware | undefined; + + constructor(accessToken: string) { + this.accessToken = accessToken; + } + + setNext(nextMiddleware: Middleware) { + this.nextMiddleware = nextMiddleware; + } + + async execute(context: Context) { + context.options ??= {}; + const headers = new Headers(context.options.headers); + headers.set("Authorization", `Bearer ${this.accessToken}`); + context.options.headers = headers; + + if (!this.nextMiddleware) { + throw new Error("Microsoft emulator middleware chain is incomplete"); + } + + await this.nextMiddleware.execute(context); + } +}