Skip to content

Commit 871ae78

Browse files
fix(web): Document 403 errors with user driven permission syncing when scope changes occur (#639)
1 parent 62bf869 commit 871ae78

File tree

7 files changed

+171
-80
lines changed

7 files changed

+171
-80
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Changed
1111
- Remove references to demo from docs. [#734](https://github.com/sourcebot-dev/sourcebot/pull/734)
1212
- Add docs for GitHub App connection auth. [#735](https://github.com/sourcebot-dev/sourcebot/pull/735)
13+
- Improved error messaging around oauth scope errors with user driven permission syncing. [#639](https://github.com/sourcebot-dev/sourcebot/pull/639)
1314

1415
### Fixed
1516
- 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)

docs/docs/features/permission-syncing.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ docker run \
2727
ghcr.io/sourcebot-dev/sourcebot:latest
2828
```
2929

30+
<Warning>
31+
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.
32+
</Warning>
33+
3034
## Platform support
3135

3236
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).

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ import { env, hasEntitlement, createLogger, loadConfig } from "@sourcebot/shared
44
import { Job, Queue, Worker } from "bullmq";
55
import { Redis } from "ioredis";
66
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
7-
import { createOctokitFromToken, getReposForAuthenticatedUser } from "../github.js";
8-
import { createGitLabFromOAuthToken, getProjectsForAuthenticatedUser } from "../gitlab.js";
7+
import {
8+
createOctokitFromToken,
9+
getOAuthScopesForAuthenticatedUser as getGitHubOAuthScopesForAuthenticatedUser,
10+
getReposForAuthenticatedUser,
11+
} from "../github.js";
12+
import {
13+
createGitLabFromOAuthToken,
14+
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
15+
getProjectsForAuthenticatedUser,
16+
} from "../gitlab.js";
917
import { Settings } from "../types.js";
1018
import { setIntervalAsync } from "../utils.js";
1119

@@ -158,7 +166,7 @@ export class AccountPermissionSyncer {
158166

159167
if (account.provider === 'github') {
160168
if (!account.access_token) {
161-
throw new Error(`User '${account.user.email}' does not have an GitHub OAuth access token associated with their GitHub account.`);
169+
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.`);
162170
}
163171

164172
// @hack: we don't have a way of identifying specific identity providers in the config file.
@@ -170,6 +178,12 @@ export class AccountPermissionSyncer {
170178
token: account.access_token,
171179
url: baseUrl,
172180
});
181+
182+
const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit);
183+
if (!scopes.includes('repo')) {
184+
throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing.`);
185+
}
186+
173187
// @note: we only care about the private repos since we don't need to build a mapping
174188
// for public repos.
175189
// @see: packages/web/src/prisma.ts
@@ -188,7 +202,7 @@ export class AccountPermissionSyncer {
188202
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
189203
} else if (account.provider === 'gitlab') {
190204
if (!account.access_token) {
191-
throw new Error(`User '${account.user.email}' does not have a GitLab OAuth access token associated with their GitLab account.`);
205+
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.`);
192206
}
193207

194208
// @hack: we don't have a way of identifying specific identity providers in the config file.
@@ -201,6 +215,11 @@ export class AccountPermissionSyncer {
201215
url: baseUrl,
202216
});
203217

218+
const scopes = await getGitLabOAuthScopesForAuthenticatedUser(api);
219+
if (!scopes.includes('read_api')) {
220+
throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'read_api' scope required for permission syncing.`);
221+
}
222+
204223
// @note: we only care about the private and internal repos since we don't need to build a mapping
205224
// for public repos.
206225
// @see: packages/web/src/prisma.ts

packages/backend/src/github.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,20 @@ export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private'
197197
}
198198
}
199199

200+
// Gets oauth scopes
201+
// @see: https://github.com/octokit/auth-token.js/?tab=readme-ov-file#find-out-what-scopes-are-enabled-for-oauth-tokens
202+
export const getOAuthScopesForAuthenticatedUser = async (octokit: Octokit) => {
203+
try {
204+
const response = await octokit.request("HEAD /");
205+
const scopes = response.headers["x-oauth-scopes"]?.split(/,\s+/) || [];
206+
return scopes;
207+
} catch (error) {
208+
Sentry.captureException(error);
209+
logger.error(`Failed to fetch OAuth scopes for authenticated user.`, error);
210+
throw error;
211+
}
212+
}
213+
200214
const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
201215
const results = await Promise.allSettled(users.map((user) => githubQueryLimit(async () => {
202216
try {

packages/backend/src/gitlab.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
4141
const token = config.token ?
4242
await getTokenFromConfig(config.token) :
4343
hostname === GITLAB_CLOUD_HOSTNAME ?
44-
env.FALLBACK_GITLAB_CLOUD_TOKEN :
45-
undefined;
44+
env.FALLBACK_GITLAB_CLOUD_TOKEN :
45+
undefined;
4646

4747
const api = await createGitLabFromPersonalAccessToken({
4848
token,
@@ -207,7 +207,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
207207

208208
return !isExcluded;
209209
});
210-
210+
211211
logger.debug(`Found ${repos.length} total repositories.`);
212212

213213
return {
@@ -316,4 +316,28 @@ export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'i
316316
logger.error(`Failed to fetch projects for authenticated user.`, error);
317317
throw error;
318318
}
319+
}
320+
321+
// Fetches OAuth scopes for the authenticated user.
322+
// @see: https://github.com/doorkeeper-gem/doorkeeper/wiki/API-endpoint-descriptions-and-examples#get----oauthtokeninfo
323+
// @see: https://docs.gitlab.com/api/oauth2/#retrieve-the-token-information
324+
export const getOAuthScopesForAuthenticatedUser = async (api: InstanceType<typeof Gitlab>) => {
325+
try {
326+
const response = await api.requester.get('/oauth/token/info');
327+
if (
328+
response &&
329+
typeof response.body === 'object' &&
330+
response.body !== null &&
331+
'scope' in response.body &&
332+
Array.isArray(response.body.scope)
333+
) {
334+
return response.body.scope;
335+
}
336+
337+
throw new Error('/oauth/token_info response body is not in the expected format.');
338+
} catch (error) {
339+
Sentry.captureException(error);
340+
logger.error('Failed to fetch OAuth scopes for authenticated user.', error);
341+
throw error;
342+
}
319343
}

packages/web/src/app/components/authMethodSelector.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ export const AuthMethodSelector = ({
2929
// Call the optional analytics callback first
3030
onProviderClick?.(provider);
3131

32-
signIn(provider, {
33-
redirectTo: callbackUrl ?? "/"
34-
});
32+
signIn(
33+
provider,
34+
{
35+
redirectTo: callbackUrl ?? "/",
36+
}
37+
);
3538
}, [callbackUrl, onProviderClick]);
3639

3740
// Separate OAuth providers from special auth methods

packages/web/src/auth.ts

Lines changed: 96 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -60,88 +60,92 @@ export const getProviders = () => {
6060
const providers: IdentityProvider[] = eeIdentityProviders;
6161

6262
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') {
63-
providers.push({ provider: EmailProvider({
64-
server: env.SMTP_CONNECTION_URL,
65-
from: env.EMAIL_FROM_ADDRESS,
66-
maxAge: 60 * 10,
67-
generateVerificationToken: async () => {
68-
const token = String(Math.floor(100000 + Math.random() * 900000));
69-
return token;
70-
},
71-
sendVerificationRequest: async ({ identifier, provider, token }) => {
72-
const transport = createTransport(provider.server);
73-
const html = await render(MagicLinkEmail({ token: token }));
74-
const result = await transport.sendMail({
75-
to: identifier,
76-
from: provider.from,
77-
subject: 'Log in to Sourcebot',
78-
html,
79-
text: `Log in to Sourcebot using this code: ${token}`
80-
});
63+
providers.push({
64+
provider: EmailProvider({
65+
server: env.SMTP_CONNECTION_URL,
66+
from: env.EMAIL_FROM_ADDRESS,
67+
maxAge: 60 * 10,
68+
generateVerificationToken: async () => {
69+
const token = String(Math.floor(100000 + Math.random() * 900000));
70+
return token;
71+
},
72+
sendVerificationRequest: async ({ identifier, provider, token }) => {
73+
const transport = createTransport(provider.server);
74+
const html = await render(MagicLinkEmail({ token: token }));
75+
const result = await transport.sendMail({
76+
to: identifier,
77+
from: provider.from,
78+
subject: 'Log in to Sourcebot',
79+
html,
80+
text: `Log in to Sourcebot using this code: ${token}`
81+
});
8182

82-
const failed = result.rejected.concat(result.pending).filter(Boolean);
83-
if (failed.length) {
84-
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
83+
const failed = result.rejected.concat(result.pending).filter(Boolean);
84+
if (failed.length) {
85+
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
86+
}
8587
}
86-
}
87-
}), purpose: "sso"});
88+
}), purpose: "sso"
89+
});
8890
}
8991

9092
if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') {
91-
providers.push({ provider: Credentials({
92-
credentials: {
93-
email: {},
94-
password: {}
95-
},
96-
type: "credentials",
97-
authorize: async (credentials) => {
98-
const body = verifyCredentialsRequestSchema.safeParse(credentials);
99-
if (!body.success) {
100-
return null;
101-
}
102-
const { email, password } = body.data;
93+
providers.push({
94+
provider: Credentials({
95+
credentials: {
96+
email: {},
97+
password: {}
98+
},
99+
type: "credentials",
100+
authorize: async (credentials) => {
101+
const body = verifyCredentialsRequestSchema.safeParse(credentials);
102+
if (!body.success) {
103+
return null;
104+
}
105+
const { email, password } = body.data;
103106

104-
const user = await prisma.user.findUnique({
105-
where: { email }
106-
});
107+
const user = await prisma.user.findUnique({
108+
where: { email }
109+
});
110+
111+
// The user doesn't exist, so create a new one.
112+
if (!user) {
113+
const hashedPassword = bcrypt.hashSync(password, 10);
114+
const newUser = await prisma.user.create({
115+
data: {
116+
email,
117+
hashedPassword,
118+
}
119+
});
107120

108-
// The user doesn't exist, so create a new one.
109-
if (!user) {
110-
const hashedPassword = bcrypt.hashSync(password, 10);
111-
const newUser = await prisma.user.create({
112-
data: {
113-
email,
114-
hashedPassword,
121+
const authJsUser: AuthJsUser = {
122+
id: newUser.id,
123+
email: newUser.email,
115124
}
116-
});
117125

118-
const authJsUser: AuthJsUser = {
119-
id: newUser.id,
120-
email: newUser.email,
121-
}
126+
onCreateUser({ user: authJsUser });
127+
return authJsUser;
122128

123-
onCreateUser({ user: authJsUser });
124-
return authJsUser;
129+
// Otherwise, the user exists, so verify the password.
130+
} else {
131+
if (!user.hashedPassword) {
132+
return null;
133+
}
125134

126-
// Otherwise, the user exists, so verify the password.
127-
} else {
128-
if (!user.hashedPassword) {
129-
return null;
130-
}
135+
if (!bcrypt.compareSync(password, user.hashedPassword)) {
136+
return null;
137+
}
131138

132-
if (!bcrypt.compareSync(password, user.hashedPassword)) {
133-
return null;
139+
return {
140+
id: user.id,
141+
email: user.email,
142+
name: user.name ?? undefined,
143+
image: user.image ?? undefined,
144+
};
134145
}
135-
136-
return {
137-
id: user.id,
138-
email: user.email,
139-
name: user.name ?? undefined,
140-
image: user.image ?? undefined,
141-
};
142146
}
143-
}
144-
}), purpose: "sso"});
147+
}), purpose: "sso"
148+
});
145149
}
146150

147151
return providers;
@@ -156,7 +160,29 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
156160
trustHost: true,
157161
events: {
158162
createUser: onCreateUser,
159-
signIn: async ({ user }) => {
163+
signIn: async ({ user, account }) => {
164+
// Explicitly update the Account record with the OAuth token details.
165+
// This is necessary to update the access token when the user
166+
// re-authenticates.
167+
if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) {
168+
await prisma.account.update({
169+
where: {
170+
provider_providerAccountId: {
171+
provider: account.provider,
172+
providerAccountId: account.providerAccountId,
173+
},
174+
},
175+
data: {
176+
refresh_token: account.refresh_token,
177+
access_token: account.access_token,
178+
expires_at: account.expires_at,
179+
token_type: account.token_type,
180+
scope: account.scope,
181+
id_token: account.id_token,
182+
}
183+
})
184+
}
185+
160186
if (user.id) {
161187
await auditService.createAudit({
162188
action: "user.signed_in",
@@ -225,7 +251,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
225251
// Propagate the userId to the session.
226252
id: token.userId,
227253
}
228-
254+
229255
// Pass only linked account provider errors to the session (not sensitive tokens)
230256
if (token.linkedAccountTokens) {
231257
const errors: Record<string, string> = {};

0 commit comments

Comments
 (0)