-
Notifications
You must be signed in to change notification settings - Fork 779
fix(auth): serve env_key bearer admission on Direct by substituting stored main auth #1861
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,8 @@ import { | |
| CodexPoolAuthenticationError, | ||
| CodexThreadAffinityExpiredError, | ||
| headersForCodexAuthContext, | ||
| materializeCodexUpstreamAuth, | ||
| CodexMainSubstitutionUnavailableError, | ||
| isCodexAuthContextUsable, | ||
| resolveCodexAuthContext, | ||
| codexProbeLeaseId, | ||
|
|
@@ -114,6 +116,7 @@ import { | |
| prepareSameTarget429Wait, | ||
| } from "../../lib/upstream-retry"; | ||
| import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; | ||
| import type { DataPlaneAdmission } from "../auth-cors"; | ||
| import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; | ||
| import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; | ||
| import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; | ||
|
|
@@ -755,6 +758,15 @@ export interface ConsumedComboFailure { | |
|
|
||
| export interface HandleResponsesOptions { | ||
| turnAdmissionLease?: AdmissionLease; | ||
| /** | ||
| * How the caller proved data-plane admission (#1686). | ||
| * | ||
| * A bearer-presented admission secret is one of OUR OWN secrets, so a Direct turn must | ||
| * SUBSTITUTE the stored main credential rather than forward it. Without this fact at the | ||
| * decision point, Direct cannot tell an admission bearer from the user own ChatGPT bearer, | ||
| * which is why it refused the whole env_key flow instead of serving it. | ||
| */ | ||
| admission?: DataPlaneAdmission; | ||
| /** Called at most once after the complete client body is read and accepted for dispatch. */ | ||
| onRequestBodyRead?: () => void; | ||
| forceEmptyResponseId?: boolean; | ||
|
|
@@ -988,7 +1000,14 @@ async function resolveResponsesCodexAuth( | |
| options: HandleResponsesOptions, | ||
| ): Promise<ResponsesAuthResolution> { | ||
| try { | ||
| if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config); | ||
| // #1686: a caller that proved admission with a BEARER presented one of our own secrets. | ||
| // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct. | ||
| // Admitting it is only safe because the stored main credential is substituted below, so | ||
| // the admission secret still never leaves this process. | ||
| const substituteMainCredential = options.admission?.source === "bearer"; | ||
| if (route.codexAccountMode === "direct" && !substituteMainCredential) { | ||
| validateForwardAdmissionCredential(req.headers, config); | ||
|
Comment on lines
+1007
to
+1009
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the default loopback hostname, Useful? React with 👍 / 👎. |
||
| } | ||
| let authCtx: CodexAuthContext; | ||
| if (route.codexAccountMode) { | ||
| authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { | ||
|
|
@@ -1011,7 +1030,7 @@ async function resolveResponsesCodexAuth( | |
| return { | ||
| ok: true, | ||
| authCtx, | ||
| headers: headersForCodexAuthContext(req.headers, authCtx), | ||
| headers: materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential }), | ||
| }; | ||
| } catch (err) { | ||
| if (err instanceof CodexAccountCooldownError) { | ||
|
|
@@ -1045,6 +1064,13 @@ async function resolveResponsesCodexAuth( | |
| if (err instanceof ForwardAdmissionCredentialError) { | ||
| return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; | ||
| } | ||
| if (err instanceof CodexMainSubstitutionUnavailableError) { | ||
| // Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret. | ||
| return { | ||
| ok: false, | ||
| response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"), | ||
| }; | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | ||
| import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { saveConfig } from "../src/config"; | ||
| import { startServer } from "../src/server"; | ||
| import type { OcxConfig } from "../src/types"; | ||
|
|
||
| /** | ||
| * #1686 end to end: a Codex client injected with `env_key` presents the proxy admission | ||
| * secret as `Authorization: Bearer`. Direct must ADMIT that request and substitute the | ||
| * stored main credential; the admission secret must never appear upstream. | ||
| * | ||
| * The unit matrix in tests/codex-auth-context.test.ts proves the materializer in isolation. | ||
| * It cannot prove the HTTP handler passes the presentation source down to it, which is the | ||
| * half that was missing: the source was resolved at the door and dropped one frame later. | ||
| */ | ||
|
|
||
| const originalFetch = globalThis.fetch; | ||
| const previousOcxHome = process.env.OPENCODEX_HOME; | ||
| const previousCodexHome = process.env.CODEX_HOME; | ||
| const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; | ||
|
|
||
| let ocxHome = ""; | ||
| let codexHome = ""; | ||
| let upstreamAuth: Array<string | null> = []; | ||
|
|
||
| const ADMISSION_SECRET = "ocx_data_envkeysecret"; | ||
|
|
||
| /** A JWT whose `exp` is far in the future, so the stored main token reads as live. */ | ||
| function liveJwt(): string { | ||
| const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url"); | ||
| return `header.${payload}.signature`; | ||
| } | ||
|
|
||
| function directConfig(): OcxConfig { | ||
| return { | ||
| port: 0, | ||
| // Remote bind, so admission is actually required rather than loopback-waived. | ||
| hostname: "0.0.0.0", | ||
| defaultProvider: "openai", | ||
| openaiProviderTierVersion: 2, | ||
| providers: { | ||
| openai: { | ||
| adapter: "openai-responses", | ||
| baseUrl: "https://chatgpt.com/backend-api/codex", | ||
| authMode: "forward", | ||
| codexAccountMode: "direct", | ||
| defaultModel: "gpt-5.6-luna", | ||
| }, | ||
| }, | ||
| apiKeys: [ | ||
| { id: "env-key", name: "env_key", key: ADMISSION_SECRET, createdAt: "2026-08-16T00:00:00.000Z" }, | ||
| ], | ||
| } as OcxConfig; | ||
| } | ||
|
|
||
| function writeStoredMain(accessToken: string): void { | ||
| writeFileSync( | ||
| join(codexHome, "auth.json"), | ||
| JSON.stringify({ tokens: { access_token: accessToken, account_id: "stored_main_acc" } }), | ||
| ); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| ocxHome = mkdtempSync(join(tmpdir(), "ocx-1686-home-")); | ||
| codexHome = mkdtempSync(join(tmpdir(), "ocx-1686-codex-")); | ||
| process.env.OPENCODEX_HOME = ocxHome; | ||
| process.env.CODEX_HOME = codexHome; | ||
| delete process.env.OPENCODEX_API_AUTH_TOKEN; | ||
| upstreamAuth = []; | ||
| globalThis.fetch = (async (input, init) => { | ||
| const raw = input instanceof Request ? input.url : String(input); | ||
| const url = new URL(raw); | ||
| if (url.hostname === "chatgpt.com" || url.hostname === "api.openai.com") { | ||
| const headers = new Headers(input instanceof Request ? input.headers : init?.headers); | ||
| upstreamAuth.push(headers.get("authorization")); | ||
| return Response.json({ id: "resp_1686", object: "response", status: "completed", output: [] }); | ||
| } | ||
| return originalFetch(input, init); | ||
| }) as typeof fetch; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; | ||
| else process.env.OPENCODEX_HOME = previousOcxHome; | ||
| if (previousCodexHome === undefined) delete process.env.CODEX_HOME; | ||
| else process.env.CODEX_HOME = previousCodexHome; | ||
| if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; | ||
| else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; | ||
| if (ocxHome) rmSync(ocxHome, { recursive: true, force: true }); | ||
| if (codexHome) rmSync(codexHome, { recursive: true, force: true }); | ||
| ocxHome = ""; | ||
| codexHome = ""; | ||
| }); | ||
|
|
||
| async function postResponses(url: string | URL, authorization: string): Promise<Response> { | ||
| return originalFetch(new URL("/v1/responses", url), { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json", authorization }, | ||
| body: JSON.stringify({ model: "gpt-5.6-luna", input: "hi", stream: false }), | ||
| }); | ||
| } | ||
|
|
||
| describe("#1686 env_key bearer admission reaches Direct with substitution", () => { | ||
| test("an admission bearer is served and the stored main credential goes upstream", async () => { | ||
| saveConfig(directConfig()); | ||
| const stored = liveJwt(); | ||
| writeStoredMain(stored); | ||
|
|
||
| const server = startServer(0); | ||
| try { | ||
| const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`); | ||
|
|
||
| // Before this change the same request answered 401: admission accepted the bearer at the | ||
| // door, then Direct refused it because it could not tell it from a user's own credential. | ||
| expect(response.status).toBe(200); | ||
| expect(upstreamAuth).toEqual([`Bearer ${stored}`]); | ||
| // The proof that matters: our own secret never reached the wire. | ||
| expect(upstreamAuth.join("|")).not.toContain(ADMISSION_SECRET); | ||
| } finally { | ||
| await server.stop(true); | ||
| } | ||
| }); | ||
|
|
||
| test("substitution fails closed before any upstream I/O when no main credential is stored", async () => { | ||
| saveConfig(directConfig()); | ||
| writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} })); | ||
|
|
||
| const server = startServer(0); | ||
| try { | ||
| const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`); | ||
|
|
||
| expect(response.status).toBe(401); | ||
| // Falling through would have forwarded the admission secret, which is the leak the | ||
| // forward guard exists to prevent. Nothing may reach an upstream on this path. | ||
| expect(upstreamAuth).toHaveLength(0); | ||
| } finally { | ||
| await server.stop(true); | ||
| } | ||
| }); | ||
|
|
||
| test("a foreign bearer is still Codex Direct passthrough, not admission", async () => { | ||
| saveConfig(directConfig()); | ||
| writeStoredMain(liveJwt()); | ||
|
|
||
| const server = startServer(0); | ||
| try { | ||
| // A real ChatGPT credential is NOT one of our secrets, so it must not be admitted as one. | ||
| const response = await postResponses(server.url, "Bearer sk-user-chatgpt-token"); | ||
| expect(response.status).toBe(401); | ||
| expect(upstreamAuth).toHaveLength(0); | ||
| } finally { | ||
| await server.stop(true); | ||
| } | ||
| }); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a remotely admitted
/v1/responses/compactDirect request using a proxy bearer,materializeCodexUpstreamAuththrowsCodexMainSubstitutionUnavailableErrorwhen the stored main credential is absent or expired. The surrounding compact-auth catch does not handle that newly imported error and rethrows it, after which the server-level wrapper converts it to a generic 500, whereas the regular Responses path returns the intended 401. Catch this error here and return the same authentication response while continuing to fail before upstream I/O.Useful? React with 👍 / 👎.