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 diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index 442fb919..a9eae01a 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -29,9 +29,20 @@ export const AuthMethodSelector = ({ // Call the optional analytics callback first onProviderClick?.(provider); - signIn(provider, { - redirectTo: callbackUrl ?? "/" - }); + // @nocheckin + signIn( + provider, + { + redirectTo: callbackUrl ?? "/", + }, + // @see: https://github.com/nextauthjs/next-auth/issues/2066 + // @see: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + // @see: https://next-auth.js.org/getting-started/client#additional-parameters + { + prompt: 'consent', + scope: 'read:user user:email repo' + } + ); }, [callbackUrl, onProviderClick]); // Separate OAuth providers from special auth methods diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index f61219d7..d965f73f 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -60,88 +60,92 @@ export const getProviders = () => { const providers: IdentityProvider[] = eeIdentityProviders; if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') { - providers.push({ provider: EmailProvider({ - server: env.SMTP_CONNECTION_URL, - from: env.EMAIL_FROM_ADDRESS, - maxAge: 60 * 10, - generateVerificationToken: async () => { - const token = String(Math.floor(100000 + Math.random() * 900000)); - return token; - }, - sendVerificationRequest: async ({ identifier, provider, token }) => { - const transport = createTransport(provider.server); - const html = await render(MagicLinkEmail({ token: token })); - const result = await transport.sendMail({ - to: identifier, - from: provider.from, - subject: 'Log in to Sourcebot', - html, - text: `Log in to Sourcebot using this code: ${token}` - }); + providers.push({ + provider: EmailProvider({ + server: env.SMTP_CONNECTION_URL, + from: env.EMAIL_FROM_ADDRESS, + maxAge: 60 * 10, + generateVerificationToken: async () => { + const token = String(Math.floor(100000 + Math.random() * 900000)); + return token; + }, + sendVerificationRequest: async ({ identifier, provider, token }) => { + const transport = createTransport(provider.server); + const html = await render(MagicLinkEmail({ token: token })); + const result = await transport.sendMail({ + to: identifier, + from: provider.from, + subject: 'Log in to Sourcebot', + html, + text: `Log in to Sourcebot using this code: ${token}` + }); - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length) { - throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`); + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length) { + throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`); + } } - } - }), purpose: "sso"}); + }), purpose: "sso" + }); } if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') { - providers.push({ provider: Credentials({ - credentials: { - email: {}, - password: {} - }, - type: "credentials", - authorize: async (credentials) => { - const body = verifyCredentialsRequestSchema.safeParse(credentials); - if (!body.success) { - return null; - } - const { email, password } = body.data; + providers.push({ + provider: Credentials({ + credentials: { + email: {}, + password: {} + }, + type: "credentials", + authorize: async (credentials) => { + const body = verifyCredentialsRequestSchema.safeParse(credentials); + if (!body.success) { + return null; + } + const { email, password } = body.data; - const user = await prisma.user.findUnique({ - where: { email } - }); + const user = await prisma.user.findUnique({ + where: { email } + }); + + // The user doesn't exist, so create a new one. + if (!user) { + const hashedPassword = bcrypt.hashSync(password, 10); + const newUser = await prisma.user.create({ + data: { + email, + hashedPassword, + } + }); - // The user doesn't exist, so create a new one. - if (!user) { - const hashedPassword = bcrypt.hashSync(password, 10); - const newUser = await prisma.user.create({ - data: { - email, - hashedPassword, + const authJsUser: AuthJsUser = { + id: newUser.id, + email: newUser.email, } - }); - const authJsUser: AuthJsUser = { - id: newUser.id, - email: newUser.email, - } + onCreateUser({ user: authJsUser }); + return authJsUser; - onCreateUser({ user: authJsUser }); - return authJsUser; + // Otherwise, the user exists, so verify the password. + } else { + if (!user.hashedPassword) { + return null; + } - // Otherwise, the user exists, so verify the password. - } else { - if (!user.hashedPassword) { - return null; - } + if (!bcrypt.compareSync(password, user.hashedPassword)) { + return null; + } - if (!bcrypt.compareSync(password, user.hashedPassword)) { - return null; + return { + id: user.id, + email: user.email, + name: user.name ?? undefined, + image: user.image ?? undefined, + }; } - - return { - id: user.id, - email: user.email, - name: user.name ?? undefined, - image: user.image ?? undefined, - }; } - } - }), purpose: "sso"}); + }), purpose: "sso" + }); } return providers; @@ -156,7 +160,29 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, events: { createUser: onCreateUser, - signIn: async ({ user }) => { + signIn: async ({ user, account }) => { + // Explicitly update the Account record with the OAuth token details. + // This is necessary to update the access token when the user + // re-authenticates. + if (account && account.provider && account.providerAccountId) { + await prisma.account.update({ + where: { + provider_providerAccountId: { + provider: account.provider, + providerAccountId: account.providerAccountId, + }, + }, + data: { + refresh_token: account.refresh_token, + access_token: account.access_token, + expires_at: account.expires_at, + token_type: account.token_type, + scope: account.scope, + id_token: account.id_token, + } + }) + } + if (user.id) { await auditService.createAudit({ action: "user.signed_in", @@ -225,7 +251,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // Propagate the userId to the session. id: token.userId, } - + // Pass only linked account provider errors to the session (not sensitive tokens) if (token.linkedAccountTokens) { const errors: Record = {};