From b26442d5427ab81c89d859a7e8926359b7a337e2 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 28 Nov 2025 13:36:37 -0800 Subject: [PATCH 1/7] Add explicit error message when OAuth scopes are incorrect --- .../backend/src/ee/accountPermissionSyncer.ts | 23 ++++++++++++-- packages/backend/src/github.ts | 14 +++++++++ packages/backend/src/gitlab.ts | 31 +++++++++++++++++-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 315c86811..ff267873a 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -4,8 +4,16 @@ import { env, hasEntitlement, createLogger, loadConfig } 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"; @@ -170,6 +178,12 @@ export class AccountPermissionSyncer { token: account.access_token, 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.`); + } + // @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 @@ -201,6 +215,11 @@ export class AccountPermissionSyncer { url: baseUrl, }); + 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 49ce1d37e..85169058c 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 44685424a..e1dac00b7 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, @@ -207,7 +207,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) = return !isExcluded; }); - + logger.debug(`Found ${repos.length} total repositories.`); return { @@ -316,4 +316,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 From 9a3ac61258ca78c114714ccf8187a7464432c295 Mon Sep 17 00:00:00 2001 From: bkellam Date: Fri, 28 Nov 2025 21:47:09 -0800 Subject: [PATCH 2/7] wip on updating access_token --- .../src/app/components/authMethodSelector.tsx | 17 +- packages/web/src/auth.ts | 166 ++++++++++-------- 2 files changed, 110 insertions(+), 73 deletions(-) diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index 442fb919a..a9eae01ad 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 f61219d7b..d965f73f3 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 = {}; From 84b1cf436b689ba2b5d717e68ef764da5cc1b1a6 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 13 Jan 2026 20:59:56 -0800 Subject: [PATCH 3/7] To revert: in this commit, I added a pretty elaborate system of automatically signing out accounts when oauth scopes change. The system is pretty fragile, so I'm going to take the safer approach and just add document this as a known issue. --- packages/backend/src/gitlab.ts | 1 - .../src/app/components/authMethodSelector.tsx | 2 - packages/web/src/auth.ts | 59 +++++++++++++++++-- .../components/linkButton.tsx | 2 +- packages/web/src/ee/features/sso/sso.ts | 33 ++++------- packages/web/src/lib/oauthScopes.ts | 48 +++++++++++++++ 6 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 packages/web/src/lib/oauthScopes.ts diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index e1dac00b7..5d07985e7 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -324,7 +324,6 @@ export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'i 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' && diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index a9eae01ad..7006a4bae 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -29,7 +29,6 @@ export const AuthMethodSelector = ({ // Call the optional analytics callback first onProviderClick?.(provider); - // @nocheckin signIn( provider, { @@ -40,7 +39,6 @@ export const AuthMethodSelector = ({ // @see: https://next-auth.js.org/getting-started/client#additional-parameters { prompt: 'consent', - scope: 'read:user user:email repo' } ); }, [callbackUrl, onProviderClick]); diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index d965f73f3..cd587a681 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -19,6 +19,7 @@ import { onCreateUser } from '@/lib/authUtils'; import { getAuditService } from '@/ee/features/audit/factory'; import { SINGLE_TENANT_ORG_ID } from './lib/constants'; import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh'; +import { getRequiredScopes, normalizeScopes } from './lib/oauthScopes'; const auditService = getAuditService(); const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : []; @@ -52,6 +53,8 @@ declare module 'next-auth' { declare module 'next-auth/jwt' { interface JWT { userId: string; + providerAccountId: string; + provider: string; linkedAccountTokens?: LinkedAccountTokensMap; } } @@ -164,7 +167,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // 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) { + if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) { await prisma.account.update({ where: { provider_providerAccountId: { @@ -217,14 +220,60 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } }, callbacks: { - async jwt({ token, user: _user, account }) { - const user = _user as User | undefined; + async jwt({ token, user: _user, account: _account }) { // @note: `user` will be available on signUp or signIn triggers. // Cache the userId in the JWT for later use. - if (user) { + if (_user) { + const user = _user as User; token.userId = user.id; } + if (_account) { + token.providerAccountId = _account.providerAccountId; + token.provider = _account.provider; + } + + const account = await prisma.account.findUnique({ + where: { + provider_providerAccountId: { + provider: token.provider, + providerAccountId: token.providerAccountId, + }, + }, + }); + + if (!account) { + return null; + } + + const currentScopes = normalizeScopes(account.scope); + const requiredScopes = getRequiredScopes(account.provider); + + if (currentScopes !== requiredScopes) { + const doesAccountExist = await prisma.account.findUnique({ + where: { + provider_providerAccountId: { + provider: account.provider, + providerAccountId: account.providerAccountId, + }, + }, + }); + + if (doesAccountExist) { + await prisma.account.delete({ + where: { + provider_providerAccountId: { + provider: account.provider, + providerAccountId: account.providerAccountId, + }, + }, + }); + console.log(`Deleted account ${account.providerAccountId} for provider ${account.provider} because it did not have the required scopes.`); + } + + return null; + } + if (hasEntitlement('permission-syncing')) { if (account && account.access_token && account.refresh_token && account.expires_at) { token.linkedAccountTokens = token.linkedAccountTokens || {}; @@ -273,4 +322,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // We set redirect to false in signInOptions so we can pass the email is as a param // verifyRequest: "/login/verify", } -}); +}); \ No newline at end of file diff --git a/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx b/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx index 757ec715e..6aed2014d 100644 --- a/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx +++ b/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx @@ -12,7 +12,7 @@ interface LinkButtonProps { export const LinkButton = ({ provider, callbackUrl }: LinkButtonProps) => { const handleLink = () => { signIn(provider, { - redirectTo: callbackUrl + redirectTo: callbackUrl, }); }; diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts index c43b32621..c10b92a4a 100644 --- a/packages/web/src/ee/features/sso/sso.ts +++ b/packages/web/src/ee/features/sso/sso.ts @@ -1,8 +1,9 @@ import type { IdentityProvider } from "@/auth"; import { onCreateUser } from "@/lib/authUtils"; +import { getRequiredScopes } from "@/lib/oauthScopes"; import { prisma } from "@/prisma"; import { AuthentikIdentityProviderConfig, GCPIAPIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig, GoogleIdentityProviderConfig, KeycloakIdentityProviderConfig, MicrosoftEntraIDIdentityProviderConfig, OktaIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; -import { createLogger, env, getTokenFromConfig, hasEntitlement, loadConfig } from "@sourcebot/shared"; +import { createLogger, env, getTokenFromConfig, loadConfig } from "@sourcebot/shared"; import { OAuth2Client } from "google-auth-library"; import type { User as AuthJsUser } from "next-auth"; import type { Provider } from "next-auth/providers"; @@ -126,19 +127,10 @@ const createGitHubProvider = (clientId: string, clientSecret: string, baseUrl?: ...(hostname === GITHUB_CLOUD_HOSTNAME ? { enterprise: { baseUrl: baseUrl } } : {}), // if this is set the provider expects GHE so we need this check authorization: { params: { - scope: [ - 'read:user', - 'user:email', - // Permission syncing requires the `repo` scope in order to fetch repositories - // for the authenticated user. - // @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user - ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ? - ['repo'] : - [] - ), - ].join(' '), + scope: getRequiredScopes('github'), }, }, + allowDangerousEmailAccountLinking: true, }); } @@ -150,16 +142,7 @@ const createGitLabProvider = (clientId: string, clientSecret: string, baseUrl?: authorization: { url: `${url}/oauth/authorize`, params: { - scope: [ - "read_user", - // Permission syncing requires the `read_api` scope in order to fetch projects - // for the authenticated user and project members. - // @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects - ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ? - ['read_api'] : - [] - ), - ].join(' '), + scope: getRequiredScopes('gitlab'), }, }, token: { @@ -168,6 +151,7 @@ const createGitLabProvider = (clientId: string, clientSecret: string, baseUrl?: userinfo: { url: `${url}/api/v4/user`, }, + allowDangerousEmailAccountLinking: true, }); } @@ -175,6 +159,7 @@ const createGoogleProvider = (clientId: string, clientSecret: string): Provider return Google({ clientId: clientId, clientSecret: clientSecret, + allowDangerousEmailAccountLinking: true, }); } @@ -183,6 +168,7 @@ const createOktaProvider = (clientId: string, clientSecret: string, issuer: stri clientId: clientId, clientSecret: clientSecret, issuer: issuer, + allowDangerousEmailAccountLinking: true, }); } @@ -191,6 +177,7 @@ const createKeycloakProvider = (clientId: string, clientSecret: string, issuer: clientId: clientId, clientSecret: clientSecret, issuer: issuer, + allowDangerousEmailAccountLinking: true, }); } @@ -199,6 +186,7 @@ const createMicrosoftEntraIDProvider = (clientId: string, clientSecret: string, clientId: clientId, clientSecret: clientSecret, issuer: issuer, + allowDangerousEmailAccountLinking: true, }); } @@ -283,5 +271,6 @@ export const createAuthentikProvider = (clientId: string, clientSecret: string, clientId: clientId, clientSecret: clientSecret, issuer: issuer, + allowDangerousEmailAccountLinking: true, }); } \ No newline at end of file diff --git a/packages/web/src/lib/oauthScopes.ts b/packages/web/src/lib/oauthScopes.ts new file mode 100644 index 000000000..306ed6ecf --- /dev/null +++ b/packages/web/src/lib/oauthScopes.ts @@ -0,0 +1,48 @@ +import { env, hasEntitlement } from "@sourcebot/shared"; + +/** + * Normalize scope strings for consistent comparison. + * Handles different delimiters and ordering. + */ +export function normalizeScopes(scopeString: string | null | undefined): string { + if (!scopeString) return ''; + return scopeString.split(/[\s,]+/).filter(Boolean).sort().join(' '); +} + +/** + * Calculate the required OAuth scopes for a given provider based on current configuration. + * Returns a normalized, sorted scope string. + */ +export function getRequiredScopes(provider: string): string { + const scopes: string[] = []; + + switch (provider) { + case 'github': + scopes.push('read:user', 'user:email'); + // Permission syncing requires the `repo` scope in order to fetch repositories + // for the authenticated user. + // @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user + if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { + scopes.push('repo'); + } + + break; + + case 'gitlab': + scopes.push('read_user'); + // Permission syncing requires the `read_api` scope in order to fetch projects + // for the authenticated user and project members. + // @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects + if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { + scopes.push('read_api'); + } + break; + + default: + // Other providers (Google, Okta, etc.) don't have dynamic scope requirements + return ''; + } + + return normalizeScopes(scopes.join(' ')); +} + From e085ddc91bddbfad00dc7b7399fca4372892ec75 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 13 Jan 2026 21:00:05 -0800 Subject: [PATCH 4/7] Revert "To revert: in this commit, I added a pretty elaborate system of automatically signing out accounts when oauth scopes change. The system is pretty fragile, so I'm going to take the safer approach and just add document this as a known issue." This reverts commit 84b1cf436b689ba2b5d717e68ef764da5cc1b1a6. --- packages/backend/src/gitlab.ts | 1 + .../src/app/components/authMethodSelector.tsx | 2 + packages/web/src/auth.ts | 59 ++----------------- .../components/linkButton.tsx | 2 +- packages/web/src/ee/features/sso/sso.ts | 33 +++++++---- packages/web/src/lib/oauthScopes.ts | 48 --------------- 6 files changed, 31 insertions(+), 114 deletions(-) delete mode 100644 packages/web/src/lib/oauthScopes.ts diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index 5d07985e7..e1dac00b7 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -324,6 +324,7 @@ export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'i 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' && diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index 7006a4bae..a9eae01ad 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -29,6 +29,7 @@ export const AuthMethodSelector = ({ // Call the optional analytics callback first onProviderClick?.(provider); + // @nocheckin signIn( provider, { @@ -39,6 +40,7 @@ export const AuthMethodSelector = ({ // @see: https://next-auth.js.org/getting-started/client#additional-parameters { prompt: 'consent', + scope: 'read:user user:email repo' } ); }, [callbackUrl, onProviderClick]); diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index cd587a681..d965f73f3 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -19,7 +19,6 @@ import { onCreateUser } from '@/lib/authUtils'; import { getAuditService } from '@/ee/features/audit/factory'; import { SINGLE_TENANT_ORG_ID } from './lib/constants'; import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh'; -import { getRequiredScopes, normalizeScopes } from './lib/oauthScopes'; const auditService = getAuditService(); const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : []; @@ -53,8 +52,6 @@ declare module 'next-auth' { declare module 'next-auth/jwt' { interface JWT { userId: string; - providerAccountId: string; - provider: string; linkedAccountTokens?: LinkedAccountTokensMap; } } @@ -167,7 +164,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // 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.provider !== 'credentials' && account.providerAccountId) { + if (account && account.provider && account.providerAccountId) { await prisma.account.update({ where: { provider_providerAccountId: { @@ -220,60 +217,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } }, callbacks: { - async jwt({ token, user: _user, account: _account }) { + async jwt({ token, user: _user, account }) { + const user = _user as User | undefined; // @note: `user` will be available on signUp or signIn triggers. // Cache the userId in the JWT for later use. - if (_user) { - const user = _user as User; + if (user) { token.userId = user.id; } - if (_account) { - token.providerAccountId = _account.providerAccountId; - token.provider = _account.provider; - } - - const account = await prisma.account.findUnique({ - where: { - provider_providerAccountId: { - provider: token.provider, - providerAccountId: token.providerAccountId, - }, - }, - }); - - if (!account) { - return null; - } - - const currentScopes = normalizeScopes(account.scope); - const requiredScopes = getRequiredScopes(account.provider); - - if (currentScopes !== requiredScopes) { - const doesAccountExist = await prisma.account.findUnique({ - where: { - provider_providerAccountId: { - provider: account.provider, - providerAccountId: account.providerAccountId, - }, - }, - }); - - if (doesAccountExist) { - await prisma.account.delete({ - where: { - provider_providerAccountId: { - provider: account.provider, - providerAccountId: account.providerAccountId, - }, - }, - }); - console.log(`Deleted account ${account.providerAccountId} for provider ${account.provider} because it did not have the required scopes.`); - } - - return null; - } - if (hasEntitlement('permission-syncing')) { if (account && account.access_token && account.refresh_token && account.expires_at) { token.linkedAccountTokens = token.linkedAccountTokens || {}; @@ -322,4 +273,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // We set redirect to false in signInOptions so we can pass the email is as a param // verifyRequest: "/login/verify", } -}); \ No newline at end of file +}); diff --git a/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx b/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx index 6aed2014d..757ec715e 100644 --- a/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx +++ b/packages/web/src/ee/features/permissionSyncing/components/linkButton.tsx @@ -12,7 +12,7 @@ interface LinkButtonProps { export const LinkButton = ({ provider, callbackUrl }: LinkButtonProps) => { const handleLink = () => { signIn(provider, { - redirectTo: callbackUrl, + redirectTo: callbackUrl }); }; diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts index c10b92a4a..c43b32621 100644 --- a/packages/web/src/ee/features/sso/sso.ts +++ b/packages/web/src/ee/features/sso/sso.ts @@ -1,9 +1,8 @@ import type { IdentityProvider } from "@/auth"; import { onCreateUser } from "@/lib/authUtils"; -import { getRequiredScopes } from "@/lib/oauthScopes"; import { prisma } from "@/prisma"; import { AuthentikIdentityProviderConfig, GCPIAPIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig, GoogleIdentityProviderConfig, KeycloakIdentityProviderConfig, MicrosoftEntraIDIdentityProviderConfig, OktaIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; -import { createLogger, env, getTokenFromConfig, loadConfig } from "@sourcebot/shared"; +import { createLogger, env, getTokenFromConfig, hasEntitlement, loadConfig } from "@sourcebot/shared"; import { OAuth2Client } from "google-auth-library"; import type { User as AuthJsUser } from "next-auth"; import type { Provider } from "next-auth/providers"; @@ -127,10 +126,19 @@ const createGitHubProvider = (clientId: string, clientSecret: string, baseUrl?: ...(hostname === GITHUB_CLOUD_HOSTNAME ? { enterprise: { baseUrl: baseUrl } } : {}), // if this is set the provider expects GHE so we need this check authorization: { params: { - scope: getRequiredScopes('github'), + scope: [ + 'read:user', + 'user:email', + // Permission syncing requires the `repo` scope in order to fetch repositories + // for the authenticated user. + // @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user + ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ? + ['repo'] : + [] + ), + ].join(' '), }, }, - allowDangerousEmailAccountLinking: true, }); } @@ -142,7 +150,16 @@ const createGitLabProvider = (clientId: string, clientSecret: string, baseUrl?: authorization: { url: `${url}/oauth/authorize`, params: { - scope: getRequiredScopes('gitlab'), + scope: [ + "read_user", + // Permission syncing requires the `read_api` scope in order to fetch projects + // for the authenticated user and project members. + // @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects + ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ? + ['read_api'] : + [] + ), + ].join(' '), }, }, token: { @@ -151,7 +168,6 @@ const createGitLabProvider = (clientId: string, clientSecret: string, baseUrl?: userinfo: { url: `${url}/api/v4/user`, }, - allowDangerousEmailAccountLinking: true, }); } @@ -159,7 +175,6 @@ const createGoogleProvider = (clientId: string, clientSecret: string): Provider return Google({ clientId: clientId, clientSecret: clientSecret, - allowDangerousEmailAccountLinking: true, }); } @@ -168,7 +183,6 @@ const createOktaProvider = (clientId: string, clientSecret: string, issuer: stri clientId: clientId, clientSecret: clientSecret, issuer: issuer, - allowDangerousEmailAccountLinking: true, }); } @@ -177,7 +191,6 @@ const createKeycloakProvider = (clientId: string, clientSecret: string, issuer: clientId: clientId, clientSecret: clientSecret, issuer: issuer, - allowDangerousEmailAccountLinking: true, }); } @@ -186,7 +199,6 @@ const createMicrosoftEntraIDProvider = (clientId: string, clientSecret: string, clientId: clientId, clientSecret: clientSecret, issuer: issuer, - allowDangerousEmailAccountLinking: true, }); } @@ -271,6 +283,5 @@ export const createAuthentikProvider = (clientId: string, clientSecret: string, clientId: clientId, clientSecret: clientSecret, issuer: issuer, - allowDangerousEmailAccountLinking: true, }); } \ No newline at end of file diff --git a/packages/web/src/lib/oauthScopes.ts b/packages/web/src/lib/oauthScopes.ts deleted file mode 100644 index 306ed6ecf..000000000 --- a/packages/web/src/lib/oauthScopes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { env, hasEntitlement } from "@sourcebot/shared"; - -/** - * Normalize scope strings for consistent comparison. - * Handles different delimiters and ordering. - */ -export function normalizeScopes(scopeString: string | null | undefined): string { - if (!scopeString) return ''; - return scopeString.split(/[\s,]+/).filter(Boolean).sort().join(' '); -} - -/** - * Calculate the required OAuth scopes for a given provider based on current configuration. - * Returns a normalized, sorted scope string. - */ -export function getRequiredScopes(provider: string): string { - const scopes: string[] = []; - - switch (provider) { - case 'github': - scopes.push('read:user', 'user:email'); - // Permission syncing requires the `repo` scope in order to fetch repositories - // for the authenticated user. - // @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user - if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { - scopes.push('repo'); - } - - break; - - case 'gitlab': - scopes.push('read_user'); - // Permission syncing requires the `read_api` scope in order to fetch projects - // for the authenticated user and project members. - // @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects - if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { - scopes.push('read_api'); - } - break; - - default: - // Other providers (Google, Okta, etc.) don't have dynamic scope requirements - return ''; - } - - return normalizeScopes(scopes.join(' ')); -} - From 9623554594ad5b664adb48a61afebacc06fac530 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 13 Jan 2026 21:17:53 -0800 Subject: [PATCH 5/7] document things --- docs/docs/features/permission-syncing.mdx | 4 ++++ packages/backend/src/ee/accountPermissionSyncer.ts | 4 ++-- packages/backend/src/gitlab.ts | 1 - packages/web/src/app/components/authMethodSelector.tsx | 8 -------- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index fb7d88afd..ac3250bbe 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -27,6 +27,10 @@ docker run \ ghcr.io/sourcebot-dev/sourcebot:latest ``` + +Enabling permission syncing on an **existing** deployment may result in errors that look like **"User does not have an OAuth access token..."**. This is because the OAuth access token associated with the user's existing account does not have the correct scopes necessary for permission syncing. To fix, have the user re-authenticate to refresh their access token by either logging out of Sourcebot and logging in again or unlinking and re-linking their account. + + ## Platform support We are actively working on supporting more code hosts. If you'd like to see a specific code host supported, please [reach out](https://www.sourcebot.dev/contact). diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index ff267873a..60e27a178 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -166,7 +166,7 @@ export class AccountPermissionSyncer { if (account.provider === 'github') { if (!account.access_token) { - throw new Error(`User '${account.user.email}' does not have an GitHub OAuth access token associated with their GitHub account.`); + throw new Error(`User '${account.user.email}' does not have an GitHub OAuth access token associated with their GitHub account. Please re-authenticate with GitHub to refresh the token.`); } // @hack: we don't have a way of identifying specific identity providers in the config file. @@ -202,7 +202,7 @@ export class AccountPermissionSyncer { repos.forEach(repo => aggregatedRepoIds.add(repo.id)); } else if (account.provider === 'gitlab') { if (!account.access_token) { - throw new Error(`User '${account.user.email}' does not have a GitLab OAuth access token associated with their GitLab account.`); + throw new Error(`User '${account.user.email}' does not have a GitLab OAuth access token associated with their GitLab account. Please re-authenticate with GitLab to refresh the token.`); } // @hack: we don't have a way of identifying specific identity providers in the config file. diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index e1dac00b7..5d07985e7 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -324,7 +324,6 @@ export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'i 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' && diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx index a9eae01ad..540d8b0a2 100644 --- a/packages/web/src/app/components/authMethodSelector.tsx +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -29,18 +29,10 @@ export const AuthMethodSelector = ({ // Call the optional analytics callback first onProviderClick?.(provider); - // @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]); From c9e16becad7b3eabc2ec38bae689aa21a53ae70c Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 13 Jan 2026 21:27:33 -0800 Subject: [PATCH 6/7] handle credentials case --- packages/web/src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index d965f73f3..b1f9c720b 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -164,7 +164,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // 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) { + if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) { await prisma.account.update({ where: { provider_providerAccountId: { From 13c4c9cb2a380f4fd602c17fbef5488328305c71 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 13 Jan 2026 21:28:28 -0800 Subject: [PATCH 7/7] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc85176c..fac898575 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 ### Changed - Remove references to demo from docs. [#734](https://github.com/sourcebot-dev/sourcebot/pull/734) - Add docs for GitHub App connection auth. [#735](https://github.com/sourcebot-dev/sourcebot/pull/735) +- Improved error messaging around oauth scope errors with user driven permission syncing. [#639](https://github.com/sourcebot-dev/sourcebot/pull/639) ### Fixed - Fixed issue where 403 errors were being raised during a user driven permission sync against a self-hosted code host. [#729](https://github.com/sourcebot-dev/sourcebot/pull/729)