Skip to content

Commit b6e70ce

Browse files
Brooooooklynclaude
andcommitted
fix(cli): forward CF Access headers on the OIDC token exchange (review)
The GitHub Actions OIDC → deploy-token exchange POSTs to $VOID_API_URL/auth/github-oidc before any PlatformClient exists, so it sent only Content-Type. When VOID_API_URL targets the Access-gated staging API, Cloudflare Access rejected the request at the edge before /auth/github-oidc could mint the deploy token. Mirror every other CLI → Void-API call: call ensureCloudflaredToken(apiUrl) and spread cfAccessHeaders(process.env) on the POST. Both no-op for the prod/ungated path (no CF Access env, host not gated), so this is a safe defense-in-depth change. The GitHub OIDC GET (token.actions.githubusercontent.com) is untouched — CF Access headers belong only to the Void API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 106f36c commit b6e70ce

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

‎packages/void/src/cli/github-oidc.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
* response { token: string, projectId, buildId }
1313
*/
1414

15+
import { cfAccessHeaders } from '../shared/cf-access.ts';
16+
import { ensureCloudflaredToken } from './cf-access.ts';
17+
1518
const OIDC_AUDIENCE = 'void';
1619

1720
/**
@@ -107,11 +110,19 @@ async function exchangeOidcTokenAtApi(
107110
const base = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
108111
const url = `${base}/auth/github-oidc`;
109112

113+
// This POST hits the Void API (which may be the Access-gated staging host)
114+
// before any PlatformClient exists, so it must carry CF Access credentials
115+
// itself — exactly like every other CLI → Void-API call (PlatformClient
116+
// ctor, exchangeLoginCode). `ensureCloudflaredToken` no-ops for prod/ungated
117+
// hosts and when the service-token pair or CF_ACCESS_TOKEN is already set;
118+
// `cfAccessHeaders` returns {} when no CF Access env is present (prod path).
119+
ensureCloudflaredToken(apiUrl);
120+
110121
let res: Response;
111122
try {
112123
res = await fetch(url, {
113124
method: 'POST',
114-
headers: { 'Content-Type': 'application/json' },
125+
headers: { 'Content-Type': 'application/json', ...cfAccessHeaders(process.env) },
115126
body: JSON.stringify({ oidcToken, project }),
116127
});
117128
} catch (err) {

‎packages/void/test/unit/github-oidc.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ function jsonResponse(body: unknown, status = 200): Response {
3030
const REQUEST_URL = 'https://token.actions.githubusercontent.com/abc?api-version=2.0';
3131
const REQUEST_TOKEN = 'req-token-123';
3232

33+
function restoreEnv(key: string, prev: string | undefined): void {
34+
if (prev === undefined) {
35+
delete process.env[key];
36+
} else {
37+
process.env[key] = prev;
38+
}
39+
}
40+
3341
function oidcEnv(overrides: Record<string, string | undefined> = {}): NodeJS.ProcessEnv {
3442
return {
3543
GITHUB_ACTIONS: 'true',
@@ -95,6 +103,83 @@ describe('exchangeGithubOidcForDeployToken', () => {
95103
expect(postInit.body).toBe(JSON.stringify({ oidcToken: 'oidc-jwt', project: 'my-app' }));
96104
});
97105

106+
it('forwards CF Access service-token headers on the API POST but not the GitHub GET', async () => {
107+
const prevId = process.env.CF_ACCESS_CLIENT_ID;
108+
const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET;
109+
process.env.CF_ACCESS_CLIENT_ID = 'cf-id';
110+
process.env.CF_ACCESS_CLIENT_SECRET = 'cf-secret';
111+
try {
112+
fetchMock
113+
.mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' }))
114+
.mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' }));
115+
116+
await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv());
117+
118+
// GitHub OIDC GET hits token.actions.githubusercontent.com — never CF Access.
119+
const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit];
120+
const getHeaders = getInit.headers as Record<string, string>;
121+
expect(getHeaders['CF-Access-Client-Id']).toBeUndefined();
122+
expect(getHeaders['CF-Access-Client-Secret']).toBeUndefined();
123+
124+
// Void API POST carries the service-token pair so Access-gated staging passes.
125+
const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit];
126+
const postHeaders = postInit.headers as Record<string, string>;
127+
expect(postHeaders['CF-Access-Client-Id']).toBe('cf-id');
128+
expect(postHeaders['CF-Access-Client-Secret']).toBe('cf-secret');
129+
} finally {
130+
restoreEnv('CF_ACCESS_CLIENT_ID', prevId);
131+
restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret);
132+
}
133+
});
134+
135+
it('forwards a CF_ACCESS_TOKEN user JWT on the API POST but not the GitHub GET', async () => {
136+
const prevToken = process.env.CF_ACCESS_TOKEN;
137+
process.env.CF_ACCESS_TOKEN = 'cf-jwt';
138+
try {
139+
fetchMock
140+
.mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' }))
141+
.mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' }));
142+
143+
await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv());
144+
145+
const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit];
146+
expect((getInit.headers as Record<string, string>)['cf-access-token']).toBeUndefined();
147+
148+
const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit];
149+
expect((postInit.headers as Record<string, string>)['cf-access-token']).toBe('cf-jwt');
150+
} finally {
151+
restoreEnv('CF_ACCESS_TOKEN', prevToken);
152+
}
153+
});
154+
155+
it('omits CF Access headers on the API POST when no CF Access env is set (prod path)', async () => {
156+
const prevId = process.env.CF_ACCESS_CLIENT_ID;
157+
const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET;
158+
const prevToken = process.env.CF_ACCESS_TOKEN;
159+
delete process.env.CF_ACCESS_CLIENT_ID;
160+
delete process.env.CF_ACCESS_CLIENT_SECRET;
161+
delete process.env.CF_ACCESS_TOKEN;
162+
try {
163+
fetchMock
164+
.mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' }))
165+
.mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' }));
166+
167+
await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv());
168+
169+
const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit];
170+
const postHeaders = postInit.headers as Record<string, string>;
171+
expect(postHeaders['CF-Access-Client-Id']).toBeUndefined();
172+
expect(postHeaders['CF-Access-Client-Secret']).toBeUndefined();
173+
expect(postHeaders['cf-access-token']).toBeUndefined();
174+
// The plain Content-Type header is still present.
175+
expect(postHeaders['Content-Type']).toBe('application/json');
176+
} finally {
177+
restoreEnv('CF_ACCESS_CLIENT_ID', prevId);
178+
restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret);
179+
restoreEnv('CF_ACCESS_TOKEN', prevToken);
180+
}
181+
});
182+
98183
it('strips a trailing slash from the API URL', async () => {
99184
fetchMock
100185
.mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' }))

0 commit comments

Comments
 (0)