Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
25 changes: 22 additions & 3 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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
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
31 changes: 28 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 @@ -202,7 +202,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =

return !isExcluded;
});

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

return {
Expand Down Expand Up @@ -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;
}
}
17 changes: 14 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,20 @@ export const AuthMethodSelector = ({
// Call the optional analytics callback first
onProviderClick?.(provider);

signIn(provider, {
redirectTo: callbackUrl ?? "/"
});
// @nocheckin
Copy link
Contributor Author

Choose a reason for hiding this comment

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

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
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.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
Loading