Skip to content

[Bug]: ForwardAdmissionCredentialError blocks direct-mode OpenAI forwarding when caller authenticates with the proxy admission token #1687

Description

@rrmlima

Summary

In direct mode (codexAccountMode: "direct"), forwarding to the ChatGPT/OpenAI upstream fails with ForwardAdmissionCredentialError whenever the caller authenticates with the proxy's own admission token via Authorization: Bearer (the env_key contract). The guard validateForwardAdmissionCredential rejects the admission bearer unconditionally on current main, even though the proxy has a main Codex account token available to substitute.

This regressed in the 2.14.x WebSocket refactor: the 2.12-era guard if (getMainAccountToken()) return; (which allowed the forward when a substitution is possible) was removed together with the ctx.kind === "main" substitution block in headersForCodexAuthContext.

Reproduction

# config.toml:
# [model_providers.opencodex]
# base_url = "http://127.0.0.1:10100/v1"
# env_key = "OPENCODEX_API_AUTH_TOKEN"

# config.json:
# "providers": { "openai": { "codexAccountMode": "direct", "authMode": "forward" } }
# ~/.codex/auth.json contains a main ChatGPT account (getMainAccountToken() non-null)

curl -X POST http://127.0.0.1:10100/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENCODEX_API_AUTH_TOKEN" \
  -d '{"model":"gpt-5.6-luna","input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}],"stream":true,"store":false}'

Logs and screenshots

{"error":{"message":"OpenCodex admission credentials cannot be forwarded upstream","type":"invalid_request_error","code":"invalid_request_error"}}

Area

Proxy runtime

Version

2.14.2 (also on current main)

OS

Ubuntu 24.04 (VPS)

Config shape

{
  "providers": {
    "openai": {
      "adapter": "openai-responses",
      "baseUrl": "https://chatgpt.com/backend-api/codex",
      "authMode": "forward",
      "codexAccountMode": "direct"
    }
  }
}

Root cause

src/server/auth-cors.ts validateForwardAdmissionCredential (current main):

export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void {
  const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
  if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
}

In direct mode, resolveCodexAuthContext returns { kind: "main" }, and headersForCodexAuthContext no longer substitutes the admission bearer with the real ChatGPT token (getMainAccountToken() from ~/.codex/auth.json) — the ctx.kind === "main" block was removed in the WebSocket refactor. The 2.12 version of this guard was:

if (!bearer || !isProxyAdmissionSecret(bearer, config)) return;
if (getMainAccountToken()) return;  // substitution happens in headersForCodexAuthContext
throw new ForwardAdmissionCredentialError();

Proposed fix

  1. Restore the guard in validateForwardAdmissionCredential:
 export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void {
   const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
-  if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
+  if (bearer && isProxyAdmissionSecret(bearer, config)) {
+    // Safe to forward-substitute only when a main account token exists
+    // (headersForCodexAuthContext performs the substitution).
+    if (getMainAccountToken()) return;
+    throw new ForwardAdmissionCredentialError();
+  }
 }
  1. Restore the ctx.kind === "main" block in headersForCodexAuthContext (src/codex/auth-context.ts) that replaces the admission bearer with Bearer ${main.accessToken} + chatgpt-account-id when bearer === process.env.OPENCODEX_API_AUTH_TOKEN.

Alternatives considered

  • Keep rejecting unconditionally and require clients to send the real ChatGPT token: breaks the env_key injection contract and the whole point of codexAccountMode: "direct" with a proxy-managed main account. Not viable.
  • Only document the limitation: the regression is a behavior change from 2.12; should be fixed, not documented.

Additional context

This mirrors how other gateways solve the same problem: the OmniRoute ChatGPT executor uses the stored session token plus a chatgpt-account-id header to route to the real account (open-sse/executors/chatgpt-web.ts:3055if (tokenEntry.accountId) headers["chatgpt-account-id"] = tokenEntry.accountId;). Substituting the caller's admission credential with the pool/main account credential is the established pattern; the proxy just needs to keep doing it in the WS path.

Security note: without a main account token the guard still throws (fail-closed), so the change only relaxes the check when a substitution is actually possible.

Test matrix:

  • direct mode + admission bearer + main account present → 200 (token substituted upstream)
  • direct mode + admission bearer + no main account → 401 (fail-closed, unchanged)
  • pool mode → unchanged (pool accessToken override)
  • x-opencodex-api-key auth (non-bearer) → unchanged (no admission-bearer path)

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management authstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions