diff --git a/src/auth_kv.ts b/src/auth_kv.ts index 185d2bf..b9f3f41 100644 --- a/src/auth_kv.ts +++ b/src/auth_kv.ts @@ -1,4 +1,5 @@ import { AuthDotJson, TokenData, RefreshRequest, RefreshResponse, Env } from "./types"; +import { redactHeadersForLogging } from "./log_redaction"; type JwtClaims = { "https://api.openai.com/auth"?: { @@ -7,9 +8,7 @@ type JwtClaims = { } & Record; function urlBase64Decode(input: string): string { - // Replace non-url-safe chars with url-safe ones input = input.replace(/-/g, "+").replace(/_/g, "/"); - // Pad out with = for base64.decode to work const pad = input.length % 4; if (pad) { input += new Array(5 - pad).join("="); @@ -62,7 +61,6 @@ export async function getEffectiveChatgptAuth( } } -// Token refresh functionality export async function refreshAccessToken(env: Env): Promise { if (!env.OPENAI_CODEX_AUTH) { return null; @@ -78,7 +76,7 @@ export async function refreshAccessToken(env: Env): Promise { } const clientId = env.CHATGPT_LOCAL_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann"; - + const tokenEndpoint = "https://auth.openai.com/oauth/token"; const refreshRequest: RefreshRequest = { client_id: clientId, grant_type: "refresh_token", @@ -86,7 +84,7 @@ export async function refreshAccessToken(env: Env): Promise { scope: "openid profile email" }; - const response = await fetch("https://auth.openai.com/oauth/token", { + const response = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/json" @@ -96,18 +94,23 @@ export async function refreshAccessToken(env: Env): Promise { if (!response.ok) { const errorText = await response.text().catch(() => "Unable to read error response"); + const correlationId = + response.headers.get("x-request-id") || response.headers.get("cf-ray") || response.headers.get("traceparent"); console.error("=== TOKEN REFRESH FAILURE ==="); console.error("Status:", response.status, response.statusText); - console.error("Response Headers:", Object.fromEntries(response.headers.entries())); - console.error("Error Body:", errorText); - console.error("Request Body:", JSON.stringify(refreshRequest, null, 2)); + console.error("Endpoint:", tokenEndpoint); + if (correlationId) { + console.error("Correlation ID:", correlationId); + } + if (env.VERBOSE === "true") { + console.error("Response Headers:", redactHeadersForLogging(response.headers)); + console.error("Error Body:", errorText); + } console.error("============================="); return null; } const refreshResponse: RefreshResponse = await response.json(); - - // Update tokens const updatedTokens: TokenData = { id_token: refreshResponse.id_token, access_token: refreshResponse.access_token || tokens.access_token, @@ -115,15 +118,6 @@ export async function refreshAccessToken(env: Env): Promise { account_id: tokens.account_id }; - // Update the auth in environment (this is a limitation - we can't modify env vars directly) - // In a real implementation, you'd want to update the stored auth.json - // const updatedAuth: AuthDotJson = { - // ...auth, - // tokens: updatedTokens, - // last_refresh: new Date().toISOString() - // }; - - // Store in KV if available if (env.KV) { await env.KV.put("auth_tokens", JSON.stringify(updatedTokens)); await env.KV.put("auth_last_refresh", new Date().toISOString()); @@ -142,14 +136,12 @@ export async function refreshAccessToken(env: Env): Promise { } export async function getRefreshedAuth(env: Env): Promise<{ accessToken: string | null; accountId: string | null }> { - // First try to get current auth const currentAuth = await getEffectiveChatgptAuth(env); if (!currentAuth.accessToken) { return currentAuth; } - // Check if token needs refresh (older than 28 days or if we have KV storage with newer tokens) let needsRefresh = false; if (env.OPENAI_CODEX_AUTH) { @@ -167,14 +159,12 @@ export async function getRefreshedAuth(env: Env): Promise<{ accessToken: string } } - // Check KV for newer tokens if (env.KV && !needsRefresh) { try { const kvLastRefresh = await env.KV.get("auth_last_refresh"); if (kvLastRefresh) { const kvRefreshTime = new Date(kvLastRefresh); if (kvRefreshTime.getTime() > Date.now() - 28 * 24 * 60 * 60 * 1000) { - // KV has newer tokens, use those const kvTokens = await env.KV.get("auth_tokens", "json"); if (kvTokens) { const tokens = kvTokens as TokenData; @@ -190,7 +180,6 @@ export async function getRefreshedAuth(env: Env): Promise<{ accessToken: string } } - // Refresh if needed if (needsRefresh) { const refreshedTokens = await refreshAccessToken(env); if (refreshedTokens) { diff --git a/src/log_redaction.ts b/src/log_redaction.ts new file mode 100644 index 0000000..4e1e373 --- /dev/null +++ b/src/log_redaction.ts @@ -0,0 +1,71 @@ +const SENSITIVE_KEYS = new Set([ + "authorization", + "chatgpt-account-id", + "chatgptaccountid", + "session_id", + "sessionid", + "refresh_token", + "access_token", + "id_token", + "token", + "api_key", + "apikey", + "password", + "secret", + "cookie", + "set-cookie", + "email" +]); + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === "[object Object]"; +} + +export function maskSecret(value: string): string { + if (!value) { + return "[REDACTED]"; + } + const visibleChars = 4; + if (value.length <= visibleChars) { + return "*".repeat(value.length); + } + return `${"*".repeat(Math.max(4, value.length - visibleChars))}${value.slice(-visibleChars)}`; +} + +function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase(); + const compact = normalized.replace(/[^a-z0-9]/g, ""); + if (SENSITIVE_KEYS.has(normalized) || SENSITIVE_KEYS.has(compact)) { + return true; + } + return compact.includes("token") || compact.includes("secret") || compact.includes("password"); +} + +export function redactForLogging(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactForLogging(item)); + } + + if (isPlainObject(value)) { + const redacted: Record = {}; + for (const [key, val] of Object.entries(value)) { + redacted[key] = isSensitiveKey(key) + ? typeof val === "string" + ? maskSecret(val) + : "[REDACTED]" + : redactForLogging(val); + } + return redacted; + } + + return value; +} + +export function redactHeadersForLogging(headers: HeadersInit): Record { + const entries = new Headers(headers).entries(); + const normalized: Record = {}; + for (const [key, value] of entries) { + normalized[key] = value; + } + return redactForLogging(normalized) as Record; +} diff --git a/src/upstream.ts b/src/upstream.ts index 38b2cfa..bd5a873 100644 --- a/src/upstream.ts +++ b/src/upstream.ts @@ -2,6 +2,7 @@ import { normalizeModelName } from "./utils"; import { getRefreshedAuth, refreshAccessToken } from "./auth_kv"; // Updated import import { getInstructionsForModel } from "./instructions"; import { Env, InputItem, Tool } from "./types"; // Import types +import { redactForLogging, redactHeadersForLogging } from "./log_redaction"; type ReasoningParam = { effort?: string; @@ -20,6 +21,17 @@ type ErrorBody = { [key: string]: unknown; }; +function getSanitizedRequestBodyForLogging(requestBody: string | undefined): unknown { + if (!requestBody) { + return null; + } + try { + return redactForLogging(JSON.parse(requestBody)); + } catch { + return "[UNPARSEABLE_REQUEST_BODY]"; + } +} + async function generateSessionId(instructions: string | undefined, inputItems: InputItem[]): Promise { const content = `${instructions || ""}|${JSON.stringify(inputItems)}`; const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content)); @@ -42,11 +54,10 @@ export async function startUpstreamRequest( } ): Promise<{ response: Response | null; error: Response | null }> { const { instructions, tools, toolChoice, parallelToolCalls, reasoningParam } = options || {}; + const verbose = env.VERBOSE === "true"; const { accessToken, accountId } = await getRefreshedAuth(env); - // KV token check (minimal logging) - if (!accessToken || !accountId) { return { response: null, @@ -79,7 +90,7 @@ export async function startUpstreamRequest( ? JSON.stringify(options?.ollamaPayload) : JSON.stringify({ model: normalizeModelName(model, env.DEBUG_MODEL), - instructions: instructions || baseInstructions, // Use fetched instructions + instructions: instructions || baseInstructions, input: inputItems, tools: tools || [], tool_choice: @@ -93,7 +104,7 @@ export async function startUpstreamRequest( include: include, prompt_cache_key: sessionId, ...(reasoningParam && { reasoning: reasoningParam }) - }); + }); const headers: HeadersInit = { "Content-Type": "application/json" @@ -114,48 +125,42 @@ export async function startUpstreamRequest( method: "POST", headers: headers, body: requestBody - // Cloudflare Workers fetch does not have a 'timeout' option like requests. - // You might need to implement a custom timeout using AbortController if necessary. }); - // Response received - if (!upstreamResponse.ok) { - // Handle HTTP errors from upstream - const errorBody = (await upstreamResponse - .json() - .catch(() => ({ raw: upstreamResponse.statusText }))) as ErrorBody; + const errorBody = (await upstreamResponse.json().catch(() => ({ raw: upstreamResponse.statusText }))) as ErrorBody; - // Log complete error details for OpenAI failures console.error("=== OPENAI API ERROR ==="); console.error("Status:", upstreamResponse.status, upstreamResponse.statusText); console.error("URL:", requestUrl); - console.error("Headers:", Object.fromEntries(upstreamResponse.headers.entries())); - console.error("Error Body:", JSON.stringify(errorBody, null, 2)); - console.error("Request Body:", requestBody); + console.error("Error Body:", JSON.stringify(redactForLogging(errorBody), null, 2)); + if (verbose) { + console.error("Response Headers:", redactHeadersForLogging(upstreamResponse.headers)); + console.error("Request Headers:", redactHeadersForLogging(headers)); + console.error("Request Body:", getSanitizedRequestBodyForLogging(requestBody)); + } console.error("========================"); - // Check if it's a 401 Unauthorized and we can refresh the token if (upstreamResponse.status === 401 && env.OPENAI_CODEX_AUTH) { const refreshedTokens = await refreshAccessToken(env); if (refreshedTokens) { - const headers: HeadersInit = { + const retryHeaders: HeadersInit = { "Content-Type": "application/json" }; if (!isOllamaRequest) { - headers["Authorization"] = `Bearer ${refreshedTokens.access_token}`; - headers["Accept"] = "text/event-stream"; - headers["chatgpt-account-id"] = refreshedTokens.account_id || accountId; - headers["OpenAI-Beta"] = "responses=experimental"; + retryHeaders["Authorization"] = `Bearer ${refreshedTokens.access_token}`; + retryHeaders["Accept"] = "text/event-stream"; + retryHeaders["chatgpt-account-id"] = refreshedTokens.account_id || accountId; + retryHeaders["OpenAI-Beta"] = "responses=experimental"; if (sessionId) { - headers["session_id"] = sessionId; + retryHeaders["session_id"] = sessionId; } } const retryResponse = await fetch(requestUrl, { method: "POST", - headers: headers, + headers: retryHeaders, body: requestBody }); @@ -180,11 +185,12 @@ export async function startUpstreamRequest( return { response: upstreamResponse, error: null }; } catch (e: unknown) { - // Log complete error details for fetch failures console.error("=== UPSTREAM REQUEST FAILURE ==="); console.error("URL:", requestUrl); - console.error("Request Body:", requestBody); - console.error("Headers:", headers); + if (verbose) { + console.error("Request Headers:", redactHeadersForLogging(headers)); + console.error("Request Body:", getSanitizedRequestBodyForLogging(requestBody)); + } console.error("Error:", e); if (e instanceof Error) { console.error("Error Message:", e.message); diff --git a/test/log_redaction.spec.ts b/test/log_redaction.spec.ts new file mode 100644 index 0000000..671bafa --- /dev/null +++ b/test/log_redaction.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { maskSecret, redactForLogging, redactHeadersForLogging } from "../src/log_redaction"; + +describe("log redaction", () => { + it("masks secret values while preserving last four characters", () => { + expect(maskSecret("abcd")).toBe("****"); + expect(maskSecret("abcdefghijkl")).toBe("********ijkl"); + }); + + it("redacts known sensitive key names recursively", () => { + const payload = { + authorization: "Bearer top-secret-token", + chatgptAccountId: "acc_123456789", + nested: { + session_id: "session-secret", + refresh_token: "refresh-secret" + }, + safe: "value" + }; + + const redacted = redactForLogging(payload) as Record; + + expect(redacted.authorization).toBe("*******************oken"); + expect(redacted.chatgptAccountId).toBe("*********6789"); + expect((redacted.nested as Record).session_id).toBe("**********cret"); + expect((redacted.nested as Record).refresh_token).toBe("**********cret"); + expect(redacted.safe).toBe("value"); + }); + + it("redacts sensitive headers", () => { + const headers = redactHeadersForLogging({ + Authorization: "Bearer abcdefghijkl", + "chatgpt-account-id": "account-123456", + "x-custom": "visible" + }); + + expect(headers.authorization).toBe("***************ijkl"); + expect(headers["chatgpt-account-id"]).toBe("**********3456"); + expect(headers["x-custom"]).toBe("visible"); + }); +});