diff --git a/CHANGELOG.md b/CHANGELOG.md index 893a427e1..f868e5d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed issue where opening GitLab file links would result in a 404. [#846](https://github.com/sourcebot-dev/sourcebot/pull/846) - Fixed issue where file references in copied chat answers were relative paths instead of full browse URLs. [#847](https://github.com/sourcebot-dev/sourcebot/pull/847) +- [EE] Fixed issue where account driven permission syncing would fail when attempting to authenticate with a GitHub App user token. [#850](https://github.com/sourcebot-dev/sourcebot/pull/850) ## [4.10.24] - 2026-02-03 diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 805cc1756..9197f8eca 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -179,15 +179,33 @@ export class AccountPermissionSyncer { url: baseUrl, }); - const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit); - if (!scopes.includes('repo')) { - throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing.`); + const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit, account.access_token); + + // Token supports scope introspection (classic PAT or OAuth app token) + if (scopes !== null) { + if (!scopes.includes('repo')) { + throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing. Please re-authorize with GitHub to grant the required scope.`); + } } // @note: we only care about the private repos since we don't need to build a mapping // for public repos. // @see: packages/web/src/prisma.ts - const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit); + let githubRepos; + try { + githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit); + } catch (error) { + if (error && typeof error === 'object' && 'status' in error) { + const status = (error as { status: number }).status; + if (status === 401 || status === 403) { + throw new Error( + `GitHub API returned ${status} error. Your token may have expired or lacks the required permissions. ` + + `Please re-authorize with GitHub to grant the necessary access.` + ); + } + } + throw error; + } const gitHubRepoIds = githubRepos.map(repo => repo.id.toString()); const repos = await this.db.repo.findMany({ diff --git a/packages/backend/src/github.test.ts b/packages/backend/src/github.test.ts index ba0ef4c0e..7c9082db7 100644 --- a/packages/backend/src/github.test.ts +++ b/packages/backend/src/github.test.ts @@ -1,5 +1,64 @@ -import { expect, test } from 'vitest'; -import { OctokitRepository, shouldExcludeRepo } from './github'; +import { expect, test, describe } from 'vitest'; +import { + OctokitRepository, + shouldExcludeRepo, + detectGitHubTokenType, + supportsOAuthScopeIntrospection, +} from './github'; + +describe('detectGitHubTokenType', () => { + test('detects classic PAT (ghp_)', () => { + expect(detectGitHubTokenType('ghp_abc123def456')).toBe('classic_pat'); + }); + + test('detects OAuth app user token (gho_)', () => { + expect(detectGitHubTokenType('gho_abc123def456')).toBe('oauth_user'); + }); + + test('detects GitHub App user token (ghu_)', () => { + expect(detectGitHubTokenType('ghu_abc123def456')).toBe('app_user'); + }); + + test('detects GitHub App installation token (ghs_)', () => { + expect(detectGitHubTokenType('ghs_abc123def456')).toBe('app_installation'); + }); + + test('detects fine-grained PAT (github_pat_)', () => { + expect(detectGitHubTokenType('github_pat_abc123def456')).toBe('fine_grained_pat'); + }); + + test('returns unknown for unrecognized token format', () => { + expect(detectGitHubTokenType('some_random_token')).toBe('unknown'); + expect(detectGitHubTokenType('')).toBe('unknown'); + expect(detectGitHubTokenType('v1.abc123')).toBe('unknown'); + }); +}); + +describe('supportsOAuthScopeIntrospection', () => { + test('returns true for classic PAT', () => { + expect(supportsOAuthScopeIntrospection('classic_pat')).toBe(true); + }); + + test('returns true for OAuth app user token', () => { + expect(supportsOAuthScopeIntrospection('oauth_user')).toBe(true); + }); + + test('returns false for GitHub App user token', () => { + expect(supportsOAuthScopeIntrospection('app_user')).toBe(false); + }); + + test('returns false for GitHub App installation token', () => { + expect(supportsOAuthScopeIntrospection('app_installation')).toBe(false); + }); + + test('returns false for fine-grained PAT', () => { + expect(supportsOAuthScopeIntrospection('fine_grained_pat')).toBe(false); + }); + + test('returns false for unknown token type', () => { + expect(supportsOAuthScopeIntrospection('unknown')).toBe(false); + }); +}); test('shouldExcludeRepo returns true when clone_url is undefined', () => { const repo = { full_name: 'test/repo' } as OctokitRepository; diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index 53f3a01b8..4ef4e880c 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -12,6 +12,43 @@ import { fetchWithRetry, measure } from "./utils.js"; export const GITHUB_CLOUD_HOSTNAME = "github.com"; +/** + * GitHub token types and their prefixes. + * @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ + */ +export type GitHubTokenType = + | 'classic_pat' // ghp_ - Personal Access Token (classic) + | 'oauth_user' // gho_ - OAuth App user token + | 'app_user' // ghu_ - GitHub App user token + | 'app_installation' // ghs_ - GitHub App installation token + | 'fine_grained_pat' // github_pat_ - Fine-grained PAT + | 'unknown'; + +/** + * Token types that support scope introspection via x-oauth-scopes header. + */ +export const SCOPE_INTROSPECTABLE_TOKEN_TYPES: GitHubTokenType[] = ['classic_pat', 'oauth_user']; + +/** + * Detects the GitHub token type based on its prefix. + * @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ + */ +export const detectGitHubTokenType = (token: string): GitHubTokenType => { + if (token.startsWith('ghp_')) return 'classic_pat'; + if (token.startsWith('gho_')) return 'oauth_user'; + if (token.startsWith('ghu_')) return 'app_user'; + if (token.startsWith('ghs_')) return 'app_installation'; + if (token.startsWith('github_pat_')) return 'fine_grained_pat'; + return 'unknown'; +}; + +/** + * Checks if a token type supports OAuth scope introspection via x-oauth-scopes header. + */ +export const supportsOAuthScopeIntrospection = (tokenType: GitHubTokenType): boolean => { + return SCOPE_INTROSPECTABLE_TOKEN_TYPES.includes(tokenType); +}; + // Limit concurrent GitHub requests to avoid hitting rate limits and overwhelming installations. const MAX_CONCURRENT_GITHUB_QUERIES = 5; const githubQueryLimit = pLimit(MAX_CONCURRENT_GITHUB_QUERIES); @@ -182,6 +219,10 @@ export const getRepoCollaborators = async (owner: string, repo: string, octokit: } } +/** + * Lists repositories that the authenticated user has explicit permission (:read, :write, or :admin) to access. + * @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user + */ export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private' | 'public' = 'all', octokit: Octokit) => { try { const fetchFn = () => octokit.paginate(octokit.repos.listForAuthenticatedUser, { @@ -198,9 +239,30 @@ export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private' } } -// Gets oauth scopes -// @see: https://github.com/octokit/auth-token.js/?tab=readme-ov-file#find-out-what-scopes-are-enabled-for-oauth-tokens -export const getOAuthScopesForAuthenticatedUser = async (octokit: Octokit) => { +/** + * Gets OAuth scopes for a GitHub token. + * + * Returns `null` for token types that don't support scope introspection: + * - GitHub App user tokens (ghu_) + * - GitHub App installation tokens (ghs_) + * - Fine-grained PATs (github_pat_) + * + * Returns scope array for tokens that support introspection: + * - Classic PATs (ghp_) + * - OAuth App user tokens (gho_) + * + * @see https://github.com/octokit/auth-token.js/?tab=readme-ov-file#find-out-what-scopes-are-enabled-for-oauth-tokens + * @see https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ + */ +export const getOAuthScopesForAuthenticatedUser = async (octokit: Octokit, token?: string): Promise => { + // If token is provided, check if it supports scope introspection + if (token) { + const tokenType = detectGitHubTokenType(token); + if (!supportsOAuthScopeIntrospection(tokenType)) { + return null; + } + } + try { const response = await octokit.request("HEAD /"); const scopes = response.headers["x-oauth-scopes"]?.split(/,\s+/) || [];