Skip to content
Merged
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
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, env.AUTH_SECRET);

// 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
73 changes: 72 additions & 1 deletion packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,75 @@ 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;
const oauthTagLength = 16;
const oauthTagPosition = oauthSaltLength + oauthIvLength;
const oauthEncryptedPosition = oauthTagPosition + oauthTagLength;
const minEncryptedLength = 128; // Minimum base64-encoded length for encrypted tokens

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

function isOAuthTokenEncrypted(token: string): boolean {
if (token.length < minEncryptedLength) return false;

try {
const decoded = Buffer.from(token, 'base64');
return decoded.length >= (oauthSaltLength + oauthIvLength + oauthTagLength);
} catch {
return false;
}
}

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

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

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

return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
}

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

try {
const data = Buffer.from(encryptedText, 'base64');

const salt = data.subarray(0, oauthSaltLength);
const iv = data.subarray(oauthSaltLength, oauthTagPosition);
const tag = data.subarray(oauthTagPosition, oauthEncryptedPosition);
const encrypted = data.subarray(oauthEncryptedPosition);

const key = deriveOAuthKey(authSecret, 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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
9 changes: 5 additions & 4 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 @@ -19,6 +18,7 @@ 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 { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter';

const auditService = getAuditService();
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
Expand Down Expand Up @@ -153,7 +153,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 +164,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 +173,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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { loadConfig } from "@sourcebot/shared";
import { getTokenFromConfig, createLogger, env } from "@sourcebot/shared";
import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared";
import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
import { LinkedAccountTokensMap } from "@/auth"
const { prisma } = await import('@/prisma');
Expand Down Expand Up @@ -42,8 +42,8 @@ export async function refreshLinkedAccountTokens(
}
},
data: {
access_token: refreshedTokens.accessToken,
refresh_token: refreshedTokens.refreshToken,
access_token: encryptOAuthToken(refreshedTokens.accessToken, env.AUTH_SECRET),
refresh_token: encryptOAuthToken(refreshedTokens.refreshToken, env.AUTH_SECRET),
expires_at: refreshedTokens.expiresAt,
},
});
Comment thread
brendan-kellam marked this conversation as resolved.
Expand Down
47 changes: 47 additions & 0 deletions packages/web/src/lib/encryptedPrismaAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import type { Adapter, AdapterAccount } from "next-auth/adapters";
import { PrismaClient } from "@sourcebot/db";
import { encryptOAuthToken, env } from "@sourcebot/shared";

/**
* Encrypts OAuth tokens in account data before database storage
*/
function encryptAccountTokens(account: AdapterAccount): AdapterAccount {
return {
...account,
access_token: encryptOAuthToken(account.access_token, env.AUTH_SECRET),
refresh_token: encryptOAuthToken(account.refresh_token, env.AUTH_SECRET),
id_token: encryptOAuthToken(account.id_token, env.AUTH_SECRET),
};
}

/**
* Encrypted Prisma adapter that automatically encrypts OAuth tokens before storage
*/
export function EncryptedPrismaAdapter(prisma: PrismaClient): Adapter {
const baseAdapter = PrismaAdapter(prisma);

return {
...baseAdapter,
async linkAccount(account: AdapterAccount) {
return baseAdapter.linkAccount!(encryptAccountTokens(account));
},
};
}

/**
* Encrypts OAuth tokens in account data (for manual account updates in signIn event)
*/
export function encryptAccountData(data: {
access_token?: string | null;
refresh_token?: string | null;
id_token?: string | null;
[key: string]: any;
}) {
return {
...data,
access_token: encryptOAuthToken(data.access_token, env.AUTH_SECRET),
refresh_token: encryptOAuthToken(data.refresh_token, env.AUTH_SECRET),
id_token: encryptOAuthToken(data.id_token, env.AUTH_SECRET),
};
Comment thread
brendan-kellam marked this conversation as resolved.
}
Loading