import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; import { createServer } from "node:http"; import type { IncomingMessage, Server, ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { buildOidcClaims, signOidcJwt } from "./idp.ts"; interface MockGoogleServerOptions { accounts: string[]; port: number; autoApprove: boolean; baseUrl?: string; } interface StartedMockGoogleServer { baseUrl: string; stop(): Promise; } interface Account { sub: string; email: string; name: string; } interface RequestEntry { method: string; path: string; url: string; at: string; } interface PendingAuthorization { id: string; params: URLSearchParams; } interface AuthorizationCode { account: Account; clientId: string; codeChallenge: string | null; codeChallengeMethod: string; scope: string; } interface RefreshCredential { account: Account; clientId: string; scope: string; } interface RecordedDraft { to: string; body: string; threadId?: string; attachments?: RecordedAttachment[]; tokenId: string; at: string; } interface RecordedAttachment { filename: string; mimeType: string; size: number; dataBase64: string; } interface RecordedDriveUpload extends RecordedAttachment { tokenId: string; at: string; } interface SigningKeys { keyId: string; privateKeyPem: string; } interface MockGoogleState { accounts: Account[]; autoApprove: boolean; baseUrl: string; requests: RequestEntry[]; pending: Map; codes: Map; accessTokens: Map; refreshTokens: Map; drafts: Map; driveUploads: Map; keys: SigningKeys; } const HTML_ENTITIES: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isAddressInfo(value: ReturnType): value is AddressInfo { return typeof value === "object" && value !== null && typeof value.address === "string" && typeof value.family === "string" && typeof value.port === "number"; } function accountName(email: string): string { const localPart = email.split("@")[0] ?? email; const words = localPart.split(/[._+-]+/).filter(Boolean); return words.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join(" ") || email; } function normalizeAccounts(values: string[]): Account[] { const emails = Array.from(new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))); if (emails.length === 0) { throw new Error("startMockGoogle requires at least one account."); } return emails.map((email) => ({ sub: email, email, name: accountName(email) })); } function createSigningKeys(): SigningKeys { const pair = generateKeyPairSync("rsa", { modulusLength: 2048, publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" }, }); return { keyId: `mock-google-${randomUUID()}`, privateKeyPem: pair.privateKey, }; } function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (character) => HTML_ENTITIES[character] ?? character); } function sendJson(response: ServerResponse, status: number, body: unknown): void { response.writeHead(status, { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "GET, POST, OPTIONS", "cache-control": "no-store", "content-type": "application/json; charset=utf-8", }); response.end(JSON.stringify(body)); } function sendHtml(response: ServerResponse, status: number, body: string): void { response.writeHead(status, { "access-control-allow-origin": "*", "cache-control": "no-store", "content-type": "text/html; charset=utf-8", }); response.end(body); } function sendRedirect(response: ServerResponse, location: string): void { response.writeHead(302, { "cache-control": "no-store", location }); response.end(); } function method(request: IncomingMessage): string { return (request.method ?? "GET").toUpperCase(); } function requestUrl(request: IncomingMessage, baseUrl: string): URL { return new URL(request.url ?? "/", baseUrl); } async function requestBuffer(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) { if (typeof chunk === "string") { chunks.push(Buffer.from(chunk)); } else if (Buffer.isBuffer(chunk)) { chunks.push(chunk); } else { chunks.push(Buffer.from(String(chunk))); } } return Buffer.concat(chunks); } async function requestBody(request: IncomingMessage): Promise { return (await requestBuffer(request)).toString("utf8"); } async function formBody(request: IncomingMessage): Promise { return new URLSearchParams(await requestBody(request)); } async function jsonBody(request: IncomingMessage): Promise { const body = await requestBody(request); return body.trim() ? JSON.parse(body) : null; } function bearerToken(request: IncomingMessage): string | null { const raw = request.headers.authorization; const header = Array.isArray(raw) ? raw[0] ?? "" : raw ?? ""; if (!/^bearer /i.test(header)) return null; return header.slice("bearer ".length).trim() || null; } function tokenId(token: string): string { return createHash("sha256").update(token).digest("hex").slice(0, 12); } function accountForRequest(state: MockGoogleState, request: IncomingMessage): Account | null { const token = bearerToken(request); return token ? state.accessTokens.get(token) ?? null : null; } function recordRequest(state: MockGoogleState, request: IncomingMessage, url: URL): void { state.requests.push({ method: method(request), path: url.pathname, url: `${url.pathname}${url.search}`, at: new Date().toISOString(), }); } function callbackForCode(params: URLSearchParams, code: string): string | null { const redirectUri = params.get("redirect_uri"); if (!redirectUri) return null; const callback = new URL(redirectUri); callback.searchParams.set("code", code); const relayState = params.get("state"); if (relayState) callback.searchParams.set("state", relayState); return callback.toString(); } function authorizeAccount( state: MockGoogleState, pending: PendingAuthorization, account: Account, response: ServerResponse, ): void { const code = `mock-google-code-${randomUUID()}`; const callback = callbackForCode(pending.params, code); if (!callback) { sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri is required" }); return; } state.codes.set(code, { account, clientId: pending.params.get("client_id") ?? "mock-google-client", codeChallenge: pending.params.get("code_challenge"), codeChallengeMethod: pending.params.get("code_challenge_method") ?? "plain", scope: pending.params.get("scope") ?? "openid email profile", }); state.pending.delete(pending.id); sendRedirect(response, callback); } function chooserPage(state: MockGoogleState, pending: PendingAuthorization): string { const accounts = state.accounts.map((account) => { const chooseUrl = new URL("/choose-account", state.baseUrl); chooseUrl.searchParams.set("request_id", pending.id); chooseUrl.searchParams.set("email", account.email); return `
  • ${escapeHtml(account.name)} ${escapeHtml(account.email)}
  • `; }).join(""); return `Choose an account

    Choose an account

    Continue to OpenWork

      ${accounts}
    `; } function consentPage(pending: PendingAuthorization): string { const approveUrl = new URL("/approve", "http://mock.invalid"); approveUrl.searchParams.set("request_id", pending.id); return `Mock Google OAuth

    Mock Google OAuth

    This fake Google provider is for OpenWork end-to-end tests.

    `; } function authorize(state: MockGoogleState, url: URL, response: ServerResponse): void { if (!url.searchParams.get("redirect_uri")) { sendJson(response, 400, { error: "invalid_request", error_description: "redirect_uri is required" }); return; } const pending: PendingAuthorization = { id: randomUUID(), params: new URLSearchParams(url.searchParams), }; const selectAccount = (url.searchParams.get("prompt") ?? "").split(/\s+/).includes("select_account"); if (selectAccount) { state.pending.set(pending.id, pending); sendHtml(response, 200, chooserPage(state, pending)); return; } if (state.autoApprove) { authorizeAccount(state, pending, state.accounts[0], response); return; } state.pending.set(pending.id, pending); sendHtml(response, 200, consentPage(pending)); } function chooseAccount(state: MockGoogleState, url: URL, response: ServerResponse): void { const pending = state.pending.get(url.searchParams.get("request_id") ?? "") ?? null; const account = state.accounts.find((candidate) => candidate.email === (url.searchParams.get("email") ?? "").toLowerCase()) ?? null; if (!pending || !account) { sendJson(response, 404, { error: "authorization_not_found" }); return; } authorizeAccount(state, pending, account, response); } function approve(state: MockGoogleState, url: URL, response: ServerResponse): void { const pending = state.pending.get(url.searchParams.get("request_id") ?? "") ?? null; if (!pending) { sendJson(response, 404, { error: "authorization_not_found" }); return; } authorizeAccount(state, pending, state.accounts[0], response); } function claimsFor(state: MockGoogleState, credential: RefreshCredential): Record { return buildOidcClaims({ issuer: state.baseUrl, clientId: credential.clientId, subject: credential.account, }); } function issueCredential(state: MockGoogleState, credential: RefreshCredential): { accessToken: string; refreshToken: string; idToken: string; } { const accessToken = `mock-google-access-${randomUUID()}`; const refreshToken = `mock-google-refresh-${randomUUID()}`; const claims = claimsFor(state, credential); state.accessTokens.set(accessToken, credential.account); state.refreshTokens.set(refreshToken, credential); return { accessToken, refreshToken, idToken: signOidcJwt({ keyId: state.keys.keyId, privateKeyPem: state.keys.privateKeyPem, claims }), }; } async function token(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise { const params = await formBody(request); const grantType = params.get("grant_type") ?? "authorization_code"; let credential: RefreshCredential; if (grantType === "authorization_code") { const codeValue = params.get("code") ?? ""; const code = state.codes.get(codeValue) ?? null; if (!code) { sendJson(response, 400, { error: "invalid_grant" }); return; } if (code.codeChallenge) { const verifier = params.get("code_verifier") ?? ""; const expected = code.codeChallengeMethod === "S256" ? createHash("sha256").update(verifier).digest("base64url") : verifier; if (expected !== code.codeChallenge) { sendJson(response, 400, { error: "invalid_grant", error_description: "PKCE verification failed" }); return; } } state.codes.delete(codeValue); credential = { account: code.account, clientId: code.clientId, scope: code.scope }; } else if (grantType === "refresh_token") { const refresh = state.refreshTokens.get(params.get("refresh_token") ?? "") ?? null; if (!refresh) { sendJson(response, 400, { error: "invalid_grant" }); return; } credential = refresh; } else { sendJson(response, 400, { error: "unsupported_grant_type" }); return; } const issued = issueCredential(state, credential); sendJson(response, 200, { access_token: issued.accessToken, refresh_token: issued.refreshToken, token_type: "Bearer", expires_in: 3600, scope: credential.scope, id_token: issued.idToken, }); } function userinfo(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): void { const account = accountForRequest(state, request); if (!account) { sendJson(response, 401, { error: "invalid_token" }); return; } sendJson(response, 200, { sub: account.sub, email: account.email, email_verified: true, name: account.name }); } function decodedRawMessage(raw: string): string { try { return Buffer.from(raw, "base64url").toString("utf8"); } catch { return ""; } } function splitMessage(message: string): { headers: string; body: string } { const separator = /\r?\n\r?\n/.exec(message); if (!separator || separator.index === undefined) return { headers: message, body: "" }; return { headers: message.slice(0, separator.index), body: message.slice(separator.index + separator[0].length), }; } function headerValue(headers: string, name: string): string { const unfolded = headers.replace(/\r?\n[ \t]+/g, " "); const prefix = `${name.toLowerCase()}:`; const line = unfolded.split(/\r?\n/).find((candidate) => candidate.toLowerCase().startsWith(prefix)); return line ? line.slice(line.indexOf(":") + 1).trim() : ""; } function decodeBase64Body(body: string): string { try { return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8"); } catch { return body; } } function mimeTextBody(headers: string, body: string): string | null { const contentType = headerValue(headers, "content-type"); const boundary = /boundary="?([^";]+)"?/i.exec(contentType)?.[1] ?? null; if (boundary) { for (const part of body.split(`--${boundary}`).slice(1)) { if (!part.trim() || part.trim().startsWith("--")) continue; const split = splitMessage(part.replace(/^\r?\n/, "")); const decoded = mimeTextBody(split.headers, split.body); if (decoded !== null) return decoded; } return null; } if (contentType && !contentType.toLowerCase().startsWith("text/plain")) return null; return headerValue(headers, "content-transfer-encoding").toLowerCase() === "base64" ? decodeBase64Body(body) : body.trimEnd(); } function draftBody(headers: string, body: string): string { return mimeTextBody(headers, body) ?? ""; } function unquoteMimeParameter(value: string): string { return value.replace(/\\([\\"])/g, "$1"); } function mimeParameter(value: string, name: string): string { const quoted = new RegExp(`${name}="((?:\\\\.|[^"\\\\])*)"`, "i").exec(value)?.[1]; if (quoted !== undefined) return unquoteMimeParameter(quoted); return new RegExp(`${name}=([^;\\s]+)`, "i").exec(value)?.[1] ?? ""; } function mimeAttachments(headers: string, body: string): RecordedAttachment[] { const contentType = headerValue(headers, "content-type"); const boundary = mimeParameter(contentType, "boundary"); if (boundary) { return body.split(`--${boundary}`).slice(1).flatMap((part) => { if (!part.trim() || part.trim().startsWith("--")) return []; const split = splitMessage(part.replace(/^\r?\n/, "")); return mimeAttachments(split.headers, split.body); }); } const disposition = headerValue(headers, "content-disposition"); if (!/^attachment(?:;|$)/i.test(disposition)) return []; const filename = mimeParameter(disposition, "filename") || mimeParameter(contentType, "name"); const mimeType = contentType.split(";", 1)[0]?.trim() || "application/octet-stream"; const content = headerValue(headers, "content-transfer-encoding").toLowerCase() === "base64" ? Buffer.from(body.replace(/\s+/g, ""), "base64") : Buffer.from(body.replace(/\r?\n$/, ""), "utf8"); return [{ filename, mimeType, size: content.byteLength, dataBase64: content.toString("base64") }]; } function draftInput(value: unknown): { raw: string; threadId?: string } { if (!isRecord(value) || !isRecord(value.message)) return { raw: "" }; const raw = typeof value.message.raw === "string" ? value.message.raw : ""; const threadId = typeof value.message.threadId === "string" ? value.message.threadId : undefined; return threadId ? { raw, threadId } : { raw }; } async function createDraft(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise { const account = accountForRequest(state, request); const accessToken = bearerToken(request); if (!account || !accessToken) { sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } }); return; } const input = draftInput(await jsonBody(request)); const message = decodedRawMessage(input.raw); const split = splitMessage(message); const draft: RecordedDraft = { to: headerValue(split.headers, "to"), body: draftBody(split.headers, split.body), tokenId: tokenId(accessToken), at: new Date().toISOString(), }; if (input.threadId) draft.threadId = input.threadId; const attachments = mimeAttachments(split.headers, split.body); if (attachments.length > 0) draft.attachments = attachments; const mailbox = state.drafts.get(account.email) ?? []; mailbox.push(draft); state.drafts.set(account.email, mailbox); const threadId = input.threadId ?? `thread-${randomUUID()}`; sendJson(response, 200, { id: `draft-${randomUUID()}`, message: { id: `msg-${randomUUID()}`, threadId }, }); } function requestHeader(request: IncomingMessage, name: string): string { const value = request.headers[name.toLowerCase()]; return Array.isArray(value) ? value[0] ?? "" : value ?? ""; } function parseDriveMultipart(body: Buffer, contentType: string): RecordedAttachment | null { const boundary = mimeParameter(contentType, "boundary"); if (!boundary) return null; const partBoundary = Buffer.from(`\r\n--${boundary}\r\n`, "utf8"); const firstHeaderEnd = body.indexOf(Buffer.from("\r\n\r\n", "utf8")); const secondPart = body.indexOf(partBoundary); if (firstHeaderEnd < 0 || secondPart < 0 || secondPart <= firstHeaderEnd) return null; const metadataText = body.subarray(firstHeaderEnd + 4, secondPart).toString("utf8"); let metadata: unknown; try { metadata = JSON.parse(metadataText); } catch { return null; } const filename = isRecord(metadata) && typeof metadata.name === "string" ? metadata.name : ""; if (!filename) return null; const secondHeaderStart = secondPart + partBoundary.byteLength; const secondHeaderEnd = body.indexOf(Buffer.from("\r\n\r\n", "utf8"), secondHeaderStart); const footer = Buffer.from(`\r\n--${boundary}--`, "utf8"); const contentEnd = body.lastIndexOf(footer); if (secondHeaderEnd < 0 || contentEnd < 0 || contentEnd < secondHeaderEnd) return null; const headers = body.subarray(secondHeaderStart, secondHeaderEnd).toString("utf8"); const mimeType = headerValue(headers, "content-type") || "application/octet-stream"; const content = body.subarray(secondHeaderEnd + 4, contentEnd); return { filename, mimeType, size: content.byteLength, dataBase64: content.toString("base64") }; } async function createDriveUpload(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise { const account = accountForRequest(state, request); const accessToken = bearerToken(request); if (!account || !accessToken) { sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } }); return; } const upload = parseDriveMultipart(await requestBuffer(request), requestHeader(request, "content-type")); if (!upload) { sendJson(response, 400, { error: { code: 400, message: "Invalid multipart upload" } }); return; } const recorded: RecordedDriveUpload = { ...upload, tokenId: tokenId(accessToken), at: new Date().toISOString(), }; const uploads = state.driveUploads.get(account.email) ?? []; uploads.push(recorded); state.driveUploads.set(account.email, uploads); const id = `drive-${randomUUID()}`; sendJson(response, 200, { id, name: upload.filename, mimeType: upload.mimeType, modifiedTime: recorded.at, webViewLink: `https://drive.google.test/file/d/${id}/view`, size: String(upload.size), }); } function pendingAuthorizations(state: MockGoogleState): unknown { return { pending: Array.from(state.pending.values()).map((entry) => ({ id: entry.id, accounts: state.accounts.map((account) => { const url = new URL("/choose-account", state.baseUrl); url.searchParams.set("request_id", entry.id); url.searchParams.set("email", account.email); return { email: account.email, chooseUrl: url.toString() }; }), })), }; } async function handleRequest(state: MockGoogleState, request: IncomingMessage, response: ServerResponse): Promise { const url = requestUrl(request, state.baseUrl); const requestMethod = method(request); recordRequest(state, request, url); if (requestMethod === "OPTIONS") { sendJson(response, 204, {}); return; } if (requestMethod === "GET" && url.pathname === "/health") { sendJson(response, 200, { ok: true, accounts: state.accounts.map((account) => account.email), autoApprove: state.autoApprove }); return; } if (requestMethod === "GET" && url.pathname === "/requests") { sendJson(response, 200, { requests: state.requests }); return; } if (requestMethod === "GET" && url.pathname === "/__mock-google/pending-authorizations") { sendJson(response, 200, pendingAuthorizations(state)); return; } if (requestMethod === "GET" && url.pathname === "/__mock-google/drafts") { sendJson(response, 200, { drafts: state.drafts.get((url.searchParams.get("email") ?? "").toLowerCase()) ?? [] }); return; } if (requestMethod === "GET" && url.pathname === "/__mock-google/drive-uploads") { sendJson(response, 200, { uploads: state.driveUploads.get((url.searchParams.get("email") ?? "").toLowerCase()) ?? [] }); return; } if (requestMethod === "GET" && url.pathname === "/authorize") { authorize(state, url, response); return; } if (requestMethod === "GET" && url.pathname === "/choose-account") { chooseAccount(state, url, response); return; } if (requestMethod === "POST" && url.pathname === "/approve") { approve(state, url, response); return; } if (requestMethod === "POST" && url.pathname === "/token") { await token(state, request, response); return; } if (requestMethod === "GET" && url.pathname === "/userinfo") { userinfo(state, request, response); return; } if (requestMethod === "GET" && url.pathname === "/gmail/v1/users/me/profile") { const account = accountForRequest(state, request); if (!account) { sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } }); return; } sendJson(response, 200, { emailAddress: account.email, messagesTotal: 0, threadsTotal: 0, historyId: "1" }); return; } if (requestMethod === "GET" && url.pathname === "/gmail/v1/users/me/messages") { if (!accountForRequest(state, request)) { sendJson(response, 401, { error: { code: 401, message: "Invalid Credentials" } }); return; } sendJson(response, 200, { messages: [], resultSizeEstimate: 0 }); return; } if (requestMethod === "POST" && url.pathname === "/gmail/v1/users/me/drafts") { await createDraft(state, request, response); return; } if (requestMethod === "POST" && url.pathname === "/upload/drive/v3/files") { await createDriveUpload(state, request, response); return; } sendJson(response, 404, { error: "not_found" }); } function listen(server: Server, port: number): Promise { return new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", () => { server.off("error", reject); const address = server.address(); if (isAddressInfo(address)) { resolve(address); return; } reject(new Error("Mock Google server did not expose a TCP port.")); }); }); } function close(server: Server): Promise { return new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); server.closeIdleConnections(); }); } export async function startMockGoogleServer(options: MockGoogleServerOptions): Promise { const state: MockGoogleState = { accounts: normalizeAccounts(options.accounts), autoApprove: options.autoApprove, baseUrl: options.baseUrl ?? "http://127.0.0.1", requests: [], pending: new Map(), codes: new Map(), accessTokens: new Map(), refreshTokens: new Map(), drafts: new Map(), driveUploads: new Map(), keys: createSigningKeys(), }; const server = createServer((request, response) => { void handleRequest(state, request, response).catch((error) => { sendJson(response, 500, { error: "mock_google_error", message: error instanceof Error ? error.message : String(error) }); }); }); const address = await listen(server, options.port); state.baseUrl = options.baseUrl ?? `http://127.0.0.1:${address.port}`; let stopped = false; return { baseUrl: state.baseUrl, async stop() { if (stopped) return; stopped = true; await close(server); }, }; }