Compare commits

...

4 commits

Author SHA1 Message Date
Brendan Kellam
dfbf66fed2
Merge d022066529 into d63f3cf9d9 2025-12-08 11:14:44 +03:00
Brendan Kellam
d63f3cf9d9
chore(web): Improve error messages for file loading errors (#665)
Some checks failed
Publish to ghcr / build (linux/amd64, blacksmith-4vcpu-ubuntu-2404) (push) Has been cancelled
Publish to ghcr / build (linux/arm64, blacksmith-8vcpu-ubuntu-2204-arm) (push) Has been cancelled
Update Roadmap Released / update (push) Has been cancelled
Publish to ghcr / merge (push) Has been cancelled
2025-12-05 11:58:19 -08:00
bkellam
d022066529 wip on updating access_token 2025-12-01 20:18:40 -08:00
bkellam
cbc2cfc190 Add explicit error message when OAuth scopes are incorrect 2025-12-01 20:18:40 -08:00
8 changed files with 189 additions and 91 deletions

View file

@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for arbitrary user IDs required for OpenShift. [#658](https://github.com/sourcebot-dev/sourcebot/pull/658) - Added support for arbitrary user IDs required for OpenShift. [#658](https://github.com/sourcebot-dev/sourcebot/pull/658)
### Updated
- Improved error messages in file source api. [#665](https://github.com/sourcebot-dev/sourcebot/pull/665)
## [4.10.2] - 2025-12-04 ## [4.10.2] - 2025-12-04
### Fixed ### Fixed

View file

@ -1,11 +1,19 @@
import * as Sentry from "@sentry/node"; 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 { env, hasEntitlement, createLogger } from "@sourcebot/shared";
import { Job, Queue, Worker } from "bullmq"; import { Job, Queue, Worker } from "bullmq";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js"; import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
import { createOctokitFromToken, getReposForAuthenticatedUser } from "../github.js"; import {
import { createGitLabFromOAuthToken, getProjectsForAuthenticatedUser } from "../gitlab.js"; createOctokitFromToken,
getOAuthScopesForAuthenticatedUser as getGitHubOAuthScopesForAuthenticatedUser,
getReposForAuthenticatedUser,
} from "../github.js";
import {
createGitLabFromOAuthToken,
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
getProjectsForAuthenticatedUser,
} from "../gitlab.js";
import { Settings } from "../types.js"; import { Settings } from "../types.js";
import { setIntervalAsync } from "../utils.js"; import { setIntervalAsync } from "../utils.js";
@ -163,6 +171,12 @@ export class AccountPermissionSyncer {
token: account.access_token, token: account.access_token,
url: env.AUTH_EE_GITHUB_BASE_URL, 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 // @note: we only care about the private repos since we don't need to build a mapping
// for public repos. // for public repos.
// @see: packages/web/src/prisma.ts // @see: packages/web/src/prisma.ts
@ -189,6 +203,11 @@ export class AccountPermissionSyncer {
url: env.AUTH_EE_GITLAB_BASE_URL, 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 // @note: we only care about the private and internal repos since we don't need to build a mapping
// for public repos. // for public repos.
// @see: packages/web/src/prisma.ts // @see: packages/web/src/prisma.ts

View file

@ -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 getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
const results = await Promise.allSettled(users.map((user) => githubQueryLimit(async () => { const results = await Promise.allSettled(users.map((user) => githubQueryLimit(async () => {
try { try {

View file

@ -41,8 +41,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
const token = config.token ? const token = config.token ?
await getTokenFromConfig(config.token) : await getTokenFromConfig(config.token) :
hostname === GITLAB_CLOUD_HOSTNAME ? hostname === GITLAB_CLOUD_HOSTNAME ?
env.FALLBACK_GITLAB_CLOUD_TOKEN : env.FALLBACK_GITLAB_CLOUD_TOKEN :
undefined; undefined;
const api = await createGitLabFromPersonalAccessToken({ const api = await createGitLabFromPersonalAccessToken({
token, token,
@ -312,3 +312,28 @@ export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'i
throw 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');
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;
}
}

View file

@ -38,8 +38,8 @@ const auditService = getAuditService();
/** /**
* "Service Error Wrapper". * "Service Error Wrapper".
* *
* Captures any thrown exceptions and converts them to a unexpected * Captures any thrown exceptions, logs them to the console and Sentry,
* service error. Also logs them with Sentry. * and returns a generic unexpected service error.
*/ */
export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> => { export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> => {
try { try {
@ -52,10 +52,6 @@ export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> =>
return e.serviceError; return e.serviceError;
} }
if (e instanceof Error) {
return unexpectedError(e.message);
}
return unexpectedError(`An unexpected error occurred. Please try again later.`); return unexpectedError(`An unexpected error occurred. Please try again later.`);
} }
} }

View file

@ -22,8 +22,12 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
getRepoInfoByName(repoName), getRepoInfoByName(repoName),
]); ]);
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) { if (isServiceError(fileSourceResponse)) {
return <div>Error loading file source</div> return <div>Error loading file source: {fileSourceResponse.message}</div>
}
if (isServiceError(repoInfoResponse)) {
return <div>Error loading repo info: {repoInfoResponse.message}</div>
} }
const codeHostInfo = getCodeHostInfoForRepo({ const codeHostInfo = getCodeHostInfoForRepo({

View file

@ -29,9 +29,20 @@ export const AuthMethodSelector = ({
// Call the optional analytics callback first // Call the optional analytics callback first
onProviderClick?.(provider); onProviderClick?.(provider);
signIn(provider, { // @nocheckin
redirectTo: callbackUrl ?? "/" 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]); }, [callbackUrl, onProviderClick]);
// Separate OAuth providers from special auth methods // Separate OAuth providers from special auth methods

View file

@ -60,88 +60,92 @@ export const getProviders = () => {
const providers: IdentityProvider[] = eeIdentityProviders; const providers: IdentityProvider[] = eeIdentityProviders;
if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') { if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') {
providers.push({ provider: EmailProvider({ providers.push({
server: env.SMTP_CONNECTION_URL, provider: EmailProvider({
from: env.EMAIL_FROM_ADDRESS, server: env.SMTP_CONNECTION_URL,
maxAge: 60 * 10, from: env.EMAIL_FROM_ADDRESS,
generateVerificationToken: async () => { maxAge: 60 * 10,
const token = String(Math.floor(100000 + Math.random() * 900000)); generateVerificationToken: async () => {
return token; const token = String(Math.floor(100000 + Math.random() * 900000));
}, return token;
sendVerificationRequest: async ({ identifier, provider, token }) => { },
const transport = createTransport(provider.server); sendVerificationRequest: async ({ identifier, provider, token }) => {
const html = await render(MagicLinkEmail({ token: token })); const transport = createTransport(provider.server);
const result = await transport.sendMail({ const html = await render(MagicLinkEmail({ token: token }));
to: identifier, const result = await transport.sendMail({
from: provider.from, to: identifier,
subject: 'Log in to Sourcebot', from: provider.from,
html, subject: 'Log in to Sourcebot',
text: `Log in to Sourcebot using this code: ${token}` html,
}); text: `Log in to Sourcebot using this code: ${token}`
});
const failed = result.rejected.concat(result.pending).filter(Boolean); const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) { if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`); throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
} }
} }), purpose: "sso"
}), purpose: "sso"}); });
} }
if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') { if (env.AUTH_CREDENTIALS_LOGIN_ENABLED === 'true') {
providers.push({ provider: Credentials({ providers.push({
credentials: { provider: Credentials({
email: {}, credentials: {
password: {} email: {},
}, password: {}
type: "credentials", },
authorize: async (credentials) => { type: "credentials",
const body = verifyCredentialsRequestSchema.safeParse(credentials); authorize: async (credentials) => {
if (!body.success) { const body = verifyCredentialsRequestSchema.safeParse(credentials);
return null; if (!body.success) {
} return null;
const { email, password } = body.data; }
const { email, password } = body.data;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email } 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,
}
}); });
const authJsUser: AuthJsUser = { // The user doesn't exist, so create a new one.
id: newUser.id, if (!user) {
email: newUser.email, const hashedPassword = bcrypt.hashSync(password, 10);
const newUser = await prisma.user.create({
data: {
email,
hashedPassword,
}
});
const authJsUser: AuthJsUser = {
id: newUser.id,
email: newUser.email,
}
onCreateUser({ user: authJsUser });
return authJsUser;
// Otherwise, the user exists, so verify the password.
} else {
if (!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,
};
} }
onCreateUser({ user: authJsUser });
return authJsUser;
// Otherwise, the user exists, so verify the password.
} else {
if (!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,
};
} }
} }), purpose: "sso"
}), purpose: "sso"}); });
} }
return providers; return providers;
@ -156,7 +160,29 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true, trustHost: true,
events: { events: {
createUser: onCreateUser, 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) { if (user.id) {
await auditService.createAudit({ await auditService.createAudit({
action: "user.signed_in", action: "user.signed_in",