Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/web/utils/outlook/client.emulator.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<number>((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");
});
});
13 changes: 10 additions & 3 deletions apps/web/utils/outlook/client.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,6 +16,10 @@ import {
vi.mock("@microsoft/microsoft-graph-client", () => ({
Client: {
init: vi.fn(),
initWithMiddleware: vi.fn(),
},
MiddlewareFactory: {
getDefaultMiddlewareChain: vi.fn(() => [{}]),
},
}));

Expand Down Expand Up @@ -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",
Expand All @@ -77,6 +83,7 @@ describe("outlook client emulator configuration", () => {
Prefer: 'IdType="ImmutableId"',
},
},
middleware: [expect.any(Object)],
});
});

Expand Down
67 changes: 61 additions & 6 deletions apps/web/utils/outlook/client.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Emulator Graph requests fail before reaching the HTTP server because the replacement authentication handler has no nextMiddleware: replacing middleware[0] discards the linked first handler, but this new instance never receives setNext. Linking it to middleware[1] (or otherwise preserving the existing chain) keeps the default retry, redirect, telemetry, and HTTP handlers while allowing the bearer header to be added.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/outlook/client.ts, line 286:

<comment>Emulator Graph requests fail before reaching the HTTP server because the replacement authentication handler has no `nextMiddleware`: replacing `middleware[0]` discards the linked first handler, but this new instance never receives `setNext`. Linking it to `middleware[1]` (or otherwise preserving the existing chain) keeps the default retry, redirect, telemetry, and HTTP handlers while allowing the bearer header to be added.</comment>

<file context>
@@ -258,3 +275,41 @@ export function getLinkingOAuth2Url() {
+
+  // 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;
</file context>
Suggested change
middleware[0] = new MicrosoftEmulatorAuthenticationHandler(accessToken);
const authenticationHandler = new MicrosoftEmulatorAuthenticationHandler(
accessToken,
);
authenticationHandler.setNext(middleware[1]);
middleware[0] = authenticationHandler;


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);
}
}
Loading