Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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;
}
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
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