diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index a1c879a2..45bd46ee 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,11 +1,19 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, AccountPermissionSyncJobStatus, Account} from "@sourcebot/db"; +import { PrismaClient, AccountPermissionSyncJobStatus, Account } from "@sourcebot/db"; import { env, hasEntitlement, createLogger } from "@sourcebot/shared"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js"; -import { createOctokitFromToken, getReposForAuthenticatedUser } from "../github.js"; -import { createGitLabFromOAuthToken, getProjectsForAuthenticatedUser } from "../gitlab.js"; +import { + createOctokitFromToken, + getOAuthScopesForAuthenticatedUser as getGitHubOAuthScopesForAuthenticatedUser, + getReposForAuthenticatedUser, +} from "../github.js"; +import { + createGitLabFromOAuthToken, + getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser, + getProjectsForAuthenticatedUser, +} from "../gitlab.js"; import { Settings } from "../types.js"; import { setIntervalAsync } from "../utils.js"; @@ -163,6 +171,12 @@ export class AccountPermissionSyncer { token: account.access_token, url: env.AUTH_EE_GITHUB_BASE_URL, }); + + 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.`); + } + // @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 @@ -189,6 +203,11 @@ export class AccountPermissionSyncer { url: env.AUTH_EE_GITLAB_BASE_URL, }); + const scopes = await getGitLabOAuthScopesForAuthenticatedUser(api); + if (!scopes.includes('read_api')) { + throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'read_api' scope required for permission syncing.`); + } + // @note: we only care about the private and internal repos since we don't need to build a mapping // for public repos. // @see: packages/web/src/prisma.ts diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index 49ce1d37..85169058 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -197,6 +197,20 @@ 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) => { + try { + const response = await octokit.request("HEAD /"); + const scopes = response.headers["x-oauth-scopes"]?.split(/,\s+/) || []; + return scopes; + } catch (error) { + Sentry.captureException(error); + logger.error(`Failed to fetch OAuth scopes for authenticated user.`, error); + throw error; + } +} + const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal, url?: string) => { const results = await Promise.allSettled(users.map((user) => githubQueryLimit(async () => { try { diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index b461d59b..6cf25de9 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -41,8 +41,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) = const token = config.token ? await getTokenFromConfig(config.token) : hostname === GITLAB_CLOUD_HOSTNAME ? - env.FALLBACK_GITLAB_CLOUD_TOKEN : - undefined; + env.FALLBACK_GITLAB_CLOUD_TOKEN : + undefined; const api = await createGitLabFromPersonalAccessToken({ token, @@ -202,7 +202,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) = return !isExcluded; }); - + logger.debug(`Found ${repos.length} total repositories.`); return { @@ -311,4 +311,29 @@ export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'i logger.error(`Failed to fetch projects for authenticated user.`, error); throw error; } +} + +// Fetches OAuth scopes for the authenticated user. +// @see: https://github.com/doorkeeper-gem/doorkeeper/wiki/API-endpoint-descriptions-and-examples#get----oauthtokeninfo +// @see: https://docs.gitlab.com/api/oauth2/#retrieve-the-token-information +export const getOAuthScopesForAuthenticatedUser = async (api: InstanceType) => { + try { + const response = await api.requester.get('/oauth/token/info'); + console.log('response', response); + if ( + response && + typeof response.body === 'object' && + response.body !== null && + 'scope' in response.body && + Array.isArray(response.body.scope) + ) { + return response.body.scope; + } + + throw new Error('/oauth/token_info response body is not in the expected format.'); + } catch (error) { + Sentry.captureException(error); + logger.error('Failed to fetch OAuth scopes for authenticated user.', error); + throw error; + } } \ No newline at end of file