Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- [EE] Fixed account-driven permission sync silently wiping all Bitbucket Server repository permissions when the OAuth token expires on instances with anonymous access enabled. [#998](https://github.com/sourcebot-dev/sourcebot/pull/998)
- [EE] Fixed Bitbucket Server repos being incorrectly treated as public in Sourcebot when the instance-level `feature.public.access` flag is disabled but per-repo public flags were not reset. [#999](https://github.com/sourcebot-dev/sourcebot/pull/999)
- [EE] Fixed account-driven permission sync jobs failing when OAuth tokens expire between user visits by moving token refresh into the backend sync flow. [#1000](https://github.com/sourcebot-dev/sourcebot/pull/1000)
- Fixed `filterByRepos` and `filterByFilepaths` returning no results for repository names containing dots, dashes, or slashes by replacing `escape-string-regexp` (which produces `\xNN` hex escapes incompatible with Zoekt's RE2 engine) with a RE2-compatible escaper. [#1004](https://github.com/sourcebot-dev/sourcebot/pull/1004)

## [4.15.5] - 2026-03-12

Expand Down
24 changes: 5 additions & 19 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Sentry from "@sentry/node";
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
import { env, hasEntitlement, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
import { ensureFreshAccountToken } from "./tokenRefresh.js";
import { Job, Queue, Worker } from "bullmq";
import { Redis } from "ioredis";
import {
Expand Down Expand Up @@ -182,18 +183,15 @@ export class AccountPermissionSyncer {

logger.info(`Syncing permissions for ${account.provider} account (id: ${account.id}) for user ${account.user.email}...`);

// Decrypt tokens (stored encrypted in the database)
const accessToken = decryptOAuthToken(account.access_token);
// Ensure the OAuth token is fresh, refreshing it if it is expired or near expiry.
// Throws and sets Account.tokenRefreshErrorMessage if the refresh fails.
const accessToken = await ensureFreshAccountToken(account, this.db);

// Get a list of all repos that the user has access to from all connected accounts.
const repoIds = await (async () => {
const aggregatedRepoIds: Set<number> = new Set();

if (account.provider === 'github') {
if (!accessToken) {
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.
// Instead, we'll use the first connection of type 'github' and hope for the best.
const baseUrl = Array.from(Object.values(config.connections ?? {}))
Expand Down Expand Up @@ -244,10 +242,6 @@ export class AccountPermissionSyncer {

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
} else if (account.provider === 'gitlab') {
if (!accessToken) {
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.
// Instead, we'll use the first connection of type 'gitlab' and hope for the best.
const baseUrl = Array.from(Object.values(config.connections ?? {}))
Expand Down Expand Up @@ -284,10 +278,6 @@ export class AccountPermissionSyncer {

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
} else if (account.provider === 'bitbucket-cloud') {
if (!accessToken) {
throw new Error(`User '${account.user.email}' does not have a Bitbucket Cloud OAuth access token associated with their account. Please re-authenticate with Bitbucket Cloud to refresh the token.`);
}

// @note: we don't pass a user here since we want to use a bearer token
// for authentication.
const client = createBitbucketCloudClient(/* user = */ undefined, accessToken)
Expand All @@ -305,10 +295,6 @@ export class AccountPermissionSyncer {

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
} else if (account.provider === 'bitbucket-server') {
if (!accessToken) {
throw new Error(`User '${account.user.email}' does not have a Bitbucket Server OAuth access token associated with their account. Please re-authenticate with Bitbucket Server to refresh the token.`);
}

// @hack: we don't have a way of identifying specific identity providers in the config file.
// Instead, we'll use the first Bitbucket Server connection's URL as the base URL.
const baseUrl = Array.from(Object.values(config.connections ?? {}))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { loadConfig, decryptOAuthToken } from "@sourcebot/shared";
import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared";
import { BitbucketCloudIdentityProviderConfig, BitbucketServerIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
import { IdentityProviderType } from "@sourcebot/shared";
import { Account, PrismaClient } from '@sourcebot/db';
import {
BitbucketCloudIdentityProviderConfig,
BitbucketServerIdentityProviderConfig,
GitHubIdentityProviderConfig,
GitLabIdentityProviderConfig,
} from '@sourcebot/schemas/v3/index.type';
import {
createLogger,
decryptOAuthToken,
encryptOAuthToken,
env,
getTokenFromConfig,
IdentityProviderType,
loadConfig,
} from '@sourcebot/shared';
import { z } from 'zod';
import { prisma } from '@/prisma';

const logger = createLogger('web-ee-token-refresh');
const logger = createLogger('backend-ee-token-refresh');

const SUPPORTED_PROVIDERS = [
'github',
Expand All @@ -16,117 +27,124 @@ const SUPPORTED_PROVIDERS = [

type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];

const isSupportedProvider = (provider: string): provider is SupportedProvider => {
return SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);
}
const isSupportedProvider = (provider: string): provider is SupportedProvider =>
SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);

// Map of providerAccountId -> error message
export type LinkedAccountErrors = Record<string, string>;
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
const OAuthTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string().optional(),
expires_in: z.number().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
});

// In-memory lock to prevent concurrent refresh attempts for the same user
const refreshLocks = new Map<string, Promise<LinkedAccountErrors>>();
type OAuthTokenResponse = z.infer<typeof OAuthTokenResponseSchema>;

type ProviderCredentials = {
clientId: string;
clientSecret: string;
baseUrl?: string;
};

const EXPIRY_BUFFER_S = 5 * 60; // 5 minutes

/**
* Refreshes expiring OAuth tokens for all linked accounts of a user.
* Loads accounts from database, refreshes tokens as needed, and returns any errors.
* Uses an in-memory lock to prevent concurrent refresh attempts for the same user.
* Ensures the OAuth access token for a given account is fresh.
*
* - If the token is not expired (or has no expiry), decrypts and returns it as-is.
* - If the token is expired or near expiry, attempts a refresh using the OAuth
* client credentials from the config file (or deprecated env vars).
* - On successful refresh: persists the new tokens to the DB, clears any
* tokenRefreshErrorMessage, and returns the fresh access token.
* - On failure: sets tokenRefreshErrorMessage on the account and throws, so
* the calling job fails with a clear error.
*/
export const refreshLinkedAccountTokens = async (userId: string): Promise<LinkedAccountErrors> => {
// Check if there's already an in-flight refresh for this user
const existingRefresh = refreshLocks.get(userId);
if (existingRefresh) {
return existingRefresh;
export const ensureFreshAccountToken = async (
account: Account,
db: PrismaClient,
): Promise<string> => {
if (!account.access_token) {
throw new Error(`Account ${account.id} (${account.provider}) has no access token.`);
}

// Create the refresh promise and store it in the lock map
const refreshPromise = doRefreshLinkedAccountTokens(userId);
refreshLocks.set(userId, refreshPromise);
if (!isSupportedProvider(account.provider)) {
// Non-refreshable provider — just decrypt and return whatever is stored.
const token = decryptOAuthToken(account.access_token);
if (!token) {
throw new Error(`Failed to decrypt access token for account ${account.id}.`);
}
return token;
}

try {
return await refreshPromise;
} finally {
refreshLocks.delete(userId);
const now = Math.floor(Date.now() / 1000);
const isExpiredOrNearExpiry =
account.expires_at !== null &&
account.expires_at > 0 &&
now >= account.expires_at - EXPIRY_BUFFER_S;

if (!isExpiredOrNearExpiry) {
const token = decryptOAuthToken(account.access_token);
if (!token) {
throw new Error(`Failed to decrypt access token for account ${account.id}.`);
}
return token;
}
};

const doRefreshLinkedAccountTokens = async (userId: string): Promise<LinkedAccountErrors> => {
// Only grab accounts that can be refreshed (i.e., have an access token, refresh token, and expires_at).
const accounts = await prisma.account.findMany({
where: {
userId,
access_token: { not: null },
refresh_token: { not: null },
expires_at: { not: null },
},
select: {
provider: true,
providerAccountId: true,
access_token: true,
refresh_token: true,
expires_at: true,
},
});
if (!account.refresh_token) {
const message = `Account ${account.id} (${account.provider}) token is expired and has no refresh token.`;
logger.error(message);
await setTokenRefreshError(account.id, message, db);
throw new Error(message);
}

const now = Math.floor(Date.now() / 1000);
const bufferTimeS = 5 * 60; // 5 minutes
const errors: LinkedAccountErrors = {};
const refreshToken = decryptOAuthToken(account.refresh_token);
if (!refreshToken) {
const message = `Failed to decrypt refresh token for account ${account.id} (${account.provider}).`;
logger.error(message);
await setTokenRefreshError(account.id, message, db);
throw new Error(message);
}

await Promise.all(
accounts.map(async (account) => {
const { provider, providerAccountId, expires_at } = account;
logger.debug(`Refreshing OAuth token for account ${account.id} (${account.provider})...`);

if (!isSupportedProvider(provider)) {
return;
}
const refreshResponse = await refreshOAuthToken(account.provider, refreshToken);
if (!refreshResponse) {
const message = `OAuth token refresh failed for account ${account.id} (${account.provider}).`;
logger.error(message);
await setTokenRefreshError(account.id, message, db);
throw new Error(message);
}

if (expires_at !== null && expires_at > 0 && now >= (expires_at - bufferTimeS)) {
const refreshToken = decryptOAuthToken(account.refresh_token);
if (!refreshToken) {
logger.error(`Failed to decrypt refresh token for providerAccountId: ${providerAccountId}`);
errors[providerAccountId] = 'RefreshTokenError';
return;
}
const newExpiresAt = refreshResponse.expires_in
? Math.floor(Date.now() / 1000) + refreshResponse.expires_in
: null;

await db.account.update({
where: { id: account.id },
data: {
access_token: encryptOAuthToken(refreshResponse.access_token),
// Only update refresh_token if a new one was provided; preserve the
// existing one otherwise (some providers use rotating refresh tokens,
// others reuse the same one).
...(refreshResponse.refresh_token !== undefined
? { refresh_token: encryptOAuthToken(refreshResponse.refresh_token) }
: {}),
expires_at: newExpiresAt,
tokenRefreshErrorMessage: null,
},
});

try {
logger.info(`Refreshing token for providerAccountId: ${providerAccountId} (${provider})`);
const refreshTokenResponse = await refreshOAuthToken(provider, refreshToken);

if (refreshTokenResponse) {
const expires_at = refreshTokenResponse.expires_in ? Math.floor(Date.now() / 1000) + refreshTokenResponse.expires_in : null;

await prisma.account.update({
where: {
provider_providerAccountId: {
provider,
providerAccountId,
}
},
data: {
access_token: encryptOAuthToken(refreshTokenResponse.access_token),
// Only update refresh_token if a new one was provided.
// This will preserve an existing refresh token if the provider
// does not return a new one.
...(refreshTokenResponse.refresh_token !== undefined ? {
refresh_token: encryptOAuthToken(refreshTokenResponse.refresh_token),
} : {}),
expires_at,
},
});
logger.info(`Successfully refreshed token for provider: ${provider}`);
} else {
logger.error(`Failed to refresh token for provider: ${provider}`);
errors[providerAccountId] = 'RefreshTokenError';
}
} catch (error) {
logger.error(`Error refreshing token for provider ${provider}:`, error);
errors[providerAccountId] = 'RefreshTokenError';
}
}
})
);
logger.debug(`Successfully refreshed OAuth token for account ${account.id} (${account.provider}).`);
return refreshResponse.access_token;
};

return errors;
}
const setTokenRefreshError = async (accountId: string, message: string, db: PrismaClient) => {
await db.account.update({
where: { id: accountId },
data: { tokenRefreshErrorMessage: message },
});
};

const refreshOAuthToken = async (
provider: SupportedProvider,
Expand All @@ -135,10 +153,9 @@ const refreshOAuthToken = async (
try {
const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config?.identityProviders ?? [];

const providerConfigs = identityProviders.filter(idp => idp.provider === provider);

// If no provider configs in the config file, try deprecated env vars
// If no provider configs in the config file, try deprecated env vars.
if (providerConfigs.length === 0) {
const envCredentials = getDeprecatedEnvCredentials(provider);
if (envCredentials) {
Expand All @@ -150,7 +167,7 @@ const refreshOAuthToken = async (
logger.error(`Failed to refresh ${provider} token using deprecated env credentials`);
return null;
}
logger.error(`Provider config not found or invalid for: ${provider}`);
logger.error(`No provider config or env credentials found for: ${provider}`);
return null;
}

Expand All @@ -172,7 +189,9 @@ const refreshOAuthToken = async (
// Get client credentials from config
const clientId = await getTokenFromConfig(linkedAccountProviderConfig.clientId);
const clientSecret = await getTokenFromConfig(linkedAccountProviderConfig.clientSecret);
const baseUrl = 'baseUrl' in linkedAccountProviderConfig ? linkedAccountProviderConfig.baseUrl : undefined;
const baseUrl = 'baseUrl' in linkedAccountProviderConfig
? linkedAccountProviderConfig.baseUrl
: undefined;

const result = await tryRefreshToken(provider, refreshToken, { clientId, clientSecret, baseUrl });
if (result) {
Expand All @@ -186,29 +205,12 @@ const refreshOAuthToken = async (

logger.error(`All provider configs failed for: ${provider}`);
return null;
} catch (error) {
logger.error(`Error refreshing ${provider} token:`, error);
} catch (e) {
logger.error(`Error refreshing ${provider} token:`, e);
return null;
}
}

type ProviderCredentials = {
clientId: string;
clientSecret: string;
baseUrl?: string;
};

// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
const OAuthTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string().optional(),
expires_in: z.number().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
});

type OAuthTokenResponse = z.infer<typeof OAuthTokenResponseSchema>;

const tryRefreshToken = async (
provider: SupportedProvider,
refreshToken: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "tokenRefreshErrorMessage" TEXT;
4 changes: 4 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,10 @@ model Account {
permissionSyncJobs AccountPermissionSyncJob[]
permissionSyncedAt DateTime?

/// Set when an OAuth token refresh fails and the account needs to be re-linked by the user.
/// Cleared when the user successfully re-authenticates.
tokenRefreshErrorMessage String?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down
Loading
Loading