From ae3afa77d905bb85fb357af1148e98b91e0214d0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 1 Jul 2026 15:50:18 +0800 Subject: [PATCH 1/4] feat(cli): auto-exchange GitHub OIDC in `void deploy` (single-step Actions workflow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `void deploy` runs inside GitHub Actions with no explicit VOID_TOKEN, the CLI now mints a GitHub OIDC token (audience `void`) and exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped deploy token. This lets the scaffolded workflow collapse to a single `void deploy` step — the curl/jq exchange step is gone. Detection + exchange (new `src/cli/github-oidc.ts`): - `isGithubActionsOidcAvailable()` — GITHUB_ACTIONS=true plus a non-empty OIDC request URL + token (i.e. `id-token: write` was granted). - `exchangeGithubOidcForDeployToken()` — GET the OIDC JWT (audience `void`), then POST `{ oidcToken, project }` to the API and read `.token`. Clear, actionable errors carrying the HTTP status + server body. - `maskGithubActionsSecret()` — emits `::add-mask::` so the token is redacted from workflow logs. Precedence in the deploy path is unchanged for the common cases: an explicit token (VOID_TOKEN env / keychain / file) still wins, so existing curl-exchange workflows keep working. Only when no token is found AND running in Actions does the CLI exchange; the slug is resolved without a client (--project / VOID_PROJECT / linked .void/project.json). Local (non-Actions) behavior is untouched. Scaffold: drop the curl/jq exchange step; keep `permissions: id-token: write` (the CLI mints the OIDC token) and the VOID_API_URL / VOID_PROJECT env. Docs updated to match. Tests: unit coverage for detection, the two-hop exchange (audience appended, exact `{oidcToken, project}` body, non-OK GET/POST + missing-field errors), the mask helper, the single-step scaffold output, and the deploy-path wiring (in-Actions triggers the exchange; explicit token skips it; unknown project errors). Co-Authored-By: Claude Opus 4.8 --- docs/guide/deployment.md | 13 +- docs/reference/cli.md | 2 +- packages/void/src/cli/deploy.ts | 26 ++- packages/void/src/cli/github-oidc.ts | 165 ++++++++++++++++++ packages/void/src/cli/init.ts | 25 +-- packages/void/test/unit/deploy-auth.test.ts | 72 ++++++++ packages/void/test/unit/github-oidc.test.ts | 181 ++++++++++++++++++++ packages/void/test/unit/init.test.ts | 17 +- 8 files changed, 465 insertions(+), 36 deletions(-) create mode 100644 packages/void/src/cli/github-oidc.ts create mode 100644 packages/void/test/unit/github-oidc.test.ts diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 259b8e502..638a16c63 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -66,7 +66,7 @@ If your CI pipeline runs typechecking or other static analysis before deploy, ru You can deploy from a GitHub repo on every push to `main`. Running `void init --github` generates `.github/workflows/void-deploy.yml` in your project. -The workflow authenticates with [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — there is **no long-lived `VOID_TOKEN` secret to store or rotate**. The job requests a short-lived OIDC token from GitHub, exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token (about 15 minutes on the free tier; longer on higher plans, capped at ~60 minutes), then runs `void deploy --project `: +The workflow authenticates with [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — there is **no long-lived `VOID_TOKEN` secret to store or rotate**. `void deploy` detects GitHub Actions and does the exchange itself: it requests a short-lived OIDC token from GitHub (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token (about 15 minutes on the free tier; longer on higher plans, capped at ~60 minutes), and deploys — so the workflow is a single `void deploy` step. `permissions: id-token: write` is still required: it is what lets the job (and the CLI) mint the OIDC token. ```yaml name: Deploy to Void @@ -98,17 +98,6 @@ jobs: node-version: lts/* cache: npm - run: npm ci - - name: Exchange GitHub OIDC token for a Void deploy token - run: | - OIDC_TOKEN="$(curl -sf \ - -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void" | jq -r '.value')" - VOID_TOKEN="$(curl -sf \ - -H 'Content-Type: application/json' \ - -d "$(jq -n --arg t "$OIDC_TOKEN" --arg p "$VOID_PROJECT" '{oidcToken: $t, project: $p}')" \ - "$VOID_API_URL/auth/github-oidc" | jq -r '.token')" - echo "::add-mask::$VOID_TOKEN" - echo "VOID_TOKEN=$VOID_TOKEN" >> "$GITHUB_ENV" - name: Deploy run: npx void deploy --project "$VOID_PROJECT" ``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 34c5dbeab..2434400d1 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -86,7 +86,7 @@ After that, the full interactive flow walks through: 4. **Skills:** links Void skills using the same detected or selected agent context. 5. **MCP config:** writes MCP server config using that same agent context. 6. **Demo code:** for existing non-Pages projects, optionally scaffolds a `db/migrations/` directory plus an API route and typed fetch example. -7. **GitHub Actions:** optionally creates `.github/workflows/void-deploy.yml`. The workflow deploys on pushes to `main` and authenticates via [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — no long-lived `VOID_TOKEN` secret is stored in the repo. The job requests an OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token, then runs `void deploy --project ` with the right package manager. The project slug is baked in from your linked project (`.void/project.json`) when known; otherwise the workflow reads a `VOID_PROJECT` repository variable. +7. **GitHub Actions:** optionally creates `.github/workflows/void-deploy.yml`. The workflow deploys on pushes to `main` and authenticates via [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — no long-lived `VOID_TOKEN` secret is stored in the repo. The workflow is a single `void deploy` step: when it runs in GitHub Actions, `void deploy` mints an OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token, and deploys. `permissions: id-token: write` is retained so the CLI can mint that token. The project slug is baked in from your linked project (`.void/project.json`) when known; otherwise the workflow reads a `VOID_PROJECT` repository variable. 8. **`env.ts` scaffold:** if the project has no `env.ts` but has `.env` / `.env.example` / `.env.local` / `.env.development*` files on disk, generates an `env.ts` pre-populated with their keys. Values get conservative type inference (`boolean`/`url`/`number`/`string`) — the file carries a banner nudging you to tighten anything the heuristic got wrong. 9. **Project setup:** optionally logs you in, lets you select or create a project, and writes `.void/project.json` so your first deploy can just be `void deploy`. diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 39c58141a..0422e7951 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -55,7 +55,7 @@ import { } from '../sandbox/config.ts'; import { resolveRemoteMode, stripInternalEnvKeys } from '../shared/env.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; -import { getToken, getTokenSource } from './auth.ts'; +import { getApiUrl, getToken, getTokenSource } from './auth.ts'; import { isExpiredTokenError, PlatformClient, @@ -76,6 +76,11 @@ import { validateProdEnv, } from './env-validation.ts'; import { collectGitDeployMetadata, type GitDeployMetadata } from './git-metadata.ts'; +import { + exchangeGithubOidcForDeployToken, + isGithubActionsOidcAvailable, + maskGithubActionsSecret, +} from './github-oidc.ts'; import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts'; import { openDeployLog, type CliLogger } from './log-file.ts'; import { promptForLoginToken } from './login.ts'; @@ -383,7 +388,24 @@ async function runDeployInner( while (true) { try { // 1. Auth - const token = getToken(root); + let token = getToken(root); + if (!token && isGithubActionsOidcAvailable()) { + // Running inside GitHub Actions with no explicit VOID_TOKEN: mint a + // GitHub OIDC token and exchange it for a short-lived, project-scoped + // deploy token. The slug must resolve WITHOUT a platform client (there + // is no token yet): --project flag > VOID_PROJECT env > linked + // .void/project.json — the same sources the deploy path uses below. + const slug = getRequestedProjectSlug(options) ?? readProjectConfig(root)?.slug; + if (!slug) { + throw new Error( + 'deploy: running in GitHub Actions but the project is unknown — pass --project or link the project.', + ); + } + cliLog.info('github_oidc_exchange_start', { slug }); + token = await exchangeGithubOidcForDeployToken(getApiUrl(root), slug); + maskGithubActionsSecret(token); + cliLog.info('github_oidc_exchange_success', { slug }); + } if (!token) { log.error('No auth token found. Run `void auth login` first.'); process.exit(1); diff --git a/packages/void/src/cli/github-oidc.ts b/packages/void/src/cli/github-oidc.ts new file mode 100644 index 000000000..91cf46010 --- /dev/null +++ b/packages/void/src/cli/github-oidc.ts @@ -0,0 +1,165 @@ +/** + * GitHub Actions OIDC → Void deploy-token exchange. + * + * When `void deploy` runs inside GitHub Actions with no explicit `VOID_TOKEN`, + * the CLI mints a short-lived GitHub OIDC token (audience `void`) and exchanges + * it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped + * Void deploy token. Doing the exchange in-process lets the scaffolded workflow + * collapse to a single `void deploy` step — no curl/jq token-exchange step. + * + * Server contract (platform `POST /auth/github-oidc` handler): + * request { oidcToken: string, project: string } (project = slug) + * response { token: string, projectId, buildId } + */ + +const OIDC_AUDIENCE = 'void'; + +/** + * True when the process is running inside GitHub Actions with the OIDC token + * request endpoint available — i.e. the workflow granted + * `permissions: id-token: write`, so GitHub injected the request URL + token. + */ +export function isGithubActionsOidcAvailable(env: NodeJS.ProcessEnv = process.env): boolean { + return ( + env.GITHUB_ACTIONS === 'true' && + typeof env.ACTIONS_ID_TOKEN_REQUEST_URL === 'string' && + env.ACTIONS_ID_TOKEN_REQUEST_URL.length > 0 && + typeof env.ACTIONS_ID_TOKEN_REQUEST_TOKEN === 'string' && + env.ACTIONS_ID_TOKEN_REQUEST_TOKEN.length > 0 + ); +} + +/** + * Mint a GitHub Actions OIDC token (audience `void`) and exchange it for a + * short-lived, project-scoped Void deploy token. + * + * Two hops: + * 1. GET $ACTIONS_ID_TOKEN_REQUEST_URL&audience=void (Bearer request token) + * → `.value` is the OIDC JWT. + * 2. POST $apiUrl/auth/github-oidc { oidcToken, project } + * → `.token` is the Void deploy token. + * + * Throws a clear, actionable error (including the HTTP status and server error + * body when available) on any non-OK response or missing field. + */ +export async function exchangeGithubOidcForDeployToken( + apiUrl: string, + project: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL; + const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!requestUrl || !requestToken) { + throw new Error( + 'github-oidc: GitHub Actions OIDC is unavailable (missing ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN). Ensure the workflow grants `permissions: id-token: write`.', + ); + } + + const oidcToken = await requestGithubOidcToken(requestUrl, requestToken); + return exchangeOidcTokenAtApi(apiUrl, project, oidcToken); +} + +/** + * GET the GitHub Actions OIDC token for audience `void`. GitHub's request URL + * already carries a query string, so `audience` is appended alongside it. + */ +async function requestGithubOidcToken(requestUrl: string, requestToken: string): Promise { + const url = new URL(requestUrl); + url.searchParams.set('audience', OIDC_AUDIENCE); + + let res: Response; + try { + res = await fetch(url, { + headers: { Authorization: `Bearer ${requestToken}` }, + }); + } catch (err) { + throw new Error(`github-oidc: Failed to reach the GitHub OIDC token endpoint: ${errText(err)}`); + } + + if (!res.ok) { + const body = await safeText(res); + throw new Error( + `github-oidc: Failed to mint a GitHub OIDC token (${res.status})${body ? `: ${body}` : ''}.`, + ); + } + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new Error('github-oidc: GitHub OIDC token endpoint returned an invalid response.'); + } + + const value = (body as { value?: unknown } | null)?.value; + if (typeof value !== 'string' || value.length === 0) { + throw new Error('github-oidc: GitHub OIDC token endpoint did not return a token value.'); + } + + return value; +} + +/** POST the OIDC JWT to the Void API and return the minted deploy token. */ +async function exchangeOidcTokenAtApi( + apiUrl: string, + project: string, + oidcToken: string, +): Promise { + const base = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; + const url = `${base}/auth/github-oidc`; + + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oidcToken, project }), + }); + } catch (err) { + throw new Error(`github-oidc: Failed to reach ${url}: ${errText(err)}`); + } + + if (!res.ok) { + const body = await safeText(res); + throw new Error( + `github-oidc: OIDC token exchange failed (${res.status})${body ? `: ${body}` : ''}. Confirm the repository is connected to project "${project}" with the github_actions executor.`, + ); + } + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new Error('github-oidc: OIDC token exchange returned an invalid response.'); + } + + const token = (body as { token?: unknown } | null)?.token; + if (typeof token !== 'string' || token.length === 0) { + throw new Error('github-oidc: OIDC token exchange did not return a deploy token.'); + } + + return token; +} + +/** + * Emit a GitHub Actions `::add-mask::` workflow command so `value` is redacted + * from workflow logs. No-op when not running inside GitHub Actions. GitHub reads + * workflow commands from stdout. + */ +export function maskGithubActionsSecret(value: string, env: NodeJS.ProcessEnv = process.env): void { + if (env.GITHUB_ACTIONS !== 'true' || !value) { + return; + } + process.stdout.write(`::add-mask::${value}\n`); +} + +function errText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function safeText(res: Response): Promise { + try { + return (await res.text()).trim(); + } catch { + return ''; + } +} diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 4c5a849c7..c2b62dbc9 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1918,10 +1918,12 @@ function getWorkflowSetupSteps( /** * Scaffold the GitHub Actions workflow that deploys via GitHub OIDC — no - * long-lived secret stored in the repo. The job requests an OIDC token from - * GitHub (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` - * for a short-lived (15-min) project-scoped deploy token, then runs - * `void deploy --project ` with that token exported as `VOID_TOKEN`. + * long-lived secret stored in the repo. `void deploy` itself detects GitHub + * Actions, mints an OIDC token (audience `void`), exchanges it at + * `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped deploy + * token, and uses it — so the workflow is a single `void deploy` step (no + * curl/jq). `permissions: id-token: write` is still required: it is what lets + * the job (and thus the CLI) mint the OIDC token. * * `slug` is the linked project slug when known (from `.void/project.json`); * otherwise a `${{ vars.VOID_PROJECT }}` placeholder is emitted with a comment @@ -1955,8 +1957,8 @@ concurrency: cancel-in-progress: true # id-token: write lets the job mint a GitHub OIDC token. No long-lived -# VOID_TOKEN secret is stored — the token below is exchanged for a -# short-lived, project-scoped deploy token at deploy time. +# VOID_TOKEN secret is stored — \`void deploy\` mints that OIDC token and +# exchanges it for a short-lived, project-scoped deploy token at deploy time. permissions: id-token: write contents: read @@ -1973,17 +1975,6 @@ jobs: - uses: actions/checkout@v6 ${setup} ${install} - - name: Exchange GitHub OIDC token for a Void deploy token - run: | - OIDC_TOKEN="$(curl -sf \\ - -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \\ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void" | jq -r '.value')" - VOID_TOKEN="$(curl -sf \\ - -H 'Content-Type: application/json' \\ - -d "$(jq -n --arg t "$OIDC_TOKEN" --arg p "$VOID_PROJECT" '{oidcToken: $t, project: $p}')" \\ - "$VOID_API_URL/auth/github-oidc" | jq -r '.token')" - echo "::add-mask::$VOID_TOKEN" - echo "VOID_TOKEN=$VOID_TOKEN" >> "$GITHUB_ENV" - name: Deploy run: ${runner} deploy --project "$VOID_PROJECT" `; diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index cd5bdc939..39a23a050 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { buildEnv, runDeploy } from '../../src/cli/deploy.ts'; @@ -8,9 +11,13 @@ const mockWarn = vi.fn(); const mockSuccess = vi.fn(); const mockGetToken = vi.fn(); const mockGetTokenSource = vi.fn(); +const mockGetApiUrl = vi.fn(); const mockListProjects = vi.fn(); const mockIsExpiredTokenError = vi.fn(); const mockPromptForLoginToken = vi.fn(); +const mockIsOidcAvailable = vi.fn(); +const mockExchangeOidc = vi.fn(); +const mockMaskSecret = vi.fn(); const originalIsTTY = process.stdin.isTTY; @@ -34,6 +41,13 @@ vi.mock('@clack/prompts', () => ({ vi.mock('../../src/cli/auth', () => ({ getToken: (...args: Array) => mockGetToken(...args), getTokenSource: (...args: Array) => mockGetTokenSource(...args), + getApiUrl: (...args: Array) => mockGetApiUrl(...args), +})); + +vi.mock('../../src/cli/github-oidc', () => ({ + isGithubActionsOidcAvailable: (...args: Array) => mockIsOidcAvailable(...args), + exchangeGithubOidcForDeployToken: (...args: Array) => mockExchangeOidc(...args), + maskGithubActionsSecret: (...args: Array) => mockMaskSecret(...args), })); vi.mock('../../src/cli/client', () => ({ @@ -57,12 +71,21 @@ beforeEach(() => { mockSuccess.mockReset(); mockGetToken.mockReset(); mockGetTokenSource.mockReset(); + mockGetApiUrl.mockReset(); mockListProjects.mockReset(); mockIsExpiredTokenError.mockReset(); mockPromptForLoginToken.mockReset(); + mockIsOidcAvailable.mockReset(); + mockExchangeOidc.mockReset(); + mockMaskSecret.mockReset(); mockGetToken.mockReturnValue('token'); mockGetTokenSource.mockReturnValue('manual'); + mockGetApiUrl.mockReturnValue('https://api.test'); + // Default: not running in GitHub Actions, so the OIDC branch is inert and the + // existing auth tests are unaffected. + mockIsOidcAvailable.mockReturnValue(false); + mockExchangeOidc.mockResolvedValue('minted-token'); mockListProjects.mockRejectedValue(new Error('expired-token')); mockIsExpiredTokenError.mockImplementation((error: unknown) => { return error instanceof Error && error.message === 'expired-token'; @@ -102,6 +125,55 @@ describe('runDeploy auth failures', () => { }); }); +describe('runDeploy GitHub Actions OIDC exchange', () => { + it('exchanges an OIDC token for a deploy token when no VOID_TOKEN and in Actions', async () => { + mockGetToken.mockReturnValue(null); + mockIsOidcAvailable.mockReturnValue(true); + mockExchangeOidc.mockResolvedValue('minted-token'); + + // The deploy still fails later (listProjects is mocked to reject), but the + // OIDC exchange must run first and mint the token the client is built with. + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); + + expect(mockExchangeOidc).toHaveBeenCalledTimes(1); + expect(mockExchangeOidc).toHaveBeenCalledWith('https://api.test', 'my-app'); + expect(mockMaskSecret).toHaveBeenCalledWith('minted-token'); + }); + + it('does NOT exchange when an explicit token (VOID_TOKEN/keychain) is present', async () => { + mockGetToken.mockReturnValue('explicit-token'); + mockIsOidcAvailable.mockReturnValue(true); + + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); + + expect(mockExchangeOidc).not.toHaveBeenCalled(); + expect(mockMaskSecret).not.toHaveBeenCalled(); + }); + + it('errors when in Actions but the project is unknown', async () => { + mockGetToken.mockReturnValue(null); + mockIsOidcAvailable.mockReturnValue(true); + + const root = mkdtempSync(join(tmpdir(), 'void-oidc-')); + const savedProject = process.env.VOID_PROJECT; + delete process.env.VOID_PROJECT; + try { + // No --project, no VOID_PROJECT, no .void/project.json → slug is unknown. + await expect(runDeploy(root)).rejects.toThrow( + 'running in GitHub Actions but the project is unknown', + ); + expect(mockExchangeOidc).not.toHaveBeenCalled(); + } finally { + if (savedProject === undefined) { + delete process.env.VOID_PROJECT; + } else { + process.env.VOID_PROJECT = savedProject; + } + rmSync(root, { recursive: true, force: true }); + } + }); +}); + // The deploy flow spawns untrusted `vite build` (and other build children) with // the spawn env. A shared CF Access service token (used only by the CLI's own API // client, which captures the headers at construction) must NOT survive into those diff --git a/packages/void/test/unit/github-oidc.test.ts b/packages/void/test/unit/github-oidc.test.ts new file mode 100644 index 000000000..527b7b086 --- /dev/null +++ b/packages/void/test/unit/github-oidc.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + exchangeGithubOidcForDeployToken, + isGithubActionsOidcAvailable, + maskGithubActionsSecret, +} from '../../src/cli/github-oidc.ts'; + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); + +afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const REQUEST_URL = 'https://token.actions.githubusercontent.com/abc?api-version=2.0'; +const REQUEST_TOKEN = 'req-token-123'; + +function oidcEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: REQUEST_URL, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: REQUEST_TOKEN, + ...overrides, + } as unknown as NodeJS.ProcessEnv; +} + +describe('isGithubActionsOidcAvailable', () => { + it('is true when GITHUB_ACTIONS=true and both request vars are non-empty', () => { + expect(isGithubActionsOidcAvailable(oidcEnv())).toBe(true); + }); + + it('is false when GITHUB_ACTIONS is not exactly "true"', () => { + expect(isGithubActionsOidcAvailable(oidcEnv({ GITHUB_ACTIONS: 'false' }))).toBe(false); + expect(isGithubActionsOidcAvailable(oidcEnv({ GITHUB_ACTIONS: undefined }))).toBe(false); + }); + + it('is false when the request URL is missing or empty', () => { + expect(isGithubActionsOidcAvailable(oidcEnv({ ACTIONS_ID_TOKEN_REQUEST_URL: undefined }))).toBe( + false, + ); + expect(isGithubActionsOidcAvailable(oidcEnv({ ACTIONS_ID_TOKEN_REQUEST_URL: '' }))).toBe(false); + }); + + it('is false when the request token is missing or empty', () => { + expect( + isGithubActionsOidcAvailable(oidcEnv({ ACTIONS_ID_TOKEN_REQUEST_TOKEN: undefined })), + ).toBe(false); + expect(isGithubActionsOidcAvailable(oidcEnv({ ACTIONS_ID_TOKEN_REQUEST_TOKEN: '' }))).toBe( + false, + ); + }); +}); + +describe('exchangeGithubOidcForDeployToken', () => { + const apiUrl = 'https://api.test'; + + it('mints an OIDC token then exchanges it for a deploy token', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token', projectId: 'p1' })); + + const token = await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); + expect(token).toBe('deploy-token'); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // 1. GET the OIDC token with audience=void appended and the request bearer. + const [getUrl, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(String(getUrl)).toContain('audience=void'); + expect(String(getUrl)).toContain('token.actions.githubusercontent.com'); + expect(getInit.method ?? 'GET').toBe('GET'); + expect((getInit.headers as Record).Authorization).toBe( + `Bearer ${REQUEST_TOKEN}`, + ); + + // 2. POST the JWT to the Void API; body is exactly { oidcToken, project }. + const [postUrl, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(postUrl).toBe(`${apiUrl}/auth/github-oidc`); + expect(postInit.method).toBe('POST'); + expect((postInit.headers as Record)['Content-Type']).toBe('application/json'); + expect(postInit.body).toBe(JSON.stringify({ oidcToken: 'oidc-jwt', project: 'my-app' })); + }); + + it('strips a trailing slash from the API URL', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken('https://api.test/', 'my-app', oidcEnv()); + const [postUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(postUrl).toBe('https://api.test/auth/github-oidc'); + }); + + it('throws before any fetch when the request vars are absent', async () => { + await expect( + exchangeGithubOidcForDeployToken( + apiUrl, + 'my-app', + oidcEnv({ ACTIONS_ID_TOKEN_REQUEST_URL: undefined }), + ), + ).rejects.toThrow(/id-token: write/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws with status + body when minting the OIDC token fails', async () => { + fetchMock.mockResolvedValueOnce(new Response('nope', { status: 403 })); + await expect(exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv())).rejects.toThrow( + /Failed to mint a GitHub OIDC token \(403\): nope/, + ); + // The second hop must not run once the first fails. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('throws when the OIDC endpoint omits a token value', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ notValue: 'x' })); + await expect(exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv())).rejects.toThrow( + /did not return a token value/, + ); + }); + + it('throws with status + body when the API exchange fails', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(new Response('project does not match connection', { status: 401 })); + + await expect(exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv())).rejects.toThrow( + /OIDC token exchange failed \(401\): project does not match connection/, + ); + }); + + it('throws when the API omits the deploy token', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ projectId: 'p1' })); + + await expect(exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv())).rejects.toThrow( + /did not return a deploy token/, + ); + }); +}); + +describe('maskGithubActionsSecret', () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('emits an ::add-mask:: workflow command inside GitHub Actions', () => { + maskGithubActionsSecret('secret-token', { GITHUB_ACTIONS: 'true' } as NodeJS.ProcessEnv); + expect(writeSpy).toHaveBeenCalledWith('::add-mask::secret-token\n'); + }); + + it('is a no-op when not running in GitHub Actions', () => { + maskGithubActionsSecret('secret-token', {} as NodeJS.ProcessEnv); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('is a no-op for an empty value', () => { + maskGithubActionsSecret('', { GITHUB_ACTIONS: 'true' } as NodeJS.ProcessEnv); + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index 41a3bb776..b8438a2e4 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1511,15 +1511,23 @@ describe('isScaffoldableEmptyProject', () => { const yaml = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); // OIDC, not a stored secret. expect(yaml).not.toContain('secrets.VOID_TOKEN'); + // `id-token: write` is retained — `void deploy` mints the OIDC token. expect(yaml).toContain('id-token: write'); expect(yaml).toContain('contents: read'); - expect(yaml).toContain('$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void'); - expect(yaml).toContain('$VOID_API_URL/auth/github-oidc'); + // Single-step workflow: the CLI does the exchange, so no curl/jq step and + // no OIDC/exchange endpoints appear in the workflow anymore. + expect(yaml).not.toContain('Exchange GitHub OIDC token'); + expect(yaml).not.toContain('$ACTIONS_ID_TOKEN_REQUEST_URL'); + expect(yaml).not.toContain('$VOID_API_URL/auth/github-oidc'); + expect(yaml).not.toContain('curl'); + expect(yaml).not.toContain('jq -r'); // No linked project → placeholder repository variable. expect(yaml).toContain('VOID_PROJECT: ${{ vars.VOID_PROJECT }}'); expect(yaml).toContain("VOID_API_URL: ${{ vars.VOID_API_URL || 'https://api.void.cloud' }}"); - // npm project → npx runner, deploy passes the project slug. + // npm project → npx runner, deploy passes the project slug (the only step + // that runs void). expect(yaml).toContain('npx void deploy --project "$VOID_PROJECT"'); + expect(yaml.match(/deploy --project "\$VOID_PROJECT"/g)).toHaveLength(1); // Latest-push-wins concurrency keyed by repo + ref (no env context, which // is unavailable to workflow-level concurrency). expect(yaml).toContain('concurrency:'); @@ -1657,7 +1665,8 @@ jobs: const workflows = readdirSync(join(root, '.github', 'workflows')).sort(); expect(workflows).toEqual(['deploy.yml', 'void-deploy.yml']); const oidc = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); - expect(oidc).toContain('$VOID_API_URL/auth/github-oidc'); + expect(oidc).toContain('name: Deploy to Void'); + expect(oidc).toContain('npx void deploy --project "$VOID_PROJECT"'); } finally { rmSync(root, { recursive: true, force: true }); } From 2a29fe95d4918aa999f15417fb86c75946ec4871 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 1 Jul 2026 16:10:52 +0800 Subject: [PATCH 2/4] fix(cli): export OIDC-minted token to build env so prerender getToken sees it (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-step GitHub Actions workflow drops the curl/jq step that wrote `VOID_TOKEN` to `$GITHUB_ENV`. `void deploy` now mints the deploy token in-process, but assigned it only to a local variable. The downstream build and prerender re-read credentials via `getToken()` (which reads `process.env.VOID_TOKEN` first) — both in-process (`collectPrerenderPaths`) and inside the spawned `vite build` child (`plugin-prerender`'s closeBundle, which inherits `process.env` via `buildEnv`). A local variable reaches neither, so in CI the prerender injected no `__VOID_TOKEN`/`__VOID_PROJECT_ID` and rendered against local/empty bindings (AI, remote D1/KV/R2 break) — a regression from the removed scaffold step. Export the minted token to `process.env.VOID_TOKEN` right after masking, inside the `!token` OIDC branch so an explicit `VOID_TOKEN` is never overwritten. This restores exact parity with the removed `$GITHUB_ENV` write. The token is the same deploy-scoped (`aud: deploy`, short-TTL) token the old workflow exposed to the build, so visibility is parity, not new exposure. The `command === 'serve'` gate on `__VOID_TOKEN` worker-var injection is untouched, so this does not leak the token into `dist/ssr/wrangler.json`. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 12 +++++++ packages/void/test/unit/deploy-auth.test.ts | 40 +++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 0422e7951..6ffef9dad 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -404,6 +404,18 @@ async function runDeployInner( cliLog.info('github_oidc_exchange_start', { slug }); token = await exchangeGithubOidcForDeployToken(getApiUrl(root), slug); maskGithubActionsSecret(token); + // Export the minted token so the downstream build/prerender can reach + // it. Both prerender paths re-read credentials via `getToken()`, which + // reads `process.env.VOID_TOKEN` first: the in-process path + // (`collectPrerenderPaths` below) and the spawned `vite build` child + // (`plugin-prerender`'s closeBundle, which inherits `process.env` via + // `buildEnv`). A local variable can't reach either, so without this the + // prerender injects no `__VOID_TOKEN`/`__VOID_PROJECT_ID` and renders + // against local/empty bindings (AI, remote D1/KV/R2 break). This + // restores parity with the removed scaffold step that wrote + // `VOID_TOKEN` to `$GITHUB_ENV`. Guarded by the enclosing `!token` + // branch, so an explicit `VOID_TOKEN` is never overwritten. + process.env.VOID_TOKEN = token; cliLog.info('github_oidc_exchange_success', { slug }); } if (!token) { diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index 39a23a050..f5afd9aca 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -20,6 +20,7 @@ const mockExchangeOidc = vi.fn(); const mockMaskSecret = vi.fn(); const originalIsTTY = process.stdin.isTTY; +let savedVoidToken: string | undefined; vi.mock('@clack/prompts', () => ({ intro: vi.fn(), @@ -92,6 +93,11 @@ beforeEach(() => { }); mockPromptForLoginToken.mockResolvedValue('fresh-token'); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + // Isolate process.env.VOID_TOKEN across tests: the OIDC branch now exports + // the minted token there, so start each test clean and restore afterwards. + savedVoidToken = process.env.VOID_TOKEN; + delete process.env.VOID_TOKEN; }); describe('runDeploy auth failures', () => { @@ -150,6 +156,35 @@ describe('runDeploy GitHub Actions OIDC exchange', () => { expect(mockMaskSecret).not.toHaveBeenCalled(); }); + it('exports the minted token to process.env.VOID_TOKEN so the build/prerender getToken sees it', async () => { + mockGetToken.mockReturnValue(null); + mockIsOidcAvailable.mockReturnValue(true); + mockExchangeOidc.mockResolvedValue('minted-token'); + + // The deploy throws later (listProjects rejects), but the OIDC branch runs + // first. The minted token must land in process.env.VOID_TOKEN — the + // downstream `vite build` child (which inherits process.env) and the + // in-process prerender both re-read it via getToken(), which reads + // process.env.VOID_TOKEN first. A local variable can't reach either. + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); + + expect(process.env.VOID_TOKEN).toBe('minted-token'); + }); + + it('does NOT overwrite an explicit VOID_TOKEN in process.env (no mint)', async () => { + // getToken returns a token (explicit VOID_TOKEN / keychain / file), so the + // OIDC branch is skipped and the existing process.env.VOID_TOKEN is left + // untouched — no re-mint, no clobber. + mockGetToken.mockReturnValue('explicit-token'); + mockIsOidcAvailable.mockReturnValue(true); + process.env.VOID_TOKEN = 'explicit-env-token'; + + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); + + expect(mockExchangeOidc).not.toHaveBeenCalled(); + expect(process.env.VOID_TOKEN).toBe('explicit-env-token'); + }); + it('errors when in Actions but the project is unknown', async () => { mockGetToken.mockReturnValue(null); mockIsOidcAvailable.mockReturnValue(true); @@ -297,4 +332,9 @@ describe('buildEnv', () => { afterEach(() => { Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + if (savedVoidToken === undefined) { + delete process.env.VOID_TOKEN; + } else { + process.env.VOID_TOKEN = savedVoidToken; + } }); From a7b53b7e5991d6031d0ae327d082181d784110a8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 1 Jul 2026 16:47:09 +0800 Subject: [PATCH 3/4] fix(cli): forward CF Access headers on the OIDC token exchange (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/void/src/cli/github-oidc.ts | 13 +++- packages/void/test/unit/github-oidc.test.ts | 85 +++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/github-oidc.ts b/packages/void/src/cli/github-oidc.ts index 91cf46010..e5fa0321f 100644 --- a/packages/void/src/cli/github-oidc.ts +++ b/packages/void/src/cli/github-oidc.ts @@ -12,6 +12,9 @@ * response { token: string, projectId, buildId } */ +import { cfAccessHeaders } from '../shared/cf-access.ts'; +import { ensureCloudflaredToken } from './cf-access.ts'; + const OIDC_AUDIENCE = 'void'; /** @@ -107,11 +110,19 @@ async function exchangeOidcTokenAtApi( const base = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl; const url = `${base}/auth/github-oidc`; + // This POST hits the Void API (which may be the Access-gated staging host) + // before any PlatformClient exists, so it must carry CF Access credentials + // itself — exactly like every other CLI → Void-API call (PlatformClient + // ctor, exchangeLoginCode). `ensureCloudflaredToken` no-ops for prod/ungated + // hosts and when the service-token pair or CF_ACCESS_TOKEN is already set; + // `cfAccessHeaders` returns {} when no CF Access env is present (prod path). + ensureCloudflaredToken(apiUrl); + let res: Response; try { res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...cfAccessHeaders(process.env) }, body: JSON.stringify({ oidcToken, project }), }); } catch (err) { diff --git a/packages/void/test/unit/github-oidc.test.ts b/packages/void/test/unit/github-oidc.test.ts index 527b7b086..7d8b62ae6 100644 --- a/packages/void/test/unit/github-oidc.test.ts +++ b/packages/void/test/unit/github-oidc.test.ts @@ -30,6 +30,14 @@ function jsonResponse(body: unknown, status = 200): Response { const REQUEST_URL = 'https://token.actions.githubusercontent.com/abc?api-version=2.0'; const REQUEST_TOKEN = 'req-token-123'; +function restoreEnv(key: string, prev: string | undefined): void { + if (prev === undefined) { + delete process.env[key]; + } else { + process.env[key] = prev; + } +} + function oidcEnv(overrides: Record = {}): NodeJS.ProcessEnv { return { GITHUB_ACTIONS: 'true', @@ -95,6 +103,83 @@ describe('exchangeGithubOidcForDeployToken', () => { expect(postInit.body).toBe(JSON.stringify({ oidcToken: 'oidc-jwt', project: 'my-app' })); }); + it('forwards CF Access service-token headers on the API POST but not the GitHub GET', async () => { + const prevId = process.env.CF_ACCESS_CLIENT_ID; + const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET; + process.env.CF_ACCESS_CLIENT_ID = 'cf-id'; + process.env.CF_ACCESS_CLIENT_SECRET = 'cf-secret'; + try { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); + + // GitHub OIDC GET hits token.actions.githubusercontent.com — never CF Access. + const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; + const getHeaders = getInit.headers as Record; + expect(getHeaders['CF-Access-Client-Id']).toBeUndefined(); + expect(getHeaders['CF-Access-Client-Secret']).toBeUndefined(); + + // Void API POST carries the service-token pair so Access-gated staging passes. + const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + const postHeaders = postInit.headers as Record; + expect(postHeaders['CF-Access-Client-Id']).toBe('cf-id'); + expect(postHeaders['CF-Access-Client-Secret']).toBe('cf-secret'); + } finally { + restoreEnv('CF_ACCESS_CLIENT_ID', prevId); + restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret); + } + }); + + it('forwards a CF_ACCESS_TOKEN user JWT on the API POST but not the GitHub GET', async () => { + const prevToken = process.env.CF_ACCESS_TOKEN; + process.env.CF_ACCESS_TOKEN = 'cf-jwt'; + try { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); + + const [, getInit] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect((getInit.headers as Record)['cf-access-token']).toBeUndefined(); + + const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect((postInit.headers as Record)['cf-access-token']).toBe('cf-jwt'); + } finally { + restoreEnv('CF_ACCESS_TOKEN', prevToken); + } + }); + + it('omits CF Access headers on the API POST when no CF Access env is set (prod path)', async () => { + const prevId = process.env.CF_ACCESS_CLIENT_ID; + const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET; + const prevToken = process.env.CF_ACCESS_TOKEN; + delete process.env.CF_ACCESS_CLIENT_ID; + delete process.env.CF_ACCESS_CLIENT_SECRET; + delete process.env.CF_ACCESS_TOKEN; + try { + fetchMock + .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) + .mockResolvedValueOnce(jsonResponse({ token: 'deploy-token' })); + + await exchangeGithubOidcForDeployToken(apiUrl, 'my-app', oidcEnv()); + + const [, postInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + const postHeaders = postInit.headers as Record; + expect(postHeaders['CF-Access-Client-Id']).toBeUndefined(); + expect(postHeaders['CF-Access-Client-Secret']).toBeUndefined(); + expect(postHeaders['cf-access-token']).toBeUndefined(); + // The plain Content-Type header is still present. + expect(postHeaders['Content-Type']).toBe('application/json'); + } finally { + restoreEnv('CF_ACCESS_CLIENT_ID', prevId); + restoreEnv('CF_ACCESS_CLIENT_SECRET', prevSecret); + restoreEnv('CF_ACCESS_TOKEN', prevToken); + } + }); + it('strips a trailing slash from the API URL', async () => { fetchMock .mockResolvedValueOnce(jsonResponse({ value: 'oidc-jwt' })) From cc683fcc93be7ec67c7b4c5bc1e5fc0c2c9f2bb5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 1 Jul 2026 17:04:50 +0800 Subject: [PATCH 4/4] fix(cli): prefer OIDC over ambient saved tokens in GitHub Actions (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy token resolution called getToken() first, which returns a keychain/config-file token in addition to the VOID_TOKEN env. On a self-hosted or reused GitHub Actions runner with a leftover `void auth login` credential, that ambient token preempted the OIDC branch: the deploy used a broad, long-lived `aud:'api'` user token (or failed if stale) instead of the short-lived, project-scoped `aud:'deploy'` token the workflow's `id-token: write` intends. Reorder precedence to: explicit VOID_TOKEN env (checked directly, not via getToken) > GitHub Actions OIDC exchange > saved keychain/file. Hosted runners (no saved token, no env) are unaffected — OIDC still runs. Explicit VOID_TOKEN still wins everywhere and is never overwritten. Restores the old scaffold's intent, which exported the exchanged token as VOID_TOKEN before `void deploy`. Co-Authored-By: Claude Opus 4.8 --- packages/void/src/cli/deploy.ts | 26 ++++++++++++-- packages/void/test/unit/deploy-auth.test.ts | 39 +++++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 6ffef9dad..164dc1fbd 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -387,14 +387,30 @@ async function runDeployInner( let reauthenticated = false; while (true) { try { - // 1. Auth - let token = getToken(root); + // 1. Auth — resolve the deploy token by precedence: + // 1. explicit `VOID_TOKEN` env (deliberate override; wins everywhere) + // 2. GitHub Actions OIDC exchange (in Actions, when no explicit token) + // 3. saved keychain / config file (local dev) + // + // OIDC MUST beat an AMBIENT saved token here. `getToken()` returns the env + // token AND a keychain/config-file token, so calling it first would let a + // leftover `void auth login` credential on a self-hosted / reused runner + // preempt OIDC — silently deploying with a broad, long-lived `aud:'api'` + // user token (or failing outright if it is stale) instead of the + // short-lived, project-scoped `aud:'deploy'` token the workflow's + // `id-token: write` intends. So we read the EXPLICIT env directly (NOT via + // getToken), then OIDC, then the saved token. This matches the old + // scaffold, which exported the freshly exchanged token as `VOID_TOKEN` + // before `void deploy` so the deploy token always won inside Actions. + let token = process.env.VOID_TOKEN ?? null; if (!token && isGithubActionsOidcAvailable()) { // Running inside GitHub Actions with no explicit VOID_TOKEN: mint a // GitHub OIDC token and exchange it for a short-lived, project-scoped // deploy token. The slug must resolve WITHOUT a platform client (there // is no token yet): --project flag > VOID_PROJECT env > linked - // .void/project.json — the same sources the deploy path uses below. + // .void/project.json — the same sources the deploy path uses below. A + // workflow with `id-token: write` intends OIDC, so an unknown slug is a + // hard error — we do NOT silently fall back to a saved token. const slug = getRequestedProjectSlug(options) ?? readProjectConfig(root)?.slug; if (!slug) { throw new Error( @@ -418,6 +434,10 @@ async function runDeployInner( process.env.VOID_TOKEN = token; cliLog.info('github_oidc_exchange_success', { slug }); } + // Local dev / non-Actions (or Actions without id-token permission): fall + // back to the saved keychain/config token. `getToken()` re-checks + // `VOID_TOKEN` first, which is harmlessly unset on this branch. + token ??= getToken(root); if (!token) { log.error('No auth token found. Run `void auth login` first.'); process.exit(1); diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index f5afd9aca..453395316 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -18,6 +18,7 @@ const mockPromptForLoginToken = vi.fn(); const mockIsOidcAvailable = vi.fn(); const mockExchangeOidc = vi.fn(); const mockMaskSecret = vi.fn(); +const mockClientCtor = vi.fn(); const originalIsTTY = process.stdin.isTTY; let savedVoidToken: string | undefined; @@ -53,6 +54,9 @@ vi.mock('../../src/cli/github-oidc', () => ({ vi.mock('../../src/cli/client', () => ({ PlatformClient: class { + constructor(...args: Array) { + mockClientCtor(...args); + } listProjects(...args: Array) { return mockListProjects(...args); } @@ -79,6 +83,7 @@ beforeEach(() => { mockIsOidcAvailable.mockReset(); mockExchangeOidc.mockReset(); mockMaskSecret.mockReset(); + mockClientCtor.mockReset(); mockGetToken.mockReturnValue('token'); mockGetTokenSource.mockReturnValue('manual'); @@ -146,14 +151,44 @@ describe('runDeploy GitHub Actions OIDC exchange', () => { expect(mockMaskSecret).toHaveBeenCalledWith('minted-token'); }); - it('does NOT exchange when an explicit token (VOID_TOKEN/keychain) is present', async () => { - mockGetToken.mockReturnValue('explicit-token'); + it('prefers OIDC over an AMBIENT saved keychain/file token in Actions (no VOID_TOKEN env)', async () => { + // Regression guard: on a self-hosted / reused runner a leftover + // `void auth login` credential lives in the keychain/config file, so + // getToken() returns it — but there is NO explicit VOID_TOKEN env. The + // deploy MUST still mint a fresh project-scoped OIDC deploy token and use + // THAT, not the broad long-lived saved token. (Before the fix, getToken was + // consulted first and the ambient token preempted OIDC entirely.) + mockGetToken.mockReturnValue('ambient-saved-token'); mockIsOidcAvailable.mockReturnValue(true); + mockExchangeOidc.mockResolvedValue('minted-token'); + + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); + + // The OIDC exchange ran... + expect(mockExchangeOidc).toHaveBeenCalledTimes(1); + expect(mockExchangeOidc).toHaveBeenCalledWith('https://api.test', 'my-app'); + expect(mockMaskSecret).toHaveBeenCalledWith('minted-token'); + // ...the minted token was exported for the build/prerender... + expect(process.env.VOID_TOKEN).toBe('minted-token'); + // ...and the PlatformClient was built with the MINTED token, NOT the saved one. + expect(mockClientCtor).toHaveBeenCalledTimes(1); + expect(mockClientCtor.mock.calls[0][0]).toBe('minted-token'); + }); + + it('uses the saved keychain/file token when NOT in Actions (local dev, no exchange)', async () => { + // Local dev: not in GitHub Actions, no VOID_TOKEN env, but a saved token is + // present. It must be used directly with no OIDC exchange. + mockGetToken.mockReturnValue('ambient-saved-token'); + mockIsOidcAvailable.mockReturnValue(false); await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); expect(mockExchangeOidc).not.toHaveBeenCalled(); expect(mockMaskSecret).not.toHaveBeenCalled(); + expect(mockClientCtor).toHaveBeenCalledTimes(1); + expect(mockClientCtor.mock.calls[0][0]).toBe('ambient-saved-token'); + // The saved token is never promoted into process.env (still clean here). + expect(process.env.VOID_TOKEN).toBeUndefined(); }); it('exports the minted token to process.env.VOID_TOKEN so the build/prerender getToken sees it', async () => {