diff --git a/apps/app/src/app/extensions.ts b/apps/app/src/app/extensions.ts index 9c528068d..3ad198729 100644 --- a/apps/app/src/app/extensions.ts +++ b/apps/app/src/app/extensions.ts @@ -321,6 +321,34 @@ export const BUILT_IN_OPENWORK_EXTENSION_MANIFESTS: OpenWorkExtensionManifest[] lifecycle: { reload: ["config"], detection: ["provider:google-workspace"] }, defaultHidden: true, }, + { + schemaVersion: 1, + id: "outlook-365", + name: "Outlook 365", + description: "Connect a Microsoft account with the minimum sign-in and profile permissions.", + preview: true, + source: { format: "openwork-builtin", origin: "builtin", trusted: true }, + icon: { simpleIconSlug: "microsoftoutlook" }, + composer: { prompt: "Use Outlook 365 to " }, + setup: { + instructions: "Connect your Microsoft account using the minimal User.Read permission. Mailbox and calendar tools are not enabled in this first phase.", + primaryCta: "Connect Outlook 365", + secondaryCta: "Test connection", + testActionRef: "openwork.outlook365.testConnection", + }, + resources: [ + { type: "provider", id: "microsoft-oauth", label: "Microsoft account", providerId: "outlook-365", required: true }, + { type: "local-service", id: "outlook-365-connector", label: "Secure local connection", required: true }, + { type: "tool", id: "microsoft-profile-read", label: "Microsoft profile", required: true }, + ], + contributions: [ + { type: "settings-panel", ref: "openwork.outlook365.settings", location: "settings-detail" }, + { type: "test-action", ref: "openwork.outlook365.testConnection", label: "Test Outlook 365" }, + { type: "composer-prompt", prompt: "Use Outlook 365 to ", location: "composer" }, + ], + lifecycle: { reload: ["config"], detection: ["provider:outlook-365"] }, + defaultHidden: true, + }, { schemaVersion: 1, id: "ollama", diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 8f816511e..20542453c 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -334,6 +334,40 @@ export type GoogleWorkspaceConnectStatus = { googleWorkspace: GoogleWorkspaceAuthStatus | null; }; +export type Outlook365Account = { + id: string | null; + displayName: string | null; + mail: string | null; + userPrincipalName: string | null; +}; + +export type Outlook365AuthStatus = { + configured: boolean; + missing: string[]; + vault: "encrypted" | "plaintext-dev" | "unavailable"; + connected: boolean; + account: Outlook365Account | null; + scopes: string[]; + connectedAt: string | null; + error: string | null; + testStatus: string | null; + mock: boolean; +}; + +export type Outlook365ConnectStart = { + flowId: string; + authUrl: string; + expiresAt: number; +}; + +export type Outlook365ConnectStatus = { + flowId: string; + status: "pending" | "connected" | "failed" | "expired"; + expiresAt: number; + error: string | null; + outlook365: Outlook365AuthStatus | null; +}; + export type OpenworkExtensionActionCall = { extensionId: string; action: string; @@ -964,6 +998,11 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s googleWorkspaceDisconnect: () => requestJson(baseUrl, "/experimental/google-workspace/disconnect", { token, hostToken, method: "POST", timeoutMs: timeouts.status }), googleWorkspaceTestConnection: () => requestJson(baseUrl, "/experimental/google-workspace/test", { token, hostToken, method: "POST", timeoutMs: 60_000 }), googleWorkspaceRunScopeSmokeTest: () => requestJson(baseUrl, "/experimental/google-workspace/smoke-test", { token, hostToken, method: "POST", timeoutMs: 120_000 }), + outlook365Status: () => requestJson(baseUrl, "/experimental/outlook-365/status", { token, hostToken, timeoutMs: timeouts.status }), + outlook365ConnectStart: () => requestJson(baseUrl, "/experimental/outlook-365/connect/start", { token, hostToken, method: "POST", timeoutMs: timeouts.status }), + outlook365ConnectStatus: (flowId: string) => requestJson(baseUrl, `/experimental/outlook-365/connect/status/${encodeURIComponent(flowId)}`, { token, hostToken, timeoutMs: timeouts.status }), + outlook365Disconnect: () => requestJson(baseUrl, "/experimental/outlook-365/disconnect", { token, hostToken, method: "POST", timeoutMs: timeouts.status }), + outlook365TestConnection: () => requestJson(baseUrl, "/experimental/outlook-365/test", { token, hostToken, method: "POST", timeoutMs: 60_000 }), callExtensionAction: (payload: OpenworkExtensionActionCall) => requestJson(baseUrl, "/experimental/extensions/call", { token, diff --git a/apps/app/src/react-app/domains/settings/outlook-365-config.tsx b/apps/app/src/react-app/domains/settings/outlook-365-config.tsx new file mode 100644 index 000000000..c278066c1 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/outlook-365-config.tsx @@ -0,0 +1,234 @@ +/** @jsxImportSource react */ +import { useEffect, useState } from "react"; +import { CheckCircle2, Loader2, Mail, ShieldCheck, UserRound, XCircle } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { OpenworkServerClient, Outlook365AuthStatus } from "../../../app/lib/openwork-server"; +import { usePlatform } from "../../kernel/platform"; +import type { ExtensionConfigContext } from "./extension-registry"; +import { registerExtensionRuntime } from "./extension-registry"; + +type BusyAction = "status" | "connect" | "disconnect" | "test"; +type Outlook365Command = () => Promise; +const DESKTOP_ACTION_TIMEOUT_MS = 6 * 60 * 1000; +const CONNECT_POLL_INTERVAL_MS = 1_000; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function normalizeOutlook365Account(value: unknown): Outlook365AuthStatus["account"] { + if (!isRecord(value)) return null; + return { + id: typeof value.id === "string" ? value.id : null, + displayName: typeof value.displayName === "string" ? value.displayName : null, + mail: typeof value.mail === "string" ? value.mail : null, + userPrincipalName: typeof value.userPrincipalName === "string" ? value.userPrincipalName : null, + }; +} + +function normalizeOutlook365AuthStatus(value: unknown): Outlook365AuthStatus { + const record = isRecord(value) ? value : {}; + const vault = record.vault === "encrypted" || record.vault === "plaintext-dev" ? record.vault : "unavailable"; + return { + configured: record.configured === true, + missing: normalizeStringList(record.missing), + vault, + connected: record.connected === true, + account: normalizeOutlook365Account(record.account), + scopes: normalizeStringList(record.scopes), + connectedAt: typeof record.connectedAt === "string" ? record.connectedAt : null, + error: typeof record.error === "string" ? record.error : null, + testStatus: typeof record.testStatus === "string" ? record.testStatus : null, + mock: record.mock === true, + }; +} + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function waitForOutlook365Connection(client: OpenworkServerClient, flowId: string, expiresAt: number) { + while (Date.now() < expiresAt + 5_000) { + const result = await client.outlook365ConnectStatus(flowId); + if (result.status === "connected" && result.outlook365) return result.outlook365; + if (result.status === "failed" || result.status === "expired") { + throw new Error(result.error ?? "Outlook 365 connection did not complete."); + } + await sleep(CONNECT_POLL_INTERVAL_MS); + } + throw new Error("Outlook 365 OAuth timed out."); +} + +function accountLabel(status: Outlook365AuthStatus) { + return status.account?.mail ?? status.account?.userPrincipalName ?? status.account?.displayName ?? null; +} + +function Outlook365Config({ openworkServerClient, onExtensionConnectionChange }: ExtensionConfigContext) { + const platform = usePlatform(); + const [status, setStatus] = useState(null); + const [busyAction, setBusyAction] = useState(null); + const [error, setError] = useState(null); + const serverAvailable = Boolean(openworkServerClient); + const canConnect = serverAvailable && status?.configured === true && status.vault !== "unavailable"; + const canTest = serverAvailable && status?.connected === true; + + const loadStatus = async (options: { clearError?: boolean } = {}) => { + if (!openworkServerClient) return; + setBusyAction("status"); + if (options.clearError !== false) setError(null); + try { + const result = normalizeOutlook365AuthStatus(await openworkServerClient.outlook365Status()); + setStatus(result); + onExtensionConnectionChange?.("outlook-365", result.connected); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to read Outlook 365 status."); + } finally { + setBusyAction(null); + } + }; + + useEffect(() => { + void loadStatus(); + }, [openworkServerClient]); + + const runDesktopAction = async (action: Exclude, command: Outlook365Command) => { + if (!openworkServerClient) return; + setBusyAction(action); + setError(null); + try { + const result = await Promise.race([ + command(), + new Promise((_, reject) => { + window.setTimeout(() => reject(new Error("Outlook 365 connection is taking too long. Try again, or restart OpenWork if the browser already said authorization was received.")), DESKTOP_ACTION_TIMEOUT_MS); + }), + ]); + const next = normalizeOutlook365AuthStatus(result); + setStatus(next); + onExtensionConnectionChange?.("outlook-365", next.connected); + } catch (err) { + setError(err instanceof Error ? err.message : `Outlook 365 ${action} failed.`); + await loadStatus({ clearError: false }); + } finally { + setBusyAction(null); + } + }; + + const connectOutlook365 = async () => { + if (!openworkServerClient) return null; + const flow = await openworkServerClient.outlook365ConnectStart(); + if (!flow.authUrl.startsWith("mock://")) platform.openLink(flow.authUrl); + return waitForOutlook365Connection(openworkServerClient, flow.flowId, flow.expiresAt); + }; + + return ( +
+ {!serverAvailable ? ( + + + OpenWork server required + Start OpenWork server to connect Outlook 365. + + ) : null} + + {status?.connected ? ( + + + Connected to Outlook 365 + + {accountLabel(status) ? `Signed in as ${accountLabel(status)}.` : "Your Microsoft account is connected."} + {status.testStatus ? ` ${status.testStatus}` : ""} + + + ) : ( + + + Connect Outlook 365 + + Use the minimum Microsoft permission set so OpenWork can identify the connected Outlook account. + + + )} + + {status && !status.configured ? ( + + + Microsoft OAuth client not configured + Set OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID to enable Outlook 365 sign-in. + + ) : null} + + {error || status?.error ? ( + + + Outlook 365 error + {error ?? status?.error} + + ) : null} + + + + What OpenWork can do + + This first Outlook 365 connection uses only Microsoft sign-in and profile access. Mailbox and calendar tools can be added later with separate permission review. + + + +
+ +
Account identity
+
Verify the signed-in Microsoft account using User.Read.
+
+
+ +
No mailbox access yet
+
This phase does not read mail, send mail, or create drafts.
+
+
+
+ + + +
+ {status?.connected ? ( + + ) : ( + + )} + +
+
+
+
+ ); +} + +registerExtensionRuntime({ + id: "outlook-365", + settingsPanel: (ctx) => , + settingsPanelRefs: ["openwork.outlook365.settings"], + isConnected: (_entry, ctx) => Boolean(ctx.extensionConnections?.["outlook-365"]), +}); diff --git a/apps/app/src/react-app/domains/settings/settings-extension-controller.ts b/apps/app/src/react-app/domains/settings/settings-extension-controller.ts index 92ca3e51b..ade252049 100644 --- a/apps/app/src/react-app/domains/settings/settings-extension-controller.ts +++ b/apps/app/src/react-app/domains/settings/settings-extension-controller.ts @@ -21,6 +21,8 @@ type SettingsExtensionControllerInput = { onComputerUsePermissionsChange: (permissions: { accessibility: boolean; screenRecording: boolean }) => void; googleWorkspaceConnected: boolean; setGoogleWorkspaceConnected: (connected: boolean) => void; + outlook365Connected: boolean; + setOutlook365Connected: (connected: boolean) => void; connectMcp: (entry: McpDirectoryInfo) => void | Promise; refreshMcpServers: () => void | Promise; providers: ProviderLike[]; @@ -61,9 +63,11 @@ export function useSettingsExtensionController(input: SettingsExtensionControlle openworkServerClient: input.openworkServerClient, extensionConnections: { "google-workspace": input.googleWorkspaceConnected, + "outlook-365": input.outlook365Connected, }, onExtensionConnectionChange: (extensionId, connected) => { if (extensionId === "google-workspace") input.setGoogleWorkspaceConnected(connected); + if (extensionId === "outlook-365") input.setOutlook365Connected(connected); }, computerUse: { connected: input.mcpServers.some((server) => server.name === "computer-use"), @@ -96,6 +100,7 @@ export function useSettingsExtensionController(input: SettingsExtensionControlle openworkServerClient: input.openworkServerClient, extensionConnections: { "google-workspace": input.googleWorkspaceConnected, + "outlook-365": input.outlook365Connected, }, }); return runtimeConnected ?? false; diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index a456cbca5..fb977ac80 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -44,6 +44,7 @@ import "../domains/settings/computer-use-config"; import "../domains/settings/browser-extension-config"; import "../domains/settings/openwork-voice-config"; import "../domains/settings/google-workspace-config"; +import "../domains/settings/outlook-365-config"; import { useSettingsExtensionController } from "../domains/settings/settings-extension-controller"; import { buildExtensionItems } from "../domains/settings/extension-items"; import { isOpenWorkExtensionEnabled, OPENWORK_EXTENSION_STATE_CHANGED, setOpenWorkExtensionEnabled } from "../domains/settings/extension-state"; @@ -526,6 +527,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const [localProviderStatus, setLocalProviderStatus] = useState(null); const [localProviderError, setLocalProviderError] = useState(null); const [googleWorkspaceConnected, setGoogleWorkspaceConnected] = useState(false); + const [outlook365Connected, setOutlook365Connected] = useState(false); const [imageExtensionBusy, setImageExtensionBusy] = useState(false); const [imageExtensionStatus, setImageExtensionStatus] = useState(null); const [imageExtensionError, setImageExtensionError] = useState(null); @@ -940,16 +942,22 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const client = selectedWorkspaceEndpoint?.client ?? openworkClient; if (!client) { setGoogleWorkspaceConnected(false); + setOutlook365Connected(false); return; } let cancelled = false; - void client.googleWorkspaceStatus() - .then((result) => { - if (!cancelled) setGoogleWorkspaceConnected(result.connected === true); + void Promise.allSettled([client.googleWorkspaceStatus(), client.outlook365Status()]) + .then(([googleResult, outlookResult]) => { + if (cancelled) return; + setGoogleWorkspaceConnected(googleResult.status === "fulfilled" && googleResult.value.connected === true); + setOutlook365Connected(outlookResult.status === "fulfilled" && outlookResult.value.connected === true); }) .catch(() => { - if (!cancelled) setGoogleWorkspaceConnected(false); + if (!cancelled) { + setGoogleWorkspaceConnected(false); + setOutlook365Connected(false); + } }); return () => { @@ -1693,6 +1701,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { onComputerUsePermissionsChange: setComputerUsePermissions, googleWorkspaceConnected, setGoogleWorkspaceConnected, + outlook365Connected, + setOutlook365Connected, connectMcp: (entry) => connectionsStore.connectMcp(entry), refreshMcpServers: () => connectionsStore.refreshMcpServers(), providers, diff --git a/apps/server/src/extensions/index.ts b/apps/server/src/extensions/index.ts index 85b80ee76..80512e41f 100644 --- a/apps/server/src/extensions/index.ts +++ b/apps/server/src/extensions/index.ts @@ -11,9 +11,15 @@ import { OPENAI_IMAGE_GENERATION_EXTENSION_ACTIONS, OPENAI_IMAGE_GENERATION_EXTENSION_ID, } from "./openai-image-generation.js"; +import { + callOutlook365ExtensionAction, + OUTLOOK_365_EXTENSION_ACTIONS, + OUTLOOK_365_EXTENSION_ID, +} from "./outlook-365.js"; const OPENWORK_EXPERIMENTAL_EXTENSION_ACTIONS = [ ...GOOGLE_WORKSPACE_EXTENSION_ACTIONS, + ...OUTLOOK_365_EXTENSION_ACTIONS, ...OPENAI_IMAGE_GENERATION_EXTENSION_ACTIONS, ]; @@ -55,6 +61,11 @@ export async function callExperimentalExtensionAction(config: ServerConfig, env: if (result) return result; } + if (extensionId === OUTLOOK_365_EXTENSION_ID) { + const result = await callOutlook365ExtensionAction(config, action, args, context); + if (result) return result; + } + if (extensionId === OPENAI_IMAGE_GENERATION_EXTENSION_ID) { const result = await callOpenAiImageGenerationExtensionAction(config, env, action, args, context); if (result) return result; diff --git a/apps/server/src/extensions/outlook-365.test.ts b/apps/server/src/extensions/outlook-365.test.ts new file mode 100644 index 000000000..c1e1a4a7b --- /dev/null +++ b/apps/server/src/extensions/outlook-365.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { ServerConfig } from "../types.js"; +import { listExperimentalExtensionActions } from "./index.js"; +import { + callOutlook365ExtensionAction, + createOutlook365ConnectFlowManager, + outlook365Disconnect, + outlook365Status, + outlook365TestConnection, +} from "./outlook-365.js"; + +function createTestConfig(): ServerConfig { + const tempDir = join( + tmpdir(), + `openwork-outlook-365-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + return { + host: "127.0.0.1", + port: 8787, + token: "test-client-token", + hostToken: "test-host-token", + configPath: join(tempDir, "server.json"), + approval: { mode: "auto", timeoutMs: 30000 }, + corsOrigins: ["*"], + workspaces: [], + authorizedRoots: [], + readOnly: false, + startedAt: Date.now(), + tokenSource: "generated", + hostTokenSource: "generated", + logFormat: "pretty", + logRequests: false, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function connectedFlag(value: unknown) { + return isRecord(value) && value.connected === true; +} + +function accountMail(value: unknown) { + if (!isRecord(value) || !isRecord(value.account)) return null; + return typeof value.account.mail === "string" ? value.account.mail : null; +} + +const previousEnv = { + mock: process.env.OPENWORK_OUTLOOK_365_MOCK, + dev: process.env.OPENWORK_DEV_MODE, + plaintext: process.env.OPENWORK_OUTLOOK_365_ALLOW_PLAINTEXT_VAULT, + clientId: process.env.OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID, +}; + +function restoreEnv(key: string, value: string | undefined) { + if (typeof value === "string") process.env[key] = value; + else delete process.env[key]; +} + +afterEach(() => { + restoreEnv("OPENWORK_OUTLOOK_365_MOCK", previousEnv.mock); + restoreEnv("OPENWORK_DEV_MODE", previousEnv.dev); + restoreEnv("OPENWORK_OUTLOOK_365_ALLOW_PLAINTEXT_VAULT", previousEnv.plaintext); + restoreEnv("OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID", previousEnv.clientId); +}); + +describe("Outlook 365 extension", () => { + test("registers the minimal extension status action", () => { + expect(listExperimentalExtensionActions("outlook-365")).toEqual([ + expect.objectContaining({ extensionId: "outlook-365", action: "status" }), + ]); + }); + + test("reports missing OAuth client without mock mode", async () => { + process.env.OPENWORK_OUTLOOK_365_MOCK = "0"; + process.env.OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID = ""; + const status = await outlook365Status(createTestConfig()); + expect(status.configured).toBe(false); + expect(status.connected).toBe(false); + expect(status.missing).toContain("OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID"); + }); + + test("mock connect flow exercises connect, status, test, and disconnect", async () => { + process.env.OPENWORK_OUTLOOK_365_MOCK = "1"; + process.env.OPENWORK_DEV_MODE = "1"; + process.env.OPENWORK_OUTLOOK_365_ALLOW_PLAINTEXT_VAULT = "1"; + const config = createTestConfig(); + const flows = createOutlook365ConnectFlowManager(config); + + const started = await flows.start(); + expect(started.authUrl).toBe("mock://outlook-365/authorize"); + + const flowStatus = await flows.status(started.flowId); + expect(flowStatus.status).toBe("connected"); + expect(connectedFlag(flowStatus.outlook365)).toBe(true); + + const status = await outlook365Status(config); + expect(status.connected).toBe(true); + expect(accountMail(status)).toBe("mock.user@example.com"); + + const tested = await outlook365TestConnection(config); + expect(tested.connected).toBe(true); + expect(String(tested.testStatus)).toContain("User.Read"); + + const actionResult = await callOutlook365ExtensionAction(config, "status", {}, {}); + expect(isRecord(actionResult) && isRecord(actionResult.result) && actionResult.result.connected).toBe(true); + + const disconnected = await outlook365Disconnect(config); + expect(disconnected.connected).toBe(false); + }); +}); diff --git a/apps/server/src/extensions/outlook-365.ts b/apps/server/src/extensions/outlook-365.ts new file mode 100644 index 000000000..49679d336 --- /dev/null +++ b/apps/server/src/extensions/outlook-365.ts @@ -0,0 +1,548 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; + +import { ApiError } from "../errors.js"; +import type { ServerConfig } from "../types.js"; + +export const OUTLOOK_365_EXTENSION_ID = "outlook-365"; + +const OUTLOOK_365_CLIENT_ID_ENV = "OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID"; +const OUTLOOK_365_TENANT_ENV = "OPENWORK_OUTLOOK_365_TENANT"; +const OUTLOOK_365_TOKEN_BROKER_URL_ENV = "OPENWORK_OUTLOOK_365_TOKEN_BROKER_URL"; +const OUTLOOK_365_GRAPH_BASE_URL_ENV = "OPENWORK_OUTLOOK_365_GRAPH_BASE_URL"; +const OUTLOOK_365_AUTH_BASE_URL_ENV = "OPENWORK_OUTLOOK_365_AUTH_BASE_URL"; +const OUTLOOK_365_ALLOW_PLAINTEXT_VAULT_ENV = "OPENWORK_OUTLOOK_365_ALLOW_PLAINTEXT_VAULT"; +const OUTLOOK_365_MOCK_ENV = "OPENWORK_OUTLOOK_365_MOCK"; +const OUTLOOK_365_AUTH_TIMEOUT_MS = 5 * 60 * 1000; +const OUTLOOK_365_API_TIMEOUT_MS = 30_000; +const OUTLOOK_365_SCOPES = ["openid", "profile", "email", "offline_access", "User.Read"]; + +export const OUTLOOK_365_EXTENSION_ACTIONS = [ + { + extensionId: OUTLOOK_365_EXTENSION_ID, + action: "status", + title: "Outlook 365 status", + description: "Check whether Outlook 365 is connected and ready for OpenWork extension actions.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, +]; + +type Outlook365Flow = { + flowId: string; + state: string; + verifier: string; + redirectUri: string; + expiresAt: number; + status: "pending" | "connected" | "failed" | "expired"; + authUrl: string; + account: unknown; + error: string | null; + server: Server | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorCode(error: unknown): string { + return isRecord(error) && typeof error.code === "string" ? error.code : ""; +} + +function configDir(config: ServerConfig): string { + return dirname(config.configPath?.trim() || resolve(homedir(), ".config", "openwork", "server.json")); +} + +function outlook365MockEnabled() { + return process.env[OUTLOOK_365_MOCK_ENV] === "1"; +} + +function outlook365Tenant() { + return process.env[OUTLOOK_365_TENANT_ENV]?.trim() || "common"; +} + +function outlook365GraphBaseUrl() { + return (process.env[OUTLOOK_365_GRAPH_BASE_URL_ENV]?.trim() || "https://graph.microsoft.com/v1.0").replace(/\/+$/, ""); +} + +function outlook365AuthBaseUrl() { + return (process.env[OUTLOOK_365_AUTH_BASE_URL_ENV]?.trim() || "https://login.microsoftonline.com").replace(/\/+$/, ""); +} + +function outlook365Credentials() { + const clientId = process.env[OUTLOOK_365_CLIENT_ID_ENV]?.trim() || process.env.MICROSOFT_365_OAUTH_CLIENT_ID?.trim() || ""; + const tokenBrokerUrl = process.env[OUTLOOK_365_TOKEN_BROKER_URL_ENV]?.trim() || process.env.MICROSOFT_365_TOKEN_BROKER_URL?.trim() || ""; + const missing: string[] = []; + if (!clientId && !outlook365MockEnabled()) missing.push(OUTLOOK_365_CLIENT_ID_ENV); + return { clientId, tokenBrokerUrl, tenant: outlook365Tenant(), missing }; +} + +function outlook365Dir(config: ServerConfig): string { + return join(configDir(config), "extensions", OUTLOOK_365_EXTENSION_ID); +} + +function outlook365VaultPath(config: ServerConfig): string { + return join(outlook365Dir(config), "oauth.vault"); +} + +function outlook365PlainTextVaultPath(config: ServerConfig): string { + return join(outlook365Dir(config), "oauth.dev-plaintext.json"); +} + +function outlook365VaultKeyPath(config: ServerConfig): string { + return join(configDir(config), "vault-key"); +} + +function outlook365PlainTextVaultEnabled() { + return process.env.OPENWORK_DEV_MODE === "1" && process.env[OUTLOOK_365_ALLOW_PLAINTEXT_VAULT_ENV] === "1"; +} + +function outlook365VaultMode() { + return outlook365PlainTextVaultEnabled() ? "plaintext-dev" : "encrypted"; +} + +function base64Url(buffer: Buffer): string { + return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function createOutlook365Pkce() { + const verifier = base64Url(randomBytes(48)); + const challenge = base64Url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +async function outlook365VaultKey(config: ServerConfig): Promise { + const envKey = process.env.OPENWORK_ENCRYPTION_KEY?.trim(); + if (envKey) return createHash("sha256").update(envKey).digest(); + + const keyPath = outlook365VaultKeyPath(config); + try { + const raw = await readFile(keyPath, "utf8"); + const key = Buffer.from(raw.trim(), "base64"); + if (key.byteLength === 32) return key; + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + + const key = randomBytes(32); + await mkdir(dirname(keyPath), { recursive: true }); + await writeFile(keyPath, `${key.toString("base64")}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(keyPath, 0o600).catch(() => undefined); + return key; +} + +async function readOutlook365Vault(config: ServerConfig): Promise | null> { + const vaultMode = outlook365VaultMode(); + const target = vaultMode === "plaintext-dev" ? outlook365PlainTextVaultPath(config) : outlook365VaultPath(config); + try { + const raw = await readFile(target, "utf8"); + if (!raw.trim()) return null; + if (vaultMode === "plaintext-dev") { + const parsed: unknown = JSON.parse(raw); + return isRecord(parsed) ? parsed : null; + } + const envelope: unknown = JSON.parse(raw); + if (!isRecord(envelope) || typeof envelope.iv !== "string" || typeof envelope.tag !== "string" || typeof envelope.data !== "string") return null; + const key = await outlook365VaultKey(config); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64")); + decipher.setAuthTag(Buffer.from(envelope.tag, "base64")); + const decrypted = Buffer.concat([decipher.update(Buffer.from(envelope.data, "base64")), decipher.final()]).toString("utf8"); + const parsed: unknown = JSON.parse(decrypted); + return isRecord(parsed) ? parsed : null; + } catch (error) { + if (errorCode(error) === "ENOENT") return null; + throw error; + } +} + +async function writeOutlook365Vault(config: ServerConfig, value: Record): Promise { + const vaultMode = outlook365VaultMode(); + const target = vaultMode === "plaintext-dev" ? outlook365PlainTextVaultPath(config) : outlook365VaultPath(config); + await mkdir(dirname(target), { recursive: true }); + if (vaultMode === "plaintext-dev") { + await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(target, 0o600).catch(() => undefined); + return; + } + const key = await outlook365VaultKey(config); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]); + const envelope = { schemaVersion: 1, algorithm: "aes-256-gcm", iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), data: encrypted.toString("base64") }; + await writeFile(target, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(target, 0o600).catch(() => undefined); +} + +async function removeOutlook365Vault(config: ServerConfig): Promise { + await Promise.all([ + rm(outlook365VaultPath(config), { force: true }), + rm(outlook365PlainTextVaultPath(config), { force: true }), + ]); +} + +function outlook365SafeAccount(account: unknown) { + if (!isRecord(account)) return null; + return { + id: typeof account.id === "string" ? account.id : null, + displayName: typeof account.displayName === "string" ? account.displayName : null, + mail: typeof account.mail === "string" ? account.mail : null, + userPrincipalName: typeof account.userPrincipalName === "string" ? account.userPrincipalName : null, + }; +} + +function outlook365StatusPayload(record: Record | null = null, extra: Record = {}) { + const credentials = outlook365Credentials(); + const token = isRecord(record?.token) ? record.token : null; + return { + configured: credentials.missing.length === 0, + missing: credentials.missing, + vault: outlook365VaultMode(), + connected: Boolean(token?.refreshToken || token?.accessToken), + account: outlook365SafeAccount(record?.account), + scopes: Array.isArray(record?.scopes) ? record.scopes.filter((item): item is string => typeof item === "string") : [], + connectedAt: typeof record?.connectedAt === "string" ? record.connectedAt : null, + error: null, + testStatus: null, + mock: outlook365MockEnabled(), + ...extra, + }; +} + +async function fetchOutlook365Json(url: string, init: RequestInit = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OUTLOOK_365_API_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") throw new Error("Microsoft Graph request timed out. Check your connection and try again."); + throw error; + } finally { + clearTimeout(timeout); + } + const text = await response.text(); + let payload: unknown = null; + if (text.trim()) { + try { payload = JSON.parse(text); } catch { payload = { raw: text }; } + } + if (!response.ok) { + const graphError = isRecord(payload) && isRecord(payload.error) ? payload.error : null; + const details = typeof graphError?.message === "string" ? graphError.message : response.statusText; + throw new Error(`Microsoft Graph request failed (${response.status}): ${details}`); + } + return payload; +} + +async function fetchOutlook365Me(accessToken: string) { + return fetchOutlook365Json(`${outlook365GraphBaseUrl()}/me`, { headers: { Authorization: `Bearer ${accessToken}` } }); +} + +async function fetchOutlook365TokenBrokerJson(tokenBrokerUrl: string, body: Record) { + return fetchOutlook365Json(tokenBrokerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); +} + +async function exchangeOutlook365Code(input: { code: string; redirectUri: string; verifier: string }) { + const { clientId, tokenBrokerUrl, tenant, missing } = outlook365Credentials(); + if (missing.length > 0) throw new Error(`Missing Outlook 365 OAuth configuration: ${missing.join(", ")}`); + if (tokenBrokerUrl) { + return fetchOutlook365TokenBrokerJson(tokenBrokerUrl, { + grantType: "authorization_code", + provider: OUTLOOK_365_EXTENSION_ID, + clientId, + tenant, + code: input.code, + codeVerifier: input.verifier, + redirectUri: input.redirectUri, + }); + } + return fetchOutlook365Json(`${outlook365AuthBaseUrl()}/${encodeURIComponent(tenant)}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + code: input.code, + code_verifier: input.verifier, + grant_type: "authorization_code", + redirect_uri: input.redirectUri, + }), + }); +} + +async function refreshOutlook365Vault(config: ServerConfig, record: Record) { + const token = isRecord(record.token) ? record.token : null; + const expiresAt = Number(token?.expiresAt ?? 0); + const accessToken = typeof token?.accessToken === "string" ? token.accessToken : ""; + const refreshToken = typeof token?.refreshToken === "string" ? token.refreshToken : ""; + if (accessToken && expiresAt > Date.now() + 60_000) return record; + if (!refreshToken) throw new Error("Outlook 365 refresh token is missing. Reconnect Outlook 365."); + if (outlook365MockEnabled() && refreshToken === "mock-refresh-token") return record; + + const { clientId, tokenBrokerUrl, tenant, missing } = outlook365Credentials(); + if (missing.length > 0) throw new Error(`Missing Outlook 365 OAuth configuration: ${missing.join(", ")}`); + const refreshed = tokenBrokerUrl + ? await fetchOutlook365TokenBrokerJson(tokenBrokerUrl, { grantType: "refresh_token", provider: OUTLOOK_365_EXTENSION_ID, clientId, tenant, refreshToken }) + : await fetchOutlook365Json(`${outlook365AuthBaseUrl()}/${encodeURIComponent(tenant)}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, grant_type: "refresh_token", refresh_token: refreshToken, scope: OUTLOOK_365_SCOPES.join(" ") }), + }); + if (!isRecord(refreshed) || typeof refreshed.access_token !== "string") throw new Error("Outlook 365 OAuth refresh did not return an access token."); + const next = { + ...record, + scopes: typeof refreshed.scope === "string" ? refreshed.scope.split(/\s+/).filter(Boolean) : record.scopes, + token: { + accessToken: refreshed.access_token, + refreshToken: typeof refreshed.refresh_token === "string" ? refreshed.refresh_token : refreshToken, + expiresAt: Date.now() + Number(refreshed.expires_in ?? 3600) * 1000, + }, + updatedAt: new Date().toISOString(), + }; + await writeOutlook365Vault(config, next); + return next; +} + +async function outlook365AccessToken(config: ServerConfig): Promise<{ record: Record; accessToken: string }> { + const record = await readOutlook365Vault(config); + if (!record) throw new ApiError(400, "outlook_365_not_connected", "Connect Outlook 365 in OpenWork Settings to use this tool."); + const refreshed = await refreshOutlook365Vault(config, record); + const token = isRecord(refreshed.token) ? refreshed.token : null; + const accessToken = typeof token?.accessToken === "string" ? token.accessToken : ""; + if (!accessToken) throw new Error("Outlook 365 access token is unavailable. Reconnect Outlook 365."); + return { record: refreshed, accessToken }; +} + +export async function callOutlook365ExtensionAction(config: ServerConfig, action: string, _args: Record, context: Record) { + if (action === "status") { + return { + ok: true, + extensionId: OUTLOOK_365_EXTENSION_ID, + action, + result: await outlook365Status(config), + context, + }; + } + return null; +} + +export async function outlook365Status(config: ServerConfig) { + try { + const record = await readOutlook365Vault(config); + return outlook365StatusPayload(record); + } catch (error) { + return outlook365StatusPayload(null, { error: error instanceof Error ? error.message : String(error) }); + } +} + +export async function outlook365TestConnection(config: ServerConfig) { + const { record, accessToken } = await outlook365AccessToken(config); + if (!outlook365MockEnabled() || accessToken !== "mock-access-token") await fetchOutlook365Me(accessToken); + return outlook365StatusPayload(record, { testStatus: "Microsoft profile access verified with the minimal User.Read permission." }); +} + +export async function outlook365Disconnect(config: ServerConfig) { + await removeOutlook365Vault(config); + return outlook365StatusPayload(null, { testStatus: "Outlook 365 local tokens removed. To fully revoke access, remove OpenWork from your Microsoft account or tenant app permissions." }); +} + +async function writeMockConnection(config: ServerConfig) { + const account = { + id: "mock-user-id", + displayName: "Mock Outlook User", + mail: "mock.user@example.com", + userPrincipalName: "mock.user@example.com", + }; + const record = { + version: 1, + account, + scopes: OUTLOOK_365_SCOPES, + token: { + accessToken: "mock-access-token", + refreshToken: "mock-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await writeOutlook365Vault(config, record); + return account; +} + +function escapeHtml(value: string): string { + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +function outlook365CallbackPage(status: number, title: string, body: string) { + return new Response(`${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(body)}

`, { + status, + headers: { "Content-Type": "text/html; charset=utf-8", Connection: "close" }, + }); +} + +export function createOutlook365ConnectFlowManager(config: ServerConfig) { + const flows = new Map(); + + const cleanup = (flowId: string) => { + const flow = flows.get(flowId); + if (!flow) return; + flow.server?.closeAllConnections?.(); + flow.server?.close(() => undefined); + flows.delete(flowId); + }; + + const start = async () => { + const credentials = outlook365Credentials(); + if (credentials.missing.length > 0) { + throw new ApiError(400, "outlook_365_oauth_not_configured", `Missing Outlook 365 OAuth configuration: ${credentials.missing.join(", ")}`); + } + const flowId = base64Url(randomBytes(18)); + const expiresAt = Date.now() + OUTLOOK_365_AUTH_TIMEOUT_MS; + + if (outlook365MockEnabled()) { + await writeMockConnection(config); + flows.set(flowId, { + flowId, + state: "mock-state", + verifier: "mock-verifier", + redirectUri: "mock://outlook-365/callback", + expiresAt, + status: "connected", + authUrl: "mock://outlook-365/authorize", + account: null, + error: null, + server: null, + }); + return { flowId, authUrl: "mock://outlook-365/authorize", expiresAt }; + } + + const state = base64Url(randomBytes(24)); + const pkce = createOutlook365Pkce(); + let callbackServer: Server | null = null; + const port = await new Promise((resolvePort, reject) => { + callbackServer = createServer(async (request, response) => { + const finish = async (page: Response) => { + response.writeHead(page.status, Object.fromEntries(page.headers.entries())); + response.end(await page.text()); + }; + try { + const flow = flows.get(flowId); + if (!flow) { + await finish(outlook365CallbackPage(410, "Outlook 365 connection expired", "Return to OpenWork and start connection again.")); + return; + } + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== "/" && url.pathname !== "/oauth/outlook-365/callback") { + response.writeHead(404); + response.end("Not found"); + return; + } + const error = url.searchParams.get("error"); + if (error) { + flow.status = "failed"; + flow.error = `Microsoft OAuth returned error: ${error}`; + await finish(outlook365CallbackPage(400, "Outlook 365 connection failed", error)); + return; + } + const returnedState = url.searchParams.get("state") ?? ""; + const code = url.searchParams.get("code") ?? ""; + if (returnedState !== flow.state || !code) { + flow.status = "failed"; + flow.error = "Invalid Outlook 365 OAuth callback."; + await finish(outlook365CallbackPage(400, "Outlook 365 connection failed", "Invalid OAuth callback.")); + return; + } + await finish(outlook365CallbackPage(200, "Outlook 365 authorization received", "You can return to OpenWork while it finishes connecting.")); + try { + const token = await exchangeOutlook365Code({ code, redirectUri: flow.redirectUri, verifier: flow.verifier }); + if (!isRecord(token) || typeof token.access_token !== "string") throw new Error("Microsoft OAuth response did not include an access token."); + const account = await fetchOutlook365Me(token.access_token); + const record = { + version: 1, + account, + scopes: typeof token.scope === "string" ? token.scope.split(/\s+/).filter(Boolean) : OUTLOOK_365_SCOPES, + token: { + accessToken: token.access_token, + refreshToken: typeof token.refresh_token === "string" ? token.refresh_token : null, + expiresAt: Date.now() + Number(token.expires_in ?? 3600) * 1000, + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await writeOutlook365Vault(config, record); + flow.status = "connected"; + flow.account = account; + } catch (exchangeError) { + flow.status = "failed"; + flow.error = `Microsoft authorized OpenWork, but token exchange failed: ${exchangeError instanceof Error ? exchangeError.message : String(exchangeError)}`; + } + } catch (callbackError) { + const flow = flows.get(flowId); + if (flow) flow.error = callbackError instanceof Error ? callbackError.message : String(callbackError); + if (!response.headersSent) { + await finish(outlook365CallbackPage(500, "Outlook 365 connection failed", callbackError instanceof Error ? callbackError.message : String(callbackError))); + } + } + }); + callbackServer.once("error", reject); + callbackServer.listen(0, "127.0.0.1", () => { + const address = callbackServer?.address(); + const resolvedPort = typeof address === "object" && address ? address.port : null; + if (!resolvedPort) reject(new Error("Could not start Outlook 365 OAuth callback server.")); + else resolvePort(resolvedPort); + }); + }); + if (!callbackServer) throw new Error("Could not start Outlook 365 OAuth callback server."); + const redirectUri = `http://127.0.0.1:${port}/`; + const authorizationUrl = new URL(`${outlook365AuthBaseUrl()}/${encodeURIComponent(credentials.tenant)}/oauth2/v2.0/authorize`); + authorizationUrl.searchParams.set("client_id", credentials.clientId); + authorizationUrl.searchParams.set("redirect_uri", redirectUri); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("scope", OUTLOOK_365_SCOPES.join(" ")); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("code_challenge", pkce.challenge); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + flows.set(flowId, { + flowId, + state, + verifier: pkce.verifier, + redirectUri, + expiresAt, + status: "pending", + authUrl: authorizationUrl.toString(), + account: null, + error: null, + server: callbackServer, + }); + setTimeout(() => { + const flow = flows.get(flowId); + if (!flow || flow.status !== "pending") return; + flow.status = "expired"; + flow.error = "Outlook 365 OAuth timed out."; + flow.server?.closeAllConnections?.(); + flow.server?.close(() => undefined); + }, OUTLOOK_365_AUTH_TIMEOUT_MS + 1000).unref?.(); + return { flowId, authUrl: authorizationUrl.toString(), expiresAt }; + }; + + const status = async (flowId: string) => { + const flow = flows.get(flowId); + if (!flow) throw new ApiError(404, "outlook_365_oauth_flow_not_found", "Outlook 365 connection flow not found"); + if (flow.status === "pending" && flow.expiresAt <= Date.now()) { + flow.status = "expired"; + flow.error = "Outlook 365 OAuth timed out."; + } + const outlook365 = flow.status === "connected" ? await outlook365Status(config) : null; + const payload = { + flowId: flow.flowId, + status: flow.status, + expiresAt: flow.expiresAt, + error: flow.error, + outlook365, + }; + if (flow.status !== "pending") setTimeout(() => cleanup(flow.flowId), 1000).unref?.(); + return payload; + }; + + return { start, status }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 950c0e261..90414e334 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,12 @@ import { googleWorkspaceStatus, googleWorkspaceTestConnection, } from "./extensions/google-workspace.js"; +import { + createOutlook365ConnectFlowManager, + outlook365Disconnect, + outlook365Status, + outlook365TestConnection, +} from "./extensions/outlook-365.js"; import { callExperimentalExtensionAction, listExperimentalExtensionActions } from "./extensions/index.js"; import pkg from "../package.json" with { type: "json" }; import constants from "../../../constants.json" with { type: "json" }; @@ -1688,6 +1694,7 @@ function createRoutes( const routes: Route[] = []; const fileSessions = new FileSessionStore(); const googleWorkspaceConnectFlows = createGoogleWorkspaceConnectFlowManager(config); + const outlook365ConnectFlows = createOutlook365ConnectFlowManager(config); const envPendingChangesByRuntime = new Map(); const serializeFileSession = (session: { @@ -1951,6 +1958,28 @@ function createRoutes( return jsonResponse(await googleWorkspaceRunScopeSmokeTest(config)); }); + addRoute(routes, "GET", "/experimental/outlook-365/status", "client", async () => { + return jsonResponse(await outlook365Status(config)); + }); + + addRoute(routes, "POST", "/experimental/outlook-365/connect/start", "client", async (ctx) => { + if (ctx.actor?.scope === "viewer") throw new ApiError(403, "forbidden", "Viewer tokens cannot connect Outlook 365"); + return jsonResponse(await outlook365ConnectFlows.start(), 201); + }); + + addRoute(routes, "GET", "/experimental/outlook-365/connect/status/:flowId", "client", async (ctx) => { + return jsonResponse(await outlook365ConnectFlows.status(ctx.params.flowId)); + }); + + addRoute(routes, "POST", "/experimental/outlook-365/disconnect", "client", async (ctx) => { + if (ctx.actor?.scope === "viewer") throw new ApiError(403, "forbidden", "Viewer tokens cannot disconnect Outlook 365"); + return jsonResponse(await outlook365Disconnect(config)); + }); + + addRoute(routes, "POST", "/experimental/outlook-365/test", "client", async () => { + return jsonResponse(await outlook365TestConnection(config)); + }); + addRoute(routes, "GET", "/workspaces", "client", async () => { const active = config.workspaces[0] ?? null; const items = config.workspaces.map(serializeWorkspace); diff --git a/docs/outlook-365-oauth.md b/docs/outlook-365-oauth.md new file mode 100644 index 000000000..0f2d172ca --- /dev/null +++ b/docs/outlook-365-oauth.md @@ -0,0 +1,34 @@ +# Outlook 365 OAuth + +## Phase 1 Scope + +The initial Outlook 365 extension intentionally uses the smallest Microsoft permission set needed to connect an account and verify Microsoft Graph access: + +```text +openid +profile +email +User.Read +``` + +OpenWork does not request Outlook Mail, Calendar, or OneDrive permissions in this phase. + +## Azure App Registration + +- Create an Azure App Registration for OpenWork. +- Configure it as a public/native client. +- Add a loopback redirect URI for desktop OAuth. +- Set `OPENWORK_OUTLOOK_365_OAUTH_CLIENT_ID` to the app client ID. +- Optionally set `OPENWORK_OUTLOOK_365_TENANT` to `common`, `organizations`, or a tenant ID. The default is `common`. + +## Test Mock + +Set these environment variables to exercise the connect/status/test/disconnect flow without a Microsoft app registration or live Microsoft account: + +```bash +OPENWORK_OUTLOOK_365_MOCK=1 \ +OPENWORK_DEV_MODE=1 \ +OPENWORK_OUTLOOK_365_ALLOW_PLAINTEXT_VAULT=1 +``` + +Mock mode is intended for automated tests and local UI verification only.