-
Notifications
You must be signed in to change notification settings - Fork 766
feat(auth): admit an admission bearer and substitute the stored main credential #1853
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
22d5492
c09ec3c
c2f1393
f419980
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 |
|---|---|---|
|
|
@@ -311,10 +311,20 @@ function secretEquals(actual: string, expected: string | undefined): boolean { | |
| * point at, and a sentinel string in the id would collide with a hand-edited | ||
| * entry that happens to be named `loopback`. | ||
| */ | ||
| /** | ||
| * HOW an admission credential was presented. | ||
| * | ||
| * The credential IDENTITY (which key matched) and its PRESENTATION (which header carried it) | ||
| * are different facts, and #1686 needs both: a proxy secret arriving as a bearer on the | ||
| * Responses transport is admissible, but only if the upstream credential is then guaranteed to | ||
| * be substituted. Collapsing the two is what made that flow unexpressible. | ||
| */ | ||
| export type DataPlaneAdmissionSource = "loopback" | "dedicated" | "bearer" | "x-api-key"; | ||
|
|
||
| export type DataPlaneAdmission = | ||
| | { kind: "configured"; keyId: string } | ||
| | { kind: "environment" } | ||
| | { kind: "loopback" }; | ||
| | { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource } | ||
| | { kind: "environment"; source: DataPlaneAdmissionSource } | ||
| | { kind: "loopback"; source: "loopback" }; | ||
|
|
||
| /** | ||
| * Which admission secret `token` is, or null when it is none of them. | ||
|
|
@@ -325,12 +335,16 @@ export type DataPlaneAdmission = | |
| * discarded, which is what makes per-key attribution possible without touching | ||
| * the admission decision itself. | ||
| */ | ||
| export function resolveDataPlaneAdmissionSecret(token: string, config: Pick<OcxConfig, "apiKeys">): DataPlaneAdmission | null { | ||
| export function resolveDataPlaneAdmissionSecret( | ||
| token: string, | ||
| config: Pick<OcxConfig, "apiKeys">, | ||
| source: DataPlaneAdmissionSource = "dedicated", | ||
| ): DataPlaneAdmission | null { | ||
| const actual = token.trim(); | ||
| if (!actual) return null; | ||
| if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment" }; | ||
| if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment", source }; | ||
| for (const k of config.apiKeys ?? []) { | ||
| if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id }; | ||
| if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id, source }; | ||
| } | ||
| return null; | ||
| } | ||
|
|
@@ -377,8 +391,12 @@ export interface ApiAuthMatrixRow { | |
| * against every cell rather than reading the table back to itself. | ||
| */ | ||
| export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [ | ||
| { endpoint: "/v1/responses", bearer: "rejected", dedicated: "required", xApiKey: "rejected" }, | ||
| { endpoint: "/v1/chat/completions", bearer: "rejected", dedicated: "required", xApiKey: "rejected" }, | ||
| // #1686: a bearer that is one of OUR admission secrets is now accepted here. It is safe | ||
| // because materializeCodexUpstreamAuth substitutes the stored main credential rather than | ||
| // forwarding it; a bearer that is NOT our secret stays unadmitted and remains Codex Direct | ||
| // passthrough, so the two bearer domains still never mix. `x-api-key` is still rejected. | ||
| { endpoint: "/v1/responses", bearer: "accepted", dedicated: "accepted", xApiKey: "rejected" }, | ||
| { endpoint: "/v1/chat/completions", bearer: "accepted", dedicated: "accepted", xApiKey: "rejected" }, | ||
| { endpoint: "/v1/messages", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, | ||
| { endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, | ||
| ]; | ||
|
|
@@ -415,13 +433,15 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx | |
| */ | ||
| export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPlaneAdmission | null { | ||
| // A loopback bind never reads a token at all, so there is no key to name. | ||
| if (!isApiAuthRequired(config)) return { kind: "loopback" }; | ||
| const actual = req.headers.get("x-opencodex-api-key")?.trim() | ||
| || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() | ||
| // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key. | ||
| || req.headers.get("x-api-key")?.trim(); | ||
| if (!actual) return null; | ||
| return resolveDataPlaneAdmissionSecret(actual, config); | ||
| if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" }; | ||
| const dedicated = req.headers.get("x-opencodex-api-key")?.trim(); | ||
| if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated"); | ||
| const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); | ||
| if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer"); | ||
| // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key. | ||
| const apiKey = req.headers.get("x-api-key")?.trim(); | ||
| if (apiKey) return resolveDataPlaneAdmissionSecret(apiKey, config, "x-api-key"); | ||
| return null; | ||
| } | ||
|
|
||
| export function hasValidApiAuth(req: Request, config: RequestPolicyView): boolean { | ||
|
|
@@ -439,14 +459,23 @@ export function requireApiAuth(req: Request, config: RequestPolicyView, _kind: " | |
| * domains can never be confused. | ||
| */ | ||
| export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView): DataPlaneAdmission | null { | ||
| if (!isApiAuthRequired(config)) return { kind: "loopback" }; | ||
| // Dedicated header ONLY. `Authorization` on these transports may belong to | ||
| // Codex Direct passthrough, and the two bearer domains must stay unconfusable. | ||
| const actual = req.headers.get("x-opencodex-api-key")?.trim(); | ||
| if (!actual) return null; | ||
| return resolveDataPlaneAdmissionSecret(actual, config); | ||
| if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" }; | ||
| // The dedicated header still WINS, because it is unambiguous. | ||
| const dedicated = req.headers.get("x-opencodex-api-key")?.trim(); | ||
| if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated"); | ||
| // #1686: a bearer may also be one of OUR admission secrets. Rejecting it outright meant a | ||
| // Codex client configured with `env_key` could not reach Direct at all. Admitting it is only | ||
| // safe because the upstream credential is then SUBSTITUTED rather than forwarded -- see | ||
| // materializeCodexUpstreamAuth. A bearer that is NOT our secret stays unadmitted here and | ||
| // remains Codex Direct passthrough, so the two bearer domains still never mix. | ||
| const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); | ||
| if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer"); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+471
to
+472
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.
When a request selects a custom-named AGENTS.md reference: AGENTS.md:L266-L272 Useful? React with 👍 / 👎. |
||
| // `x-api-key` is deliberately NOT accepted on this transport. | ||
| return null; | ||
| } | ||
|
|
||
|
|
||
|
|
||
| export function requireResponsesApiAuth(req: Request, config: RequestPolicyView): Response | null { | ||
| if (resolveResponsesApiAuth(req, config)) return null; | ||
| return formatErrorResponse(401, "authentication_error", "opencodex API key required"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -16,6 +16,8 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cooldownErrorMessage, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cooldownErrorResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headersForCodexAuthContext, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| materializeCodexUpstreamAuth, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| CodexMainSubstitutionUnavailableError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isCodexAuthContextUsable, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolveCodexAuthContext, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| shouldMarkAccountNeedsReauthForCodexAuthFailure, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -185,6 +187,12 @@ const forwardProvider: OcxProviderConfig = { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| authMode: "forward", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** A JWT whose `exp` is far in the future, so isMainAccountTokenLive() accepts it. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function liveJwt(): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `header.${payload}.signature`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe("Codex auth context", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("main-profile drain routes a non-main pool account without native reads or quota priming", async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| saveCodexAccountCredential("pool-a", { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -617,6 +625,49 @@ describe("Codex auth context", () => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("an admission bearer on main substitutes the stored credential, never forwards it (#1686)", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The caller proved admission with one of OUR secrets. That secret must never leave the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // process, so the only acceptable outcome is the stored main credential in its place. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const admissionSecret = "ocx_data_localsecret"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| writeFileSync(join(testDir, "auth.json"), JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tokens: { access_token: liveJwt(), account_id: "stored_main_acc" }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const headers = materializeCodexUpstreamAuth( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Headers({ authorization: `Bearer ${admissionSecret}`, "openai-beta": "responses=experimental" }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { kind: "main", accountId: null }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { substituteMainCredential: true }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(headers.get("authorization")).not.toContain(admissionSecret); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(headers.get("authorization")).toBe(`Bearer ${liveJwt()}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+632
to
+644
Contributor
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Store the generated JWT before asserting it. Line 634 writes one Generate the token once and use it in both places. Proposed fix const admissionSecret = "ocx_data_localsecret";
+ const storedAccessToken = liveJwt();
writeFileSync(join(testDir, "auth.json"), JSON.stringify({
- tokens: { access_token: liveJwt(), account_id: "stored_main_acc" },
+ tokens: { access_token: storedAccessToken, account_id: "stored_main_acc" },
}));
@@
- expect(headers.get("authorization")).toBe(`Bearer ${liveJwt()}`);
+ expect(headers.get("authorization")).toBe(`Bearer ${storedAccessToken}`);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Unrelated forwarded headers still ride along. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(headers.get("openai-beta")).toBe("responses=experimental"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("substitution fails closed when no usable main credential exists (#1686)", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Falling through here would forward the admission secret upstream, which is exactly | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the leak the forward guard exists to prevent. Throw before any I/O instead. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: {} })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(() => materializeCodexUpstreamAuth( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Headers({ authorization: "Bearer ocx_data_localsecret" }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { kind: "main", accountId: null }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { substituteMainCredential: true }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )).toThrow(CodexMainSubstitutionUnavailableError); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("a dedicated-header main caller keeps its own bearer as passthrough (#1686)", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Without the substitution flag this is the user's own ChatGPT credential on the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // canonical forward provider, and rewriting it would break Direct. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const headers = materializeCodexUpstreamAuth( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Headers({ authorization: "Bearer user_chatgpt_token" }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { kind: "main", accountId: null }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(headers.get("authorization")).toBe("Bearer user_chatgpt_token"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("selected pool headers replace inbound main auth", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const headers = headersForCodexAuthContext( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
When the full Bun suite runs,
tests/loopback-listener-admission.test.tsstill compares these results exactly against{ kind: "loopback" }and{ kind: "configured", keyId: "k1" }, so both assertions fail now that every result includessource. Update those existing expectations (and include this test in focused validation) so the required full server suite remains green.AGENTS.md reference: AGENTS.md:L276-L278
Useful? React with 👍 / 👎.