Skip to content
71 changes: 71 additions & 0 deletions packages/backend/src/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import {
detectGitHubTokenType,
supportsOAuthScopeIntrospection,
getGitHubReposFromConfig,
supportsUserIntrospection,
verifyCredential,
} from './github';

describe("GitHub repository discovery", () => {
Expand Down Expand Up @@ -374,3 +376,72 @@ test('shouldExcludeRepo handles exclude.repos correctly', () => {
}
})).toBe(false);
});

describe('supportsUserIntrospection', () => {
test('user-context tokens can call GET /user', () => {
expect(supportsUserIntrospection('classic_pat')).toBe(true);
expect(supportsUserIntrospection('oauth_user')).toBe(true);
expect(supportsUserIntrospection('app_user')).toBe(true);
expect(supportsUserIntrospection('fine_grained_pat')).toBe(true);
});

test('installation tokens cannot call GET /user', () => {
expect(supportsUserIntrospection('app_installation')).toBe(false);
});

test('unknown token types are not assumed to be user-context', () => {
expect(supportsUserIntrospection('unknown')).toBe(false);
});
});

describe('verifyCredential', () => {
const httpError = (status: number) =>
Object.assign(new Error(`HTTP ${status}`), { status, name: 'HttpError' });

const makeOctokit = () => ({
rest: { users: { getAuthenticated: vi.fn() } },
request: vi.fn(),
});

test('validates a personal access token via GET /user', async () => {
const octokit = makeOctokit();
await verifyCredential(octokit as never, 'ghp_abc123');
expect(octokit.rest.users.getAuthenticated).toHaveBeenCalledOnce();
expect(octokit.request).not.toHaveBeenCalled();
});

test('validates an installation token via the installation endpoint', async () => {
const octokit = makeOctokit();
await verifyCredential(octokit as never, 'ghs_abc123');
expect(octokit.rest.users.getAuthenticated).not.toHaveBeenCalled();
expect(octokit.request).toHaveBeenCalledWith('GET /installation/repositories', { per_page: 1 });
});

test('falls back to the installation endpoint when an unknown token is 403ed', async () => {
const octokit = makeOctokit();
octokit.rest.users.getAuthenticated.mockRejectedValueOnce(httpError(403));
await verifyCredential(octokit as never, 'weird_prefix_abc123');
expect(octokit.rest.users.getAuthenticated).toHaveBeenCalledOnce();
expect(octokit.request).toHaveBeenCalledWith('GET /installation/repositories', { per_page: 1 });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('does not fall back for an unknown token after a non-403 response', async () => {
const octokit = makeOctokit();
octokit.rest.users.getAuthenticated.mockRejectedValueOnce(httpError(401));
await expect(verifyCredential(octokit as never, 'weird_prefix_bad')).rejects.toThrow();
expect(octokit.request).not.toHaveBeenCalled();
});

test('a genuinely invalid credential still throws', async () => {
const octokit = makeOctokit();
octokit.rest.users.getAuthenticated.mockRejectedValueOnce(httpError(401));
await expect(verifyCredential(octokit as never, 'ghp_bad')).rejects.toThrow();
expect(octokit.request).not.toHaveBeenCalled();
});

test('an installation token with no accessible installation still throws', async () => {
const octokit = makeOctokit();
octokit.request.mockRejectedValueOnce(httpError(401));
await expect(verifyCredential(octokit as never, 'ghs_bad')).rejects.toThrow();
});
});
50 changes: 49 additions & 1 deletion packages/backend/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export type GitHubTokenType =
*/
export const SCOPE_INTROSPECTABLE_TOKEN_TYPES: GitHubTokenType[] = ['classic_pat', 'oauth_user'];

/**
* Token types that authenticate as a *user* and can therefore call `GET /user`.
* A GitHub App installation token (`ghs_`) authenticates as an installation and has no
* associated user, so `GET /user` returns 403 "Resource not accessible by integration".
*/
export const USER_INTROSPECTABLE_TOKEN_TYPES: GitHubTokenType[] =
['classic_pat', 'oauth_user', 'app_user', 'fine_grained_pat'];

/**
* Detects the GitHub token type based on its prefix.
* @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
Expand All @@ -52,6 +60,14 @@ export const supportsOAuthScopeIntrospection = (tokenType: GitHubTokenType): boo
return SCOPE_INTROSPECTABLE_TOKEN_TYPES.includes(tokenType);
};

/**
* Checks if a token type can be validated via `GET /user`. Installation tokens cannot;
* they are validated against `GET /installation/repositories` instead.
*/
export const supportsUserIntrospection = (tokenType: GitHubTokenType): boolean => {
return USER_INTROSPECTABLE_TOKEN_TYPES.includes(tokenType);
};

/**
* Type guard to check if an error is an Octokit RequestError.
*/
Expand Down Expand Up @@ -101,6 +117,38 @@ const isHttpError = (error: unknown, status: number): boolean => {
&& error.status === status;
}

/**
* Verifies that a credential is usable, against whichever endpoint suits its type.
*
* `GET /user` is only meaningful for tokens that authenticate as a user. A GitHub App
* installation token has no user, so the same call returns 403 and a valid credential
* looks like an authentication failure. Such tokens are verified against the installation
* instead. Tokens whose prefix we do not recognise - enterprise proxies, future formats -
* are tried both ways before being rejected.
*/
export const verifyCredential = async (octokit: Octokit, token?: string): Promise<void> => {
const tokenType = token ? detectGitHubTokenType(token) : 'unknown';

if (supportsUserIntrospection(tokenType)) {
await octokit.rest.users.getAuthenticated();
return;
}

if (tokenType === 'app_installation') {
await octokit.request('GET /installation/repositories', { per_page: 1 });
return;
}

try {
await octokit.rest.users.getAuthenticated();
} catch (error) {
if (!isHttpError(error, 403)) {
throw error;
}
await octokit.request('GET /installation/repositories', { per_page: 1 });
}
};

export const createOctokitFromToken = async ({ token, url }: { token?: string, url?: string }): Promise<{ octokit: Octokit, isAuthenticated: boolean }> => {
const isGitHubCloud = url ? new URL(url).hostname === GITHUB_CLOUD_HOSTNAME : true;
const octokit = new Octokit({
Expand Down Expand Up @@ -182,7 +230,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, s

if (isAuthenticated) {
try {
await octokit.rest.users.getAuthenticated();
await verifyCredential(octokit, token);
} catch (error) {
Sentry.captureException(error);
logger.error(`Failed to authenticate with GitHub`, error);
Expand Down