Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,13 +568,25 @@ See [Environment Variables](../guide/env-vars.md) for the full guide.

## GitHub

Deploy-on-GitHub works from **any** Void login — Google, GitHub, or other SSO. The first time you connect GitHub, Void links your GitHub identity to your current account (a one-time step, independent of how you logged in); it never creates a second account.

### `void github link`

```
void github link
```

Link your current Void account to a GitHub identity. Opens your browser to authorize Void on GitHub (a localhost + PKCE handshake, the same mechanics as `void auth login`), then binds that GitHub identity to the logged-in account. Requires an authenticated CLI (`void auth login` first).

You normally don't need to run this directly — `void github install` runs the link automatically when your account has no GitHub identity yet. Run it on its own to link ahead of time, or to link a GitHub identity without installing the App. If that GitHub account is already linked to a different Void account, the command fails and asks you to use a different GitHub account.

### `void github install`

```
void github install
```

Open the GitHub App install page in your browser. After installing, run `void github connect` to link a repository to your project.
Open the GitHub App install page in your browser. If your account has no linked GitHub identity yet, `void github install` first runs the GitHub link automatically (browser authorize), then continues. After installing, run `void github connect` to link a repository to your project.

### `void github installations`

Expand Down
209 changes: 147 additions & 62 deletions packages/void/src/cli/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,11 @@ function base64Url(buffer: Buffer): string {
return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}

function createCodeVerifier(): string {
export function createCodeVerifier(): string {
return base64Url(randomBytes(32));
}

function createCodeChallenge(verifier: string): string {
export function createCodeChallenge(verifier: string): string {
return base64Url(createHash('sha256').update(verifier).digest());
}

Expand Down Expand Up @@ -263,28 +263,58 @@ export function openBrowser(
}

/**
* Start a local HTTP server, open the browser to the platform login page,
* and wait for the OAuth callback with a short-lived code. The CLI exchanges
* the code itself so the localhost callback never accepts bearer tokens.
* Start a local HTTP callback server, open the browser to a caller-built
* authorize URL, and wait for the redirect back to `http://localhost:PORT/callback`.
*
* `onUrl` is invoked with the login URL once the local server is listening,
* so callers can show the URL to the user as a manual fallback when the
* browser fails to open (common on WSL2, headless SSH, Docker, etc.).
* The server validates the `state` it appended to the callback URL against the
* incoming request, then runs the caller's `finalize` hook INSIDE the request
* handler (so the browser only ever sees the success page after the underlying
* exchange/link actually succeeded). On finalize success it renders the success
* HTML and resolves with finalize's return value; on finalize failure it
* renders the same "could not complete" failure page the login flow uses and
* rejects with the thrown error. The server is always closed before settling.
*
* `onUrl` is invoked with the authorize URL once the local server is listening,
* so callers can show the URL to the user as a manual fallback when the browser
* fails to open (common on WSL2, headless SSH, Docker, etc.). To match the
* login flow it fires immediately before the browser is opened.
*
* `execFile` is injectable so tests can avoid launching a real browser.
*/
export async function runLogin(
root: string = process.cwd(),
provider = 'github',
onUrl?: (url: string) => void,
): Promise<string> {
export async function awaitBrowserCallback<T>(options: {
/** Caller-generated; appended to the callback URL and validated on the incoming request. */
state: string;
/** Given `http://localhost:PORT/callback?state=…`, return the URL to open. */
buildAuthUrl: (callback: URL) => Promise<string> | string;
/**
* Runs INSIDE the request handler, after state/error/validate checks pass and
* BEFORE the success page is rendered. Resolve to settle the outer promise
* with this value (success page shown); throw to render the failure page and
* reject the outer promise with the thrown error.
*/
finalize: (params: URLSearchParams) => Promise<T>;
/** Map an `?error=…` query to a user message (return null to ignore). */
describeError?: (params: URLSearchParams) => string | null;
/**
* Reject an otherwise-valid callback request (renders a 400 page) WITHOUT
* settling, so the server keeps listening for a subsequent valid callback.
* Used by login to reject `?token=` and missing-`code` requests. Return a
* message to reject, or null to accept.
*/
validate?: (params: URLSearchParams) => string | null;
/** Optional, invoked with the authorize URL before the browser is opened. */
onUrl?: (url: string) => void;
/** Default 5 minutes. */
timeoutMs?: number;
/** Injectable for tests; defaults to the real `node:child_process` `execFile`. */
execFile?: typeof import('node:child_process').execFile;
}): Promise<T> {
const http = await import('node:http');
const { execFile } = await import('node:child_process');
const { state, buildAuthUrl, finalize, describeError, validate, onUrl } = options;
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
const execFile = options.execFile ?? (await import('node:child_process')).execFile;

const apiUrl = getApiUrl(root);
const state = randomBytes(32).toString('hex');
const codeVerifier = createCodeVerifier();
const codeChallenge = createCodeChallenge(codeVerifier);

return new Promise<string>((resolve, reject) => {
return new Promise<T>((resolve, reject) => {
let settled = false;

const fail = (error: Error): void => {
Expand All @@ -296,13 +326,13 @@ export async function runLogin(
reject(error);
};

const succeed = (token: string): void => {
const succeed = (result: T): void => {
if (settled) {
return;
}
settled = true;
server.close();
resolve(token);
resolve(result);
};

const server = http.createServer(async (req, res) => {
Expand All @@ -319,79 +349,134 @@ export async function runLogin(
return;
}

const error = url.searchParams.get('error');
if (error === 'signup_restricted') {
const errorMessage = describeError?.(url.searchParams);
if (errorMessage) {
res.writeHead(403, { 'Content-Type': 'text/html' });
res.end(
page('Signup restricted', 'Your email is not on the approved list for this environment.'),
);
fail(
new Error(
'Signup restricted: your email is not on the approved list for this environment.',
),
);
return;
}

if (url.searchParams.has('token')) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(page('Login failed', 'Invalid login callback. Please try again.'));
res.end(page('Login failed', errorMessage));
fail(new Error(errorMessage));
return;
}

const code = url.searchParams.get('code');
if (!code) {
// Reject-but-keep-listening: invalid request shapes (e.g. a stray
// `?token=`, or a redirect that arrived without a code). The server
// stays open so the genuine callback can still complete.
const invalidMessage = validate?.(url.searchParams);
if (invalidMessage) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(page('Login failed', 'No authorization code received. Please try again.'));
res.end(page('Login failed', invalidMessage));
return;
}

let token: string;
// Finalize INSIDE the request path so the browser only sees the success
// page once the underlying exchange/link succeeds. A finalize failure
// (expired code, API error, 409 conflict, etc.) renders the failure page
// and rejects — matching the base login flow's exchange-in-request-path
// behavior. Guard against the timeout racing the in-flight finalize.
let result: T;
try {
token = await exchangeLoginCode(apiUrl, code, state, codeVerifier);
result = await finalize(url.searchParams);
} catch (err) {
if (settled) {
return;
}
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(page('Login failed', 'Could not complete login. Please try again.'));
fail(err instanceof Error ? err : new Error('auth: Failed to complete login.'));
return;
}

if (settled) {
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(page('Login successful!', 'You can close this tab and return to the terminal.'));
succeed(token);
succeed(result);
});

// Close immediately after callback — don't wait for keep-alive timeout
server.on('connection', (socket) => socket.unref());

server.listen(0, '127.0.0.1', () => {
server.listen(0, '127.0.0.1', async () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
reject(new Error('auth: Failed to start local callback server.'));
fail(new Error('auth: Failed to start local callback server.'));
return;
}

const port = addr.port;
const redirect = new URL(`http://localhost:${port}/callback`);
redirect.searchParams.set('state', state);
const callback = new URL(`http://localhost:${port}/callback`);
callback.searchParams.set('state', state);

const loginUrl = new URL('auth/login', apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
loginUrl.searchParams.set('redirect', redirect.href);
loginUrl.searchParams.set('provider', provider);
loginUrl.searchParams.set('code_challenge', codeChallenge);
loginUrl.searchParams.set('code_challenge_method', 'S256');
let authUrl: string;
try {
authUrl = await buildAuthUrl(callback);
} catch (err) {
fail(err instanceof Error ? err : new Error('auth: Failed to build authorize URL.'));
return;
}

onUrl?.(loginUrl.href);
openBrowser(loginUrl.href, execFile);
onUrl?.(authUrl);
openBrowser(authUrl, execFile);
});

// 5-minute timeout
const timeout = setTimeout(
() => {
fail(new Error('auth: Login timed out after 5 minutes. Try again.'));
},
5 * 60 * 1000,
);
const timeout = setTimeout(() => {
fail(new Error('auth: Login timed out after 5 minutes. Try again.'));
}, timeoutMs);
timeout.unref();
});
}

/**
* Start a local HTTP server, open the browser to the platform login page,
* and wait for the OAuth callback with a short-lived code. The CLI exchanges
* the code itself so the localhost callback never accepts bearer tokens.
*
* `onUrl` is invoked with the login URL once the local server is listening,
* so callers can show the URL to the user as a manual fallback when the
* browser fails to open (common on WSL2, headless SSH, Docker, etc.).
*/
export async function runLogin(
root: string = process.cwd(),
provider = 'github',
onUrl?: (url: string) => void,
): Promise<string> {
const { execFile } = await import('node:child_process');

const apiUrl = getApiUrl(root);
const state = randomBytes(32).toString('hex');
const codeVerifier = createCodeVerifier();
const codeChallenge = createCodeChallenge(codeVerifier);

// The exchange runs INSIDE the request handler (via finalize), so an exchange
// failure renders the failure page — exactly as the base login flow did.
return awaitBrowserCallback<string>({
state,
execFile,
onUrl,
buildAuthUrl: (callback) => {
const loginUrl = new URL('auth/login', apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
loginUrl.searchParams.set('redirect', callback.href);
loginUrl.searchParams.set('provider', provider);
loginUrl.searchParams.set('code_challenge', codeChallenge);
loginUrl.searchParams.set('code_challenge_method', 'S256');
return loginUrl.href;
},
describeError: (p) =>
p.get('error') === 'signup_restricted'
? 'Signup restricted: your email is not on the approved list for this environment.'
: null,
// Keep listening but reject these request shapes (preserves the original
// localhost server behavior): a stray `?token=` is never honored, and a
// callback without a `code` is ignored until the real one arrives.
validate: (p) => {
if (p.has('token')) {
return 'Invalid login callback. Please try again.';
}
if (!p.get('code')) {
return 'No authorization code received. Please try again.';
}
return null;
},
finalize: (params) => exchangeLoginCode(apiUrl, params.get('code')!, state, codeVerifier),
});
}
2 changes: 1 addition & 1 deletion packages/void/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ if (helpPath) {
},
{
name: 'github',
hint: 'install | installations | connect',
hint: 'install | installations | connect | link',
description: 'Connect a GitHub repository for automatic deploys',
},
{
Expand Down
39 changes: 39 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,4 +929,43 @@ export class PlatformClient {

return res.json();
}

async getGithubLinkUrl(redirect: string, codeChallenge: string): Promise<{ url: string }> {
const params = new URLSearchParams({
redirect,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const res = await fetch(`${this.baseUrl}/github/link-url?${params}`, {
headers: {
...this.authHeaders,
},
});

if (!res.ok) {
await throwPlatformApiError('Failed to start GitHub link', res);
}

return res.json();
}

async completeGithubLink(
linkCode: string,
codeVerifier: string,
): Promise<{ status: 'linked' | 'already_linked' }> {
const res = await fetch(`${this.baseUrl}/github/link/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authHeaders,
},
body: JSON.stringify({ link_code: linkCode, code_verifier: codeVerifier }),
});

if (!res.ok) {
await throwPlatformApiError('Failed to complete GitHub link', res);
}

return res.json();
}
}
11 changes: 10 additions & 1 deletion packages/void/src/cli/github-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export const githubSubcommands: Array<SubcommandDef> = [
arg: '[project]',
description: 'Connect a repository to a project for auto-deploy',
},
{ name: 'link', description: 'Link your GitHub account to Void' },
];

export type GithubArgs =
| { subcommand: 'install' }
| { subcommand: 'installations' }
| { subcommand: 'link' }
| {
subcommand: 'connect';
projectName: string;
Expand All @@ -39,6 +41,13 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
return { subcommand };
}

if (subcommand === 'link') {
if (rest.length > 0) {
throw new Error(`github: Unexpected argument '${rest[0]}'.`);
}
return { subcommand };
}

if (subcommand === 'connect') {
let projectName = '';
let installationId: string | undefined;
Expand Down Expand Up @@ -111,5 +120,5 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
return { subcommand, projectName, installationId, repo, branch, executor };
}

throw new Error('github: Usage: void github <install|installations|connect>.');
throw new Error('github: Usage: void github <install|installations|connect|link>.');
}
Loading