import fs from "node:fs/promises"; import type { ConnectionOptions } from "node:tls"; import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime"; import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher"; import { buildHostnameAllowlistPolicyFromSuffixAllowlist, fetchWithSsrFGuard, } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES } from "./google-auth-limits.js"; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; type GoogleAuthRuntime = typeof import("google-auth-library"); type GoogleAuthTransport = InstanceType; type GoogleAuthTransportOptions = NonNullable< ConstructorParameters[0] >; type GoogleAuthTransportInit = GoogleAuthTransportOptions & { dispatcher?: unknown }; type ProxyRule = NonNullable[number]; type TlsOptions = { cert?: ConnectionOptions["cert"]; key?: ConnectionOptions["key"]; }; type ProxyAgentLike = { connectOpts?: TlsOptions; proxy: URL; }; type TlsAgentLike = { options?: TlsOptions; }; type GoogleChatServiceAccountCredentials = Record & import("google-auth-library").JWTInput & { client_email: string; private_key: string; }; const GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES = ["accounts.google.com", "googleapis.com"]; const GOOGLE_AUTH_POLICY = buildHostnameAllowlistPolicyFromSuffixAllowlist( GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES, ); const GOOGLE_AUTH_FETCH_TIMEOUT_MS = 30_000; const GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth"; const GOOGLE_AUTH_PROVIDER_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs"; const GOOGLE_AUTH_TOKEN_URI = "https://oauth2.googleapis.com/token"; const GOOGLE_AUTH_UNIVERSE_DOMAIN = "googleapis.com"; const GOOGLE_CLIENT_CERTS_URL_PREFIX = "https://www.googleapis.com/robot/v1/metadata/x509/"; const MAX_GOOGLE_AUTH_RESPONSE_BYTES = 1024 * 1024; let googleAuthRuntimePromise: Promise | null = null; function normalizeGoogleAuthPreparedRequestHeaders( config: T, ): T & { headers: Headers } { if (!(config.headers instanceof Headers)) { config.headers = new Headers(config.headers as HeadersInit | undefined); } return config as T & { headers: Headers }; } function normalizeGoogleAuthResponseHeaders( response: T, ): T & { headers: Headers } { if (!(response.headers instanceof Headers)) { response.headers = new Headers(response.headers as HeadersInit | undefined); } return response as T & { headers: Headers }; } function installGoogleAuthHeaderCompatibilityInterceptor( transport: GoogleAuthTransport, ): GoogleAuthTransport { transport.interceptors.request.add({ resolved: async (config) => normalizeGoogleAuthPreparedRequestHeaders(config), }); transport.interceptors.response.add({ resolved: async (response) => normalizeGoogleAuthResponseHeaders(response), }); return transport; } function asNullableObjectRecord(value: unknown): Record | null { return value !== null && typeof value === "object" ? (value as Record) : null; } function hasProxyAgentShape(value: unknown): value is ProxyAgentLike { const record = asNullableObjectRecord(value); return record !== null && record.proxy instanceof URL; } function hasTlsAgentShape(value: unknown): value is TlsAgentLike { const record = asNullableObjectRecord(value); return record !== null && asNullableObjectRecord(record.options) !== null; } function resolveGoogleAuthAgent(init: GoogleAuthTransportOptions, url: URL): unknown { return typeof init.agent === "function" ? init.agent(url) : init.agent; } function hasTlsOptions(options: TlsOptions): boolean { return options.cert !== undefined || options.key !== undefined; } function resolveGoogleAuthTlsOptions(init: GoogleAuthTransportOptions, url: URL): TlsOptions { const explicit = { cert: init.cert, key: init.key, }; if (hasTlsOptions(explicit)) { return explicit; } const agent = resolveGoogleAuthAgent(init, url); if (hasProxyAgentShape(agent)) { return { cert: agent.connectOpts?.cert, key: agent.connectOpts?.key, }; } if (hasTlsAgentShape(agent)) { return { cert: agent.options?.cert, key: agent.options?.key, }; } return {}; } function normalizeGoogleAuthProxyEnvValue(value: string | undefined): string | null | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function resolveGoogleAuthEnvProxyUrl(protocol: "http" | "https"): string | undefined { const httpProxy = normalizeGoogleAuthProxyEnvValue(process.env.HTTP_PROXY) ?? normalizeGoogleAuthProxyEnvValue(process.env.http_proxy); const httpsProxy = normalizeGoogleAuthProxyEnvValue(process.env.HTTPS_PROXY) ?? normalizeGoogleAuthProxyEnvValue(process.env.https_proxy); if (protocol === "https") { return httpsProxy ?? httpProxy ?? undefined; } return httpProxy ?? undefined; } function collectGoogleAuthNoProxyRules(noProxy: ProxyRule[] = []): ProxyRule[] { const rules = [...noProxy]; const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? []; for (const rule of envRules) { const trimmed = rule.trim(); if (trimmed.length > 0) { rules.push(trimmed); } } return rules; } function shouldBypassGoogleAuthProxy(url: URL, noProxy: ProxyRule[] = []): boolean { for (const rule of collectGoogleAuthNoProxyRules(noProxy)) { if (rule instanceof RegExp) { if (rule.test(url.toString())) { return true; } continue; } if (rule instanceof URL) { if (rule.origin === url.origin) { return true; } continue; } if (rule.startsWith("*.") || rule.startsWith(".")) { const cleanedRule = rule.replace(/^\*\./, "."); if (url.hostname.endsWith(cleanedRule)) { return true; } continue; } if (rule === url.origin || rule === url.hostname || rule === url.href) { return true; } } return false; } function readGoogleAuthProxyUrl(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } if (value instanceof URL) { return value.toString(); } return undefined; } function readOptionalTrimmedString( record: Record, fieldName: string, ): string | undefined { const value = record[fieldName]; if (value === undefined || value === null) { return undefined; } if (typeof value !== "string") { throw new Error(`Google Chat service account field "${fieldName}" must be a string`); } const trimmed = value.trim(); if (!trimmed) { throw new Error(`Google Chat service account field "${fieldName}" cannot be empty`); } return trimmed; } function readRequiredTrimmedString(record: Record, fieldName: string): string { return ( readOptionalTrimmedString(record, fieldName) ?? (() => { throw new Error(`Google Chat service account is missing "${fieldName}"`); })() ); } function assertExactUrlField( record: Record, fieldName: string, expectedUrl: string, ): void { const value = readOptionalTrimmedString(record, fieldName); if (!value) { return; } if (value !== expectedUrl) { throw new Error( `Google Chat service account field "${fieldName}" must be ${expectedUrl}, got ${value}`, ); } } function assertUrlPrefixField( record: Record, fieldName: string, expectedPrefix: string, ): void { const value = readOptionalTrimmedString(record, fieldName); if (!value) { return; } if (!value.startsWith(expectedPrefix)) { throw new Error( `Google Chat service account field "${fieldName}" must start with ${expectedPrefix}, got ${value}`, ); } } function validateGoogleChatServiceAccountCredentials( credentials: Record, ): GoogleChatServiceAccountCredentials { const type = readOptionalTrimmedString(credentials, "type"); if (type && type !== "service_account") { throw new Error(`Google Chat credentials must use service_account auth, got "${type}" instead`); } readRequiredTrimmedString(credentials, "client_email"); readRequiredTrimmedString(credentials, "private_key"); const universeDomain = readOptionalTrimmedString(credentials, "universe_domain"); if (universeDomain && universeDomain !== GOOGLE_AUTH_UNIVERSE_DOMAIN) { throw new Error( `Google Chat service account field "universe_domain" must be ${GOOGLE_AUTH_UNIVERSE_DOMAIN}, got ${universeDomain}`, ); } assertExactUrlField(credentials, "auth_uri", GOOGLE_AUTH_URI); assertExactUrlField(credentials, "auth_provider_x509_cert_url", GOOGLE_AUTH_PROVIDER_CERTS_URL); assertExactUrlField(credentials, "token_uri", GOOGLE_AUTH_TOKEN_URI); assertUrlPrefixField(credentials, "client_x509_cert_url", GOOGLE_CLIENT_CERTS_URL_PREFIX); return credentials as unknown as GoogleChatServiceAccountCredentials; } async function readCredentialsFile(filePath: string): Promise> { const resolvedPath = resolveUserPath(filePath); if (!resolvedPath) { throw new Error("Google Chat service account file path is empty"); } let handle: Awaited> | null; try { handle = await fs.open(resolvedPath, "r"); } catch { throw new Error("Failed to load Google Chat service account file."); } try { const stat = await handle.stat(); if (!stat.isFile()) { throw new Error("Google Chat service account file must be a regular file."); } if (stat.size > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) { throw new Error( `Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`, ); } let raw: string; try { raw = await handle.readFile({ encoding: "utf8" }); } catch { throw new Error("Failed to load Google Chat service account file."); } if (Buffer.byteLength(raw, "utf8") > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) { throw new Error( `Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`, ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error("Invalid Google Chat service account JSON."); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Google Chat service account file must contain a JSON object."); } return parsed as Record; } finally { await handle.close().catch(() => {}); } } function sanitizeGoogleAuthInit(init?: GoogleAuthTransportInit): RequestInit | undefined { if (!init) { return undefined; } const nextInit = { ...init }; delete nextInit.agent; delete nextInit.cert; delete nextInit.dispatcher; delete nextInit.fetchImplementation; delete nextInit.key; delete nextInit.noProxy; delete nextInit.proxy; return nextInit; } function resolveGoogleAuthDispatcherPolicy( input: RequestInfo | URL, init?: RequestInit, ): { dispatcherPolicy?: PinnedDispatcherPolicy; init?: RequestInit; } { const requestUrl = input instanceof Request ? new URL(input.url) : new URL(typeof input === "string" ? input : input.toString()); const nextInit = sanitizeGoogleAuthInit(init); const googleAuthInit = (init ?? {}) as GoogleAuthTransportInit; const tlsOptions = resolveGoogleAuthTlsOptions(googleAuthInit, requestUrl); const proxyBypassed = shouldBypassGoogleAuthProxy( requestUrl, Array.isArray(googleAuthInit.noProxy) ? (googleAuthInit.noProxy as ProxyRule[]) : [], ); const agent = resolveGoogleAuthAgent(googleAuthInit, requestUrl); const explicitProxy = readGoogleAuthProxyUrl(googleAuthInit.proxy) ?? (hasProxyAgentShape(agent) ? agent.proxy.toString() : undefined); if (!proxyBypassed && explicitProxy) { return { dispatcherPolicy: { allowPrivateProxy: true, mode: "explicit-proxy", ...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}), proxyUrl: explicitProxy, }, init: nextInit, }; } const envProxyUrl = proxyBypassed ? undefined : resolveGoogleAuthEnvProxyUrl(requestUrl.protocol === "http:" ? "http" : "https"); if (envProxyUrl) { return { dispatcherPolicy: { mode: "env-proxy", ...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}), }, init: nextInit, }; } if (hasTlsOptions(tlsOptions)) { return { dispatcherPolicy: { connect: { ...tlsOptions }, mode: "direct", }, init: nextInit, }; } return { init: nextInit }; } function createGoogleAuthFetch(): FetchLike { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = input instanceof Request ? input.url : String(input); const guardedOptions = resolveGoogleAuthDispatcherPolicy(input, init); const { response, release } = await fetchWithSsrFGuard({ auditContext: "googlechat.auth.google-auth", dispatcherPolicy: guardedOptions.dispatcherPolicy, init: guardedOptions.init, policy: GOOGLE_AUTH_POLICY, signal: guardedOptions.init?.signal ?? undefined, timeoutMs: GOOGLE_AUTH_FETCH_TIMEOUT_MS, url, }); try { const body = await readGoogleAuthResponseBytes(response); const bufferedBody = Uint8Array.from(body); return new Response(bufferedBody.buffer, { headers: response.headers, status: response.status, statusText: response.statusText, }); } finally { await release(); } }; } async function readGoogleAuthResponseBytes(response: Response): Promise { const contentLengthHeader = response.headers.get("content-length"); if (contentLengthHeader) { const contentLength = parseMediaContentLength(contentLengthHeader); if (contentLength !== null && contentLength > MAX_GOOGLE_AUTH_RESPONSE_BYTES) { throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`); } } const reader = response.body?.getReader(); if (!reader) { throw new Error( "Google auth response body stream unavailable; refusing to buffer unbounded response.", ); } const chunks: Uint8Array[] = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } if (!value) { continue; } total += value.byteLength; if (total > MAX_GOOGLE_AUTH_RESPONSE_BYTES) { try { await reader.cancel("Google auth response exceeded buffer limit"); } catch { // Ignore cancellation errors; the caller still releases the dispatcher. } throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`); } chunks.push(value); } } finally { reader.releaseLock(); } const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return bytes; } export async function loadGoogleAuthRuntime(): Promise { googleAuthRuntimePromise ??= import("google-auth-library").catch((error: unknown) => { googleAuthRuntimePromise = null; throw error; }); return await googleAuthRuntimePromise; } export async function getGoogleAuthTransport(): Promise { const { gaxios } = await loadGoogleAuthRuntime(); return installGoogleAuthHeaderCompatibilityInterceptor( new gaxios.Gaxios({ fetchImplementation: createGoogleAuthFetch(), }), ); } export async function resolveValidatedGoogleChatCredentials( account: ResolvedGoogleChatAccount, ): Promise { if (account.credentials) { return validateGoogleChatServiceAccountCredentials(account.credentials); } if (account.credentialsFile) { const fileCredentials = await readCredentialsFile(account.credentialsFile); return validateGoogleChatServiceAccountCredentials(fileCredentials); } return null; }