Skip to content

Commit c0b39e6

Browse files
fix(worker): refresh OAuth tokens in backend permission sync flow (#1000)
* fix(worker): refresh OAuth tokens in backend permission sync flow Token refresh was previously only triggered from the Next.js jwt callback, meaning tokens could expire between user visits and cause account-driven permission sync jobs to fail silently. Move refresh logic to packages/backend/src/ee/tokenRefresh.ts and call it from accountPermissionSyncer before using an account's access token. On refresh failure, tokenRefreshErrorMessage is set on the Account record and surfaced in the linked accounts UI so users know to re-authenticate. Also adds a DB migration for the tokenRefreshErrorMessage field and wires the signIn event to clear it on successful re-authentication. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changelog --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a0d4658 commit c0b39e6

File tree

9 files changed

+145
-163
lines changed

9 files changed

+145
-163
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
### Fixed
1414
- [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)
1515
- [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)
16+
- [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)
1617

1718
## [4.15.5] - 2026-03-12
1819

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as Sentry from "@sentry/node";
22
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
3-
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
3+
import { env, hasEntitlement, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
4+
import { ensureFreshAccountToken } from "./tokenRefresh.js";
45
import { Job, Queue, Worker } from "bullmq";
56
import { Redis } from "ioredis";
67
import {
@@ -182,18 +183,15 @@ export class AccountPermissionSyncer {
182183

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

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

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

192194
if (account.provider === 'github') {
193-
if (!accessToken) {
194-
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.`);
195-
}
196-
197195
// @hack: we don't have a way of identifying specific identity providers in the config file.
198196
// Instead, we'll use the first connection of type 'github' and hope for the best.
199197
const baseUrl = Array.from(Object.values(config.connections ?? {}))
@@ -244,10 +242,6 @@ export class AccountPermissionSyncer {
244242

245243
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
246244
} else if (account.provider === 'gitlab') {
247-
if (!accessToken) {
248-
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.`);
249-
}
250-
251245
// @hack: we don't have a way of identifying specific identity providers in the config file.
252246
// Instead, we'll use the first connection of type 'gitlab' and hope for the best.
253247
const baseUrl = Array.from(Object.values(config.connections ?? {}))
@@ -284,10 +278,6 @@ export class AccountPermissionSyncer {
284278

285279
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
286280
} else if (account.provider === 'bitbucket-cloud') {
287-
if (!accessToken) {
288-
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.`);
289-
}
290-
291281
// @note: we don't pass a user here since we want to use a bearer token
292282
// for authentication.
293283
const client = createBitbucketCloudClient(/* user = */ undefined, accessToken)
@@ -305,10 +295,6 @@ export class AccountPermissionSyncer {
305295

306296
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
307297
} else if (account.provider === 'bitbucket-server') {
308-
if (!accessToken) {
309-
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.`);
310-
}
311-
312298
// @hack: we don't have a way of identifying specific identity providers in the config file.
313299
// Instead, we'll use the first Bitbucket Server connection's URL as the base URL.
314300
const baseUrl = Array.from(Object.values(config.connections ?? {}))

packages/web/src/ee/features/sso/tokenRefresh.ts renamed to packages/backend/src/ee/tokenRefresh.ts

Lines changed: 126 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1-
import { loadConfig, decryptOAuthToken } from "@sourcebot/shared";
2-
import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared";
3-
import { BitbucketCloudIdentityProviderConfig, BitbucketServerIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
4-
import { IdentityProviderType } from "@sourcebot/shared";
1+
import { Account, PrismaClient } from '@sourcebot/db';
2+
import {
3+
BitbucketCloudIdentityProviderConfig,
4+
BitbucketServerIdentityProviderConfig,
5+
GitHubIdentityProviderConfig,
6+
GitLabIdentityProviderConfig,
7+
} from '@sourcebot/schemas/v3/index.type';
8+
import {
9+
createLogger,
10+
decryptOAuthToken,
11+
encryptOAuthToken,
12+
env,
13+
getTokenFromConfig,
14+
IdentityProviderType,
15+
loadConfig,
16+
} from '@sourcebot/shared';
517
import { z } from 'zod';
6-
import { prisma } from '@/prisma';
718

8-
const logger = createLogger('web-ee-token-refresh');
19+
const logger = createLogger('backend-ee-token-refresh');
920

1021
const SUPPORTED_PROVIDERS = [
1122
'github',
@@ -16,117 +27,124 @@ const SUPPORTED_PROVIDERS = [
1627

1728
type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];
1829

19-
const isSupportedProvider = (provider: string): provider is SupportedProvider => {
20-
return SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);
21-
}
30+
const isSupportedProvider = (provider: string): provider is SupportedProvider =>
31+
SUPPORTED_PROVIDERS.includes(provider as SupportedProvider);
2232

23-
// Map of providerAccountId -> error message
24-
export type LinkedAccountErrors = Record<string, string>;
33+
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
34+
const OAuthTokenResponseSchema = z.object({
35+
access_token: z.string(),
36+
token_type: z.string().optional(),
37+
expires_in: z.number().optional(),
38+
refresh_token: z.string().optional(),
39+
scope: z.string().optional(),
40+
});
2541

26-
// In-memory lock to prevent concurrent refresh attempts for the same user
27-
const refreshLocks = new Map<string, Promise<LinkedAccountErrors>>();
42+
type OAuthTokenResponse = z.infer<typeof OAuthTokenResponseSchema>;
43+
44+
type ProviderCredentials = {
45+
clientId: string;
46+
clientSecret: string;
47+
baseUrl?: string;
48+
};
49+
50+
const EXPIRY_BUFFER_S = 5 * 60; // 5 minutes
2851

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

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

45-
try {
46-
return await refreshPromise;
47-
} finally {
48-
refreshLocks.delete(userId);
80+
const now = Math.floor(Date.now() / 1000);
81+
const isExpiredOrNearExpiry =
82+
account.expires_at !== null &&
83+
account.expires_at > 0 &&
84+
now >= account.expires_at - EXPIRY_BUFFER_S;
85+
86+
if (!isExpiredOrNearExpiry) {
87+
const token = decryptOAuthToken(account.access_token);
88+
if (!token) {
89+
throw new Error(`Failed to decrypt access token for account ${account.id}.`);
90+
}
91+
return token;
4992
}
50-
};
5193

52-
const doRefreshLinkedAccountTokens = async (userId: string): Promise<LinkedAccountErrors> => {
53-
// Only grab accounts that can be refreshed (i.e., have an access token, refresh token, and expires_at).
54-
const accounts = await prisma.account.findMany({
55-
where: {
56-
userId,
57-
access_token: { not: null },
58-
refresh_token: { not: null },
59-
expires_at: { not: null },
60-
},
61-
select: {
62-
provider: true,
63-
providerAccountId: true,
64-
access_token: true,
65-
refresh_token: true,
66-
expires_at: true,
67-
},
68-
});
94+
if (!account.refresh_token) {
95+
const message = `Account ${account.id} (${account.provider}) token is expired and has no refresh token.`;
96+
logger.error(message);
97+
await setTokenRefreshError(account.id, message, db);
98+
throw new Error(message);
99+
}
69100

70-
const now = Math.floor(Date.now() / 1000);
71-
const bufferTimeS = 5 * 60; // 5 minutes
72-
const errors: LinkedAccountErrors = {};
101+
const refreshToken = decryptOAuthToken(account.refresh_token);
102+
if (!refreshToken) {
103+
const message = `Failed to decrypt refresh token for account ${account.id} (${account.provider}).`;
104+
logger.error(message);
105+
await setTokenRefreshError(account.id, message, db);
106+
throw new Error(message);
107+
}
73108

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

78-
if (!isSupportedProvider(provider)) {
79-
return;
80-
}
111+
const refreshResponse = await refreshOAuthToken(account.provider, refreshToken);
112+
if (!refreshResponse) {
113+
const message = `OAuth token refresh failed for account ${account.id} (${account.provider}).`;
114+
logger.error(message);
115+
await setTokenRefreshError(account.id, message, db);
116+
throw new Error(message);
117+
}
81118

82-
if (expires_at !== null && expires_at > 0 && now >= (expires_at - bufferTimeS)) {
83-
const refreshToken = decryptOAuthToken(account.refresh_token);
84-
if (!refreshToken) {
85-
logger.error(`Failed to decrypt refresh token for providerAccountId: ${providerAccountId}`);
86-
errors[providerAccountId] = 'RefreshTokenError';
87-
return;
88-
}
119+
const newExpiresAt = refreshResponse.expires_in
120+
? Math.floor(Date.now() / 1000) + refreshResponse.expires_in
121+
: null;
122+
123+
await db.account.update({
124+
where: { id: account.id },
125+
data: {
126+
access_token: encryptOAuthToken(refreshResponse.access_token),
127+
// Only update refresh_token if a new one was provided; preserve the
128+
// existing one otherwise (some providers use rotating refresh tokens,
129+
// others reuse the same one).
130+
...(refreshResponse.refresh_token !== undefined
131+
? { refresh_token: encryptOAuthToken(refreshResponse.refresh_token) }
132+
: {}),
133+
expires_at: newExpiresAt,
134+
tokenRefreshErrorMessage: null,
135+
},
136+
});
89137

90-
try {
91-
logger.info(`Refreshing token for providerAccountId: ${providerAccountId} (${provider})`);
92-
const refreshTokenResponse = await refreshOAuthToken(provider, refreshToken);
93-
94-
if (refreshTokenResponse) {
95-
const expires_at = refreshTokenResponse.expires_in ? Math.floor(Date.now() / 1000) + refreshTokenResponse.expires_in : null;
96-
97-
await prisma.account.update({
98-
where: {
99-
provider_providerAccountId: {
100-
provider,
101-
providerAccountId,
102-
}
103-
},
104-
data: {
105-
access_token: encryptOAuthToken(refreshTokenResponse.access_token),
106-
// Only update refresh_token if a new one was provided.
107-
// This will preserve an existing refresh token if the provider
108-
// does not return a new one.
109-
...(refreshTokenResponse.refresh_token !== undefined ? {
110-
refresh_token: encryptOAuthToken(refreshTokenResponse.refresh_token),
111-
} : {}),
112-
expires_at,
113-
},
114-
});
115-
logger.info(`Successfully refreshed token for provider: ${provider}`);
116-
} else {
117-
logger.error(`Failed to refresh token for provider: ${provider}`);
118-
errors[providerAccountId] = 'RefreshTokenError';
119-
}
120-
} catch (error) {
121-
logger.error(`Error refreshing token for provider ${provider}:`, error);
122-
errors[providerAccountId] = 'RefreshTokenError';
123-
}
124-
}
125-
})
126-
);
138+
logger.debug(`Successfully refreshed OAuth token for account ${account.id} (${account.provider}).`);
139+
return refreshResponse.access_token;
140+
};
127141

128-
return errors;
129-
}
142+
const setTokenRefreshError = async (accountId: string, message: string, db: PrismaClient) => {
143+
await db.account.update({
144+
where: { id: accountId },
145+
data: { tokenRefreshErrorMessage: message },
146+
});
147+
};
130148

131149
const refreshOAuthToken = async (
132150
provider: SupportedProvider,
@@ -135,10 +153,9 @@ const refreshOAuthToken = async (
135153
try {
136154
const config = await loadConfig(env.CONFIG_PATH);
137155
const identityProviders = config?.identityProviders ?? [];
138-
139156
const providerConfigs = identityProviders.filter(idp => idp.provider === provider);
140157

141-
// If no provider configs in the config file, try deprecated env vars
158+
// If no provider configs in the config file, try deprecated env vars.
142159
if (providerConfigs.length === 0) {
143160
const envCredentials = getDeprecatedEnvCredentials(provider);
144161
if (envCredentials) {
@@ -150,7 +167,7 @@ const refreshOAuthToken = async (
150167
logger.error(`Failed to refresh ${provider} token using deprecated env credentials`);
151168
return null;
152169
}
153-
logger.error(`Provider config not found or invalid for: ${provider}`);
170+
logger.error(`No provider config or env credentials found for: ${provider}`);
154171
return null;
155172
}
156173

@@ -172,7 +189,9 @@ const refreshOAuthToken = async (
172189
// Get client credentials from config
173190
const clientId = await getTokenFromConfig(linkedAccountProviderConfig.clientId);
174191
const clientSecret = await getTokenFromConfig(linkedAccountProviderConfig.clientSecret);
175-
const baseUrl = 'baseUrl' in linkedAccountProviderConfig ? linkedAccountProviderConfig.baseUrl : undefined;
192+
const baseUrl = 'baseUrl' in linkedAccountProviderConfig
193+
? linkedAccountProviderConfig.baseUrl
194+
: undefined;
176195

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

187206
logger.error(`All provider configs failed for: ${provider}`);
188207
return null;
189-
} catch (error) {
190-
logger.error(`Error refreshing ${provider} token:`, error);
208+
} catch (e) {
209+
logger.error(`Error refreshing ${provider} token:`, e);
191210
return null;
192211
}
193-
}
194-
195-
type ProviderCredentials = {
196-
clientId: string;
197-
clientSecret: string;
198-
baseUrl?: string;
199212
};
200213

201-
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
202-
const OAuthTokenResponseSchema = z.object({
203-
access_token: z.string(),
204-
token_type: z.string().optional(),
205-
expires_in: z.number().optional(),
206-
refresh_token: z.string().optional(),
207-
scope: z.string().optional(),
208-
});
209-
210-
type OAuthTokenResponse = z.infer<typeof OAuthTokenResponseSchema>;
211-
212214
const tryRefreshToken = async (
213215
provider: SupportedProvider,
214216
refreshToken: string,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Account" ADD COLUMN "tokenRefreshErrorMessage" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,10 @@ model Account {
460460
permissionSyncJobs AccountPermissionSyncJob[]
461461
permissionSyncedAt DateTime?
462462
463+
/// Set when an OAuth token refresh fails and the account needs to be re-linked by the user.
464+
/// Cleared when the user successfully re-authenticates.
465+
tokenRefreshErrorMessage String?
466+
463467
createdAt DateTime @default(now())
464468
updatedAt DateTime @updatedAt
465469

0 commit comments

Comments
 (0)