Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/features/permission-syncing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ docker run \
ghcr.io/sourcebot-dev/sourcebot:latest
```

<Warning>
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.
</Warning>

## 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).
Expand Down
27 changes: 23 additions & 4 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -158,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.
Expand All @@ -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
Expand All @@ -188,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.
Expand All @@ -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.`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitLab scope check rejects valid api scope tokens

Medium Severity

The GitLab scope validation only checks for the exact read_api scope, but GitLab's api scope is a superset that includes all read_api permissions. Users who configured their OAuth application with api scope (for other integrations or by preference) would be incorrectly rejected with a confusing error stating they're missing read_api, even though their token has sufficient permissions to list projects.

Fix in Cursor Fix in Web


// @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
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 27 additions & 3 deletions packages/backend/src/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -207,7 +207,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =

return !isExcluded;
});

logger.debug(`Found ${repos.length} total repositories.`);

return {
Expand Down Expand Up @@ -316,4 +316,28 @@ 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<typeof Gitlab>) => {
try {
const response = await api.requester.get('/oauth/token/info');
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;
}
}
9 changes: 6 additions & 3 deletions packages/web/src/app/components/authMethodSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ export const AuthMethodSelector = ({
// Call the optional analytics callback first
onProviderClick?.(provider);

signIn(provider, {
redirectTo: callbackUrl ?? "/"
});
signIn(
provider,
{
redirectTo: callbackUrl ?? "/",
}
);
}, [callbackUrl, onProviderClick]);

// Separate OAuth providers from special auth methods
Expand Down
166 changes: 96 additions & 70 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.provider !== 'credentials' && 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",
Expand Down Expand Up @@ -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<string, string> = {};
Expand Down