Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/main/handlers/github-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { delimiter } from 'node:path';

import { readGitHubCliToken } from './github-cli';

const execFileMock = vi.fn();

vi.mock('node:child_process', () => {
const execFile = (...args: unknown[]) => execFileMock(...args);

// Node's real `execFile` carries this custom implementation, which is what
// makes `promisify(execFile)` resolve to `{ stdout, stderr }`.
(execFile as unknown as Record<symbol, unknown>)[Symbol.for('nodejs.util.promisify.custom')] = (
...args: unknown[]
) => execFileMock(...args);

return { execFile };
});

vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } }));

describe('main/handlers/github-cli.ts', () => {
const inheritedEnv = { ...process.env };

beforeEach(() => {
execFileMock.mockReset();
execFileMock.mockResolvedValue({ stdout: 'gho_token', stderr: '' });
});

afterEach(() => {
process.env = { ...inheritedEnv };
});

function spawnedEnv(): Record<string, string | undefined> {
const options = execFileMock.mock.calls[0][2];
return options.env;
}

it('returns the token the CLI holds for the host', async () => {
execFileMock.mockResolvedValue({ stdout: 'gho_token\n', stderr: '' });

await expect(readGitHubCliToken('github.com')).resolves.toEqual({ token: 'gho_token' });
expect(execFileMock).toHaveBeenCalledWith(
'gh',
['auth', 'token', '--hostname', 'github.com'],
expect.anything(),
);
});

it('never leaves an empty PATH element, which would search the working directory', async () => {
delete process.env.PATH;

await readGitHubCliToken('github.com');

expect(spawnedEnv().PATH?.split(delimiter)).not.toContain('');
});

it("passes an ambient token through, since it can be the CLI's only credential", async () => {
process.env.GH_TOKEN = 'gho_from_shell';

await readGitHubCliToken('github.com');

expect(spawnedEnv().GH_TOKEN).toBe('gho_from_shell');
});

it('reports a missing CLI', async () => {
execFileMock.mockRejectedValue(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }));

await expect(readGitHubCliToken('github.com')).resolves.toEqual({ error: 'GH_NOT_FOUND' });
});

it('reports a CLI with no token for the host', async () => {
execFileMock.mockRejectedValue(
Object.assign(new Error('exit 1'), {
code: 1,
stderr: 'no oauth token found for github.example.com\n',
}),
);

await expect(readGitHubCliToken('github.example.com')).resolves.toEqual({
error: 'GH_NOT_AUTHENTICATED',
});
});

it('treats empty CLI output as no token', async () => {
execFileMock.mockResolvedValue({ stdout: '\n', stderr: '' });

await expect(readGitHubCliToken('github.com')).resolves.toEqual({
error: 'GH_NOT_AUTHENTICATED',
});
});

it('reports a timed-out CLI separately, since a keychain prompt blocks it', async () => {
execFileMock.mockRejectedValue(Object.assign(new Error('killed'), { killed: true }));

await expect(readGitHubCliToken('github.com')).resolves.toEqual({ error: 'GH_TIMED_OUT' });
});

it("carries the CLI's own reason for an unclassified failure", async () => {
execFileMock.mockRejectedValue(
Object.assign(new Error('exit 1'), { code: 1, stderr: 'keyring is locked\nmore detail' }),
);

await expect(readGitHubCliToken('github.com')).resolves.toEqual({
error: 'GH_FAILED',
detail: 'keyring is locked',
});
});

it('never spawns the CLI for a hostname it cannot vouch for', async () => {
await expect(readGitHubCliToken('github.com; rm -rf /')).resolves.toEqual({
error: 'GH_FAILED',
});
expect(execFileMock).not.toHaveBeenCalled();
});
});
107 changes: 107 additions & 0 deletions src/main/handlers/github-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { execFile } from 'node:child_process';
import { homedir } from 'node:os';
import { delimiter } from 'node:path';
import { promisify } from 'node:util';

import type { GitHubCliTokenError, IGitHubCliTokenResult } from '../../shared/events';
import { EVENTS } from '../../shared/events';
import { logError, toError } from '../../shared/logger';
import { isWindows } from '../../shared/platform';

import { handleMainEvent } from '../events';

const execFileAsync = promisify(execFile);

/**
* A GUI-launched app inherits the session launcher's minimal PATH, which omits
* the package-manager prefixes `gh` is usually installed under. Unix-only: the
* Windows installer puts `gh` on the inherited PATH itself.
*/
const EXTRA_PATH_ENTRIES = isWindows()
? []
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${homedir()}/.local/bin`,
];

const HOSTNAME_PATTERN = /^[a-z0-9][a-z0-9.-]*$/i;

/**
* Ask the locally installed GitHub CLI for the token it holds for `hostname`.
*
* Whatever the CLI resolves is what Gitify uses, including a token it takes
* from `GH_TOKEN`/`GH_ENTERPRISE_TOKEN`: for some users that environment token
* is the only credential `gh` has.
*
* @param hostname - Host to read the token for (e.g. `github.com`).
* @returns The token, or the reason the CLI could not supply one.
*/
export async function readGitHubCliToken(hostname: string): Promise<IGitHubCliTokenResult> {
if (typeof hostname !== 'string' || !HOSTNAME_PATTERN.test(hostname)) {
return { error: 'GH_FAILED' };
}

const env: NodeJS.ProcessEnv = { ...process.env, PATH: buildPath() };

try {
const { stdout } = await execFileAsync('gh', ['auth', 'token', '--hostname', hostname], {
env,
timeout: 10_000,
});

const token = stdout.trim();

return token ? { token } : { error: 'GH_NOT_AUTHENTICATED' };
} catch (err) {
const { error, detail } = classifyFailure(err);

if (error === 'GH_FAILED') {
logError('main:github-cli-token', `Failed to read gh token for ${hostname}`, toError(err));
}

return { error, detail };
}
}

/**
* An empty PATH element means "the current directory" to `execvp`, so an unset
* PATH must not leave one behind.
*/
function buildPath(): string {
return [process.env.PATH, ...EXTRA_PATH_ENTRIES].filter(Boolean).join(delimiter);
}

function classifyFailure(err: unknown): {
error: GitHubCliTokenError;
detail?: string;
} {
const { code, killed, stderr } = err as {
code?: string | number;
killed?: boolean;
stderr?: string;
};

if (code === 'ENOENT') {
return { error: 'GH_NOT_FOUND' };
}

if (killed) {
return { error: 'GH_TIMED_OUT' };
}

if (/no oauth token found|not logged in/i.test(stderr ?? '')) {
return { error: 'GH_NOT_AUTHENTICATED' };
}

return { error: 'GH_FAILED', detail: stderr?.trim().split('\n')[0] || undefined };
}

/**
* Register the IPC handler that resolves GitHub CLI tokens. Spawning is only
* possible from the main process, so the renderer asks for the token per host.
*/
export function registerGitHubCliHandlers(): void {
handleMainEvent(EVENTS.GITHUB_CLI_TOKEN, (_, hostname) => readGitHubCliToken(hostname));
}
1 change: 1 addition & 0 deletions src/main/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './app';
export * from './github-cli';
export * from './storage';
export * from './system';
export * from './tray';
Expand Down
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { menubar } from 'electron-menubar';
import { Paths, WindowConfig } from './config';
import {
registerAppHandlers,
registerGitHubCliHandlers,
registerStorageHandlers,
registerSystemHandlers,
registerTrayHandlers,
Expand Down Expand Up @@ -63,6 +64,7 @@ app.whenReady().then(async () => {
registerTrayHandlers(mb);
registerSystemHandlers(mb);
registerStorageHandlers();
registerGitHubCliHandlers();
registerAppHandlers(mb);
registerUpdaterHandlers(appUpdater);
});
Expand Down
8 changes: 8 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export const api = {
*/
decryptValue: (value: string) => invokeMainEvent(EVENTS.SAFE_STORAGE_DECRYPT, value),

/**
* Read the token the locally installed GitHub CLI holds for a host.
*
* @param hostname - Host to read the token for (e.g. `github.com`).
* @returns The token, or the reason the CLI could not supply one.
*/
githubCliToken: (hostname: string) => invokeMainEvent(EVENTS.GITHUB_CLI_TOKEN, hostname),

/**
* Enable or disable launching the application at system login.
*
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { BitbucketLoginWithPersonalAccessTokenRoute } from './routes/bitbucket/L
import { FiltersRoute } from './routes/Filters';
import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken';
import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow';
import { GitHubLoginWithCLIRoute } from './routes/github/LoginWithGitHubCLI';
import { GitHubLoginWithOAuthAppRoute } from './routes/github/LoginWithOAuthApp';
import { GitHubLoginWithPersonalAccessTokenRoute } from './routes/github/LoginWithPersonalAccessToken';
import { GitLabLoginWithPersonalAccessTokenRoute } from './routes/gitlab/LoginWithPersonalAccessToken';
Expand Down Expand Up @@ -101,6 +102,7 @@ export const App = () => {
element={<GitHubLoginWithDeviceFlowRoute />}
path="/login/github/device-flow"
/>
<Route element={<GitHubLoginWithCLIRoute />} path="/login/github/cli" />
<Route
element={<GitHubLoginWithPersonalAccessTokenRoute />}
path="/login/github/personal-access-token"
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/hook-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function buildLoginsDefaults(): LoginsState {
loginWithDeviceFlowStart: vi.fn(),
loginWithDeviceFlowPoll: vi.fn(),
loginWithDeviceFlowComplete: vi.fn(),
loginWithCli: vi.fn(),
loginWithOAuthApp: vi.fn(),
loginWithPersonalAccessToken: vi.fn(),
logoutFromAccount: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const LOGIN_KEYS = [
'loginWithDeviceFlowStart',
'loginWithDeviceFlowPoll',
'loginWithDeviceFlowComplete',
'loginWithCli',
'loginWithOAuthApp',
'loginWithPersonalAccessToken',
'logoutFromAccount',
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/visual.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
openExternalLink: vi.fn(),
decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }),
encryptValue: vi.fn().mockResolvedValue('encrypted'),
githubCliToken: vi.fn().mockResolvedValue({ token: 'gh-cli-token' }),
setWindowVibrancy: vi.fn().mockResolvedValue(undefined),
setNativeTheme: vi.fn().mockResolvedValue(undefined),
platform: {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ function createGitifyBridgeApi(): Window['gitify'] {
openExternalLink: vi.fn(),
decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted' }),
encryptValue: vi.fn().mockResolvedValue('encrypted'),
githubCliToken: vi.fn().mockResolvedValue({ token: 'gh-cli-token' }),
setWindowVibrancy: vi.fn().mockResolvedValue(undefined),
setNativeTheme: vi.fn().mockResolvedValue(undefined),
platform: {
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/__mocks__/account-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export const mockPersonalAccessTokenAccount: Account = {
scopes: getRecommendedScopeNames(),
};

export const mockGitHubCliAccount: Account = {
forge: 'github',
platform: 'GitHub Cloud',
method: 'GitHub CLI',
// CLI accounts carry no credential of their own; the CLI is read per request.
token: '' as Token,
hostname: Constants.GITHUB_HOSTNAME,
user: mockGitifyUser,
// The scope set the GitHub CLI's own OAuth app is granted.
scopes: ['gist', 'read:org', 'repo', 'workflow'],
};

export const mockOAuthAccount: Account = {
forge: 'github',
platform: 'GitHub Enterprise Server',
Expand Down
30 changes: 30 additions & 0 deletions src/renderer/hooks/useLogins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface LoginsState {
) => Promise<DeviceFlowSession>;
loginWithDeviceFlowPoll: (forge: Forge, session: DeviceFlowSession) => Promise<Token | null>;
loginWithDeviceFlowComplete: (forge: Forge, token: Token, hostname: Hostname) => Promise<void>;
loginWithCli: (forge: Forge, hostname: Hostname) => Promise<void>;
loginWithOAuthApp: (forge: Forge, data: LoginOAuthWebOptions) => Promise<void>;
loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => Promise<void>;
logoutFromAccount: (account: Account) => Promise<void>;
Expand Down Expand Up @@ -104,6 +105,34 @@ export const useLogins = (): LoginsState => {
[accounts, createAccount, removeAccountNotifications],
);

/**
* Login with the token held by a locally installed forge CLI.
*
* The token is resolved here only to fail fast while the login screen is
* still up. Nothing persists it: the CLI is re-read for every API client, so
* the account carries no credential of its own.
*/
const loginWithCli = useCallback(
async (forge: Forge, hostname: Hostname) => {
const { cliAuth } = getAdapter(forge);
if (!cliAuth) {
throw new Error(`CLI login is not supported for forge "${forge}".`);
}

await cliAuth.resolveToken(hostname);

const existingAccount = accounts.find(
(a) => a.hostname === hostname && a.method === cliAuth.authMethod,
);
if (existingAccount) {
await removeAccountNotifications(existingAccount);
}

await createAccount(cliAuth.authMethod, '' as Token, hostname, forge);
},
[accounts, createAccount, removeAccountNotifications],
);

/**
* Login with a custom OAuth app on the given forge.
*/
Expand Down Expand Up @@ -171,6 +200,7 @@ export const useLogins = (): LoginsState => {
loginWithDeviceFlowStart,
loginWithDeviceFlowPoll,
loginWithDeviceFlowComplete,
loginWithCli,
loginWithOAuthApp,
loginWithPersonalAccessToken,
logoutFromAccount,
Expand Down
Loading
Loading