Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- Changed the queuing behaviour for permission syncing to prioritize newly created accounts & repos. [#856](https://github.com/sourcebot-dev/sourcebot/pull/856)
- Changed auth behaviour to store OAuth tokens, refresh tokens, and id tokens encrypted at rest in the database. [#853](https://github.com/sourcebot-dev/sourcebot/pull/853)
- Changed refresh token behaviour to source provider tokens from the DB rather than storing them in the JWT token. [#853](https://github.com/sourcebot-dev/sourcebot/pull/853)

## [4.10.25] - 2026-02-04

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"dev:prisma:studio": "yarn with-env yarn workspace @sourcebot/db prisma:studio",
"dev:prisma:migrate:reset": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:reset",
"dev:prisma:db:push": "yarn with-env yarn workspace @sourcebot/db prisma:db:push",
"build:deps": "yarn workspaces foreach --recursive --topological --from '{@sourcebot/schemas,@sourcebot/db,@sourcebot/shared,@sourcebot/query-language}' run build"
"build:deps": "yarn workspaces foreach --recursive --topological --from '{@sourcebot/schemas,@sourcebot/db,@sourcebot/shared,@sourcebot/query-language}' run build",
"tool:decrypt-jwe": "yarn with-env yarn workspace @sourcebot/web tool:decrypt-jwe"
},
"devDependencies": {
"concurrently": "^9.2.1",
Expand Down
15 changes: 9 additions & 6 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/node";
import { PrismaClient, AccountPermissionSyncJobStatus, Account} from "@sourcebot/db";
import { env, hasEntitlement, createLogger, loadConfig } from "@sourcebot/shared";
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared";
import { Job, Queue, Worker } from "bullmq";
import { Redis } from "ioredis";
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
Expand Down Expand Up @@ -166,12 +166,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);

// 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 (!account.access_token) {
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.`);
}

Expand All @@ -181,11 +184,11 @@ export class AccountPermissionSyncer {
.find(connection => connection.type === 'github')?.url;

const { octokit } = await createOctokitFromToken({
token: account.access_token,
token: accessToken,
url: baseUrl,
});

const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit, account.access_token);
const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit, accessToken);

// Token supports scope introspection (classic PAT or OAuth app token)
if (scopes !== null) {
Expand Down Expand Up @@ -225,7 +228,7 @@ export class AccountPermissionSyncer {

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
} else if (account.provider === 'gitlab') {
if (!account.access_token) {
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.`);
}

Expand All @@ -235,7 +238,7 @@ export class AccountPermissionSyncer {
.find(connection => connection.type === 'gitlab')?.url

const api = await createGitLabFromOAuthToken({
oauthToken: account.access_token,
oauthToken: accessToken,
url: baseUrl,
});

Expand Down
102 changes: 101 additions & 1 deletion packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'crypto';
import fs from 'fs';
import { z } from 'zod';
import { env } from './env.server.js';
import { Token } from '@sourcebot/schemas/v3/shared.type';
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
Expand Down Expand Up @@ -104,4 +105,103 @@ export const getTokenFromConfig = async (token: Token): Promise<string> => {
} else {
throw new Error('Invalid token configuration');
}
};
};

// OAuth Token Encryption using AUTH_SECRET
// Encrypts OAuth tokens (access_token, refresh_token, id_token) before database storage.
// Supports automatic migration from plaintext by detecting and handling both encrypted and plaintext tokens.

const oauthAlgorithm = 'aes-256-gcm';
const oauthIvLength = 16;
const oauthSaltLength = 64;

/**
* Schema for encrypted OAuth token structure.
* Stored as base64-encoded JSON in the database.
*/
const encryptedOAuthTokenSchema = z.object({
v: z.literal(1), // Version for future format changes
salt: z.string(), // hex-encoded salt for key derivation
iv: z.string(), // hex-encoded initialization vector
tag: z.string(), // hex-encoded auth tag
data: z.string(), // hex-encoded encrypted data
});

type EncryptedOAuthToken = z.infer<typeof encryptedOAuthTokenSchema>;

function deriveOAuthKey(authSecret: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(authSecret, salt, 100000, 32, 'sha256');
}

function isOAuthTokenEncrypted(token: string): boolean {
try {
const decoded = Buffer.from(token, 'base64').toString('utf8');
const parsed = JSON.parse(decoded);
return encryptedOAuthTokenSchema.safeParse(parsed).success;
} catch {
return false;
}
}

/**
* Encrypts OAuth token using AUTH_SECRET. Idempotent - returns token unchanged if already encrypted.
*/
export function encryptOAuthToken(text: string | null | undefined): string | undefined {
if (!text) {
return undefined;
}

if (isOAuthTokenEncrypted(text)) {
return text;
}

const iv = crypto.randomBytes(oauthIvLength);
const salt = crypto.randomBytes(oauthSaltLength);
const key = deriveOAuthKey(env.AUTH_SECRET, salt);

const cipher = crypto.createCipheriv(oauthAlgorithm, key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();

const tokenData: EncryptedOAuthToken = {
v: 1,
salt: salt.toString('hex'),
iv: iv.toString('hex'),
tag: tag.toString('hex'),
data: encrypted.toString('hex'),
};

return Buffer.from(JSON.stringify(tokenData)).toString('base64');
}

/**
* Decrypts OAuth token using AUTH_SECRET. Returns plaintext tokens unchanged during migration.
*/
export function decryptOAuthToken(encryptedText: string | null | undefined): string | undefined {
if (!encryptedText) {
return undefined;
}

if (!isOAuthTokenEncrypted(encryptedText)) {
return encryptedText;
}

try {
const decoded = Buffer.from(encryptedText, 'base64').toString('utf8');
const tokenData = encryptedOAuthTokenSchema.parse(JSON.parse(decoded));

const salt = Buffer.from(tokenData.salt, 'hex');
const iv = Buffer.from(tokenData.iv, 'hex');
const tag = Buffer.from(tokenData.tag, 'hex');
const encrypted = Buffer.from(tokenData.data, 'hex');

const key = deriveOAuthKey(env.AUTH_SECRET, salt);
const decipher = crypto.createDecipheriv(oauthAlgorithm, key, iv);
decipher.setAuthTag(tag);

return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
} catch {
// Decryption failed - likely a plaintext token, return as-is
return encryptedText;
}
}
2 changes: 2 additions & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export {
hashSecret,
generateApiKey,
verifySignature,
encryptOAuthToken,
decryptOAuthToken,
} from "./crypto.js";
export {
getDBConnectionString,
Expand Down
3 changes: 2 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"test": "cross-env SKIP_ENV_VALIDATION=1 vitest",
"generate:protos": "proto-loader-gen-types --includeComments --longs=Number --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --keepCase --includeDirs=../../vendor/zoekt/grpc/protos --outDir=src/proto zoekt/webserver/v1/webserver.proto zoekt/webserver/v1/query.proto",
"dev:emails": "email dev --dir ./src/emails",
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe",
"tool:decrypt-jwe": "tsx tools/decryptJWE.ts"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.73",
Expand Down
59 changes: 17 additions & 42 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import 'next-auth/jwt';
import NextAuth, { DefaultSession, User as AuthJsUser } from "next-auth"
import Credentials from "next-auth/providers/credentials"
import EmailProvider from "next-auth/providers/nodemailer";
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma";
import { env } from "@sourcebot/shared";
import { User } from '@sourcebot/db';
Expand All @@ -18,7 +17,8 @@ import { hasEntitlement } from '@sourcebot/shared';
import { onCreateUser } from '@/lib/authUtils';
import { getAuditService } from '@/ee/features/audit/factory';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh';
import { refreshLinkedAccountTokens, LinkedAccountErrors } from '@/ee/features/permissionSyncing/tokenRefresh';
import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter';

const auditService = getAuditService();
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
Expand All @@ -31,28 +31,19 @@ export type IdentityProvider = {
required?: boolean;
}

export type LinkedAccountToken = {
provider: string;
accessToken: string;
refreshToken: string;
expiresAt: number;
error?: string;
};
export type LinkedAccountTokensMap = Record<string, LinkedAccountToken>;

declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
linkedAccountProviderErrors?: Record<string, string>;
linkedAccountProviderErrors?: LinkedAccountErrors;
}
}

declare module 'next-auth/jwt' {
interface JWT {
userId: string;
linkedAccountTokens?: LinkedAccountTokensMap;
linkedAccountErrors?: LinkedAccountErrors;
}
}

Expand Down Expand Up @@ -153,7 +144,7 @@ export const getProviders = () => {

export const { handlers, signIn, signOut, auth } = NextAuth({
secret: env.AUTH_SECRET,
adapter: PrismaAdapter(prisma),
adapter: EncryptedPrismaAdapter(prisma),
session: {
strategy: "jwt",
},
Expand All @@ -164,6 +155,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// Explicitly update the Account record with the OAuth token details.
// This is necessary to update the access token when the user
// re-authenticates.
// NOTE: Tokens are encrypted before storage for security
if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) {
await prisma.account.update({
where: {
Expand All @@ -172,14 +164,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providerAccountId: account.providerAccountId,
},
},
data: {
data: encryptAccountData({
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,
}
})
})
}

Expand Down Expand Up @@ -217,28 +209,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}
},
callbacks: {
async jwt({ token, user: _user, account }) {
async jwt({ token, user: _user }) {
const user = _user as User | undefined;
// @note: `user` will be available on signUp or signIn triggers.
// Cache the userId in the JWT for later use.
if (user) {
token.userId = user.id;
}

if (hasEntitlement('permission-syncing')) {
if (account && account.access_token && account.refresh_token && account.expires_at) {
token.linkedAccountTokens = token.linkedAccountTokens || {};
token.linkedAccountTokens[account.providerAccountId] = {
provider: account.provider,
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at,
};
}

if (token.linkedAccountTokens) {
token.linkedAccountTokens = await refreshLinkedAccountTokens(token.linkedAccountTokens);
}
// Refresh expiring tokens and capture any errors.
if (hasEntitlement('permission-syncing') && token.userId) {
const errors = await refreshLinkedAccountTokens(token.userId);
token.linkedAccountErrors = Object.keys(errors).length > 0 ? errors : undefined;
}

return token;
Expand All @@ -252,18 +234,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
id: token.userId,
}

// Pass only linked account provider errors to the session (not sensitive tokens)
if (token.linkedAccountTokens) {
const errors: Record<string, string> = {};
for (const [providerAccountId, tokenData] of Object.entries(token.linkedAccountTokens)) {
if (tokenData.error) {
errors[providerAccountId] = tokenData.error;
}
}
if (Object.keys(errors).length > 0) {
session.linkedAccountProviderErrors = errors;
}
// Pass linked account errors to the session for UI display
if (token.linkedAccountErrors) {
session.linkedAccountProviderErrors = token.linkedAccountErrors;
}

return session;
},
},
Expand Down
Loading
Loading