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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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