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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 13 additions & 24 deletions src/auth_kv.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AuthDotJson, TokenData, RefreshRequest, RefreshResponse, Env } from "./types";
import { redactHeadersForLogging } from "./log_redaction";

type JwtClaims = {
"https://api.openai.com/auth"?: {
Expand All @@ -7,9 +8,7 @@ type JwtClaims = {
} & Record<string, unknown>;

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("=");
Expand Down Expand Up @@ -62,7 +61,6 @@ export async function getEffectiveChatgptAuth(
}
}

// Token refresh functionality
export async function refreshAccessToken(env: Env): Promise<TokenData | null> {
if (!env.OPENAI_CODEX_AUTH) {
return null;
Expand All @@ -78,15 +76,15 @@ export async function refreshAccessToken(env: Env): Promise<TokenData | null> {
}

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",
refresh_token: tokens.refresh_token,
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"
Expand All @@ -96,34 +94,30 @@ export async function refreshAccessToken(env: Env): Promise<TokenData | null> {

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,
refresh_token: refreshResponse.refresh_token || tokens.refresh_token,
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());
Expand All @@ -142,14 +136,12 @@ export async function refreshAccessToken(env: Env): Promise<TokenData | null> {
}

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) {
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions src/log_redaction.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown> = {};
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<string, string> {
const entries = new Headers(headers).entries();
const normalized: Record<string, string> = {};
for (const [key, value] of entries) {
normalized[key] = value;
}
return redactForLogging(normalized) as Record<string, string>;
}
60 changes: 33 additions & 27 deletions src/upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string> {
const content = `${instructions || ""}|${JSON.stringify(inputItems)}`;
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -93,7 +104,7 @@ export async function startUpstreamRequest(
include: include,
prompt_cache_key: sessionId,
...(reasoningParam && { reasoning: reasoningParam })
});
});

const headers: HeadersInit = {
"Content-Type": "application/json"
Expand All @@ -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
});

Expand All @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions test/log_redaction.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

expect(redacted.authorization).toBe("*******************oken");
expect(redacted.chatgptAccountId).toBe("*********6789");
expect((redacted.nested as Record<string, unknown>).session_id).toBe("**********cret");
expect((redacted.nested as Record<string, unknown>).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");
});
});