mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +00:00
Compare commits
4 commits
a164bc983f
...
dfbf66fed2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfbf66fed2 | ||
|
|
d63f3cf9d9 | ||
|
|
d022066529 | ||
|
|
cbc2cfc190 |
8 changed files with 189 additions and 91 deletions
|
|
@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,8 +38,8 @@ const auditService = getAuditService();
|
|||
/**
|
||||
* "Service Error Wrapper".
|
||||
*
|
||||
* Captures any thrown exceptions and converts them to a unexpected
|
||||
* service error. Also logs them with Sentry.
|
||||
* Captures any thrown exceptions, logs them to the console and Sentry,
|
||||
* and returns a generic unexpected service error.
|
||||
*/
|
||||
export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> => {
|
||||
try {
|
||||
|
|
@ -52,10 +52,6 @@ export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> =>
|
|||
return e.serviceError;
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return unexpectedError(e.message);
|
||||
}
|
||||
|
||||
return unexpectedError(`An unexpected error occurred. Please try again later.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,12 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
|
|||
getRepoInfoByName(repoName),
|
||||
]);
|
||||
|
||||
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
|
||||
return <div>Error loading file source</div>
|
||||
if (isServiceError(fileSourceResponse)) {
|
||||
return <div>Error loading file source: {fileSourceResponse.message}</div>
|
||||
}
|
||||
|
||||
if (isServiceError(repoInfoResponse)) {
|
||||
return <div>Error loading repo info: {repoInfoResponse.message}</div>
|
||||
}
|
||||
|
||||
const codeHostInfo = getCodeHostInfoForRepo({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
});
|
||||
|
||||
// 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 user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
const authJsUser: AuthJsUser = {
|
||||
id: newUser.id,
|
||||
email: newUser.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 = {
|
||||
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;
|
||||
|
|
@ -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<string, string> = {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue