From aa8c06b9aa7cd7028cfbc4dac6bfea0a3d2a5da5 Mon Sep 17 00:00:00 2001 From: Harrison Smith Date: Thu, 5 Feb 2026 09:12:24 +0900 Subject: [PATCH 1/5] feat(auth): Implement encrypted storage for OAuth account tokens - Add AES-256-GCM encryption for OAuth access and refresh tokens - Create EncryptedPrismaAdapter wrapping Auth.js PrismaAdapter - Implement automatic encryption on token storage and decryption on usage - Support graceful handling of existing plaintext tokens with automatic migration - Update accountPermissionSyncer to decrypt tokens before API calls - Update tokenRefresh to encrypt refreshed tokens before storage This improves security by ensuring OAuth tokens are encrypted at rest in the database. --- .../backend/src/ee/accountPermissionSyncer.ts | 15 ++-- packages/shared/src/crypto.ts | 73 ++++++++++++++++++- packages/shared/src/index.server.ts | 2 + packages/web/src/auth.ts | 9 ++- .../permissionSyncing/tokenRefresh.ts | 6 +- .../web/src/lib/encryptedPrismaAdapter.ts | 47 ++++++++++++ 6 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 packages/web/src/lib/encryptedPrismaAdapter.ts diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 9197f8eca..9f395c103 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -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"; @@ -160,12 +160,15 @@ export class AccountPermissionSyncer { logger.info(`Syncing permissions for ${account.provider} account (id: ${account.id}) for user ${account.user.email}...`); + // Decrypt tokens (stored encrypted in the database) + const accessToken = decryptOAuthToken(account.access_token, env.AUTH_SECRET); + // Get a list of all repos that the user has access to from all connected accounts. const repoIds = await (async () => { const aggregatedRepoIds: Set = 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.`); } @@ -175,11 +178,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) { @@ -219,7 +222,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.`); } @@ -229,7 +232,7 @@ export class AccountPermissionSyncer { .find(connection => connection.type === 'gitlab')?.url const api = await createGitLabFromOAuthToken({ - oauthToken: account.access_token, + oauthToken: accessToken, url: baseUrl, }); diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index 7a88e5ab2..379b8c3c2 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -104,4 +104,75 @@ export const getTokenFromConfig = async (token: Token): Promise => { } else { throw new Error('Invalid token configuration'); } -}; \ No newline at end of file +}; + +// OAuth Token Encryption using AUTH_SECRET +// Encrypts OAuth tokens (access_token, refresh_token, id_token) before database storage. +// Supports automatic migration from plaintext by detecting and handling both encrypted and plaintext tokens. + +const oauthAlgorithm = 'aes-256-gcm'; +const oauthIvLength = 16; +const oauthSaltLength = 64; +const oauthTagLength = 16; +const oauthTagPosition = oauthSaltLength + oauthIvLength; +const oauthEncryptedPosition = oauthTagPosition + oauthTagLength; +const minEncryptedLength = 128; // Minimum base64-encoded length for encrypted tokens + +function deriveOAuthKey(authSecret: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync(authSecret, salt, 100000, 32, 'sha256'); +} + +function isOAuthTokenEncrypted(token: string): boolean { + if (token.length < minEncryptedLength) return false; + + try { + const decoded = Buffer.from(token, 'base64'); + return decoded.length >= (oauthSaltLength + oauthIvLength + oauthTagLength); + } catch { + return false; + } +} + +/** + * Encrypts OAuth token using AUTH_SECRET. Idempotent - returns token unchanged if already encrypted. + */ +export function encryptOAuthToken(text: string | null | undefined, authSecret: string): string | null { + if (!text || !authSecret) return null; + if (isOAuthTokenEncrypted(text)) return text; + + const iv = crypto.randomBytes(oauthIvLength); + const salt = crypto.randomBytes(oauthSaltLength); + const key = deriveOAuthKey(authSecret, salt); + + const cipher = crypto.createCipheriv(oauthAlgorithm, key, iv); + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); +} + +/** + * Decrypts OAuth token using AUTH_SECRET. Returns plaintext tokens unchanged during migration. + */ +export function decryptOAuthToken(encryptedText: string | null | undefined, authSecret: string): string | null { + if (!encryptedText || !authSecret) return null; + if (!isOAuthTokenEncrypted(encryptedText)) return encryptedText; + + try { + const data = Buffer.from(encryptedText, 'base64'); + + const salt = data.subarray(0, oauthSaltLength); + const iv = data.subarray(oauthSaltLength, oauthTagPosition); + const tag = data.subarray(oauthTagPosition, oauthEncryptedPosition); + const encrypted = data.subarray(oauthEncryptedPosition); + + const key = deriveOAuthKey(authSecret, salt); + const decipher = crypto.createDecipheriv(oauthAlgorithm, key, iv); + decipher.setAuthTag(tag); + + return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8'); + } catch { + // Decryption failed - likely a plaintext token, return as-is + return encryptedText; + } +} diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 1002400b3..d7bc51d38 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -44,6 +44,8 @@ export { hashSecret, generateApiKey, verifySignature, + encryptOAuthToken, + decryptOAuthToken, } from "./crypto.js"; export { getDBConnectionString, diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index b1f9c720b..d781a9b8a 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -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'; @@ -19,6 +18,7 @@ import { onCreateUser } from '@/lib/authUtils'; import { getAuditService } from '@/ee/features/audit/factory'; import { SINGLE_TENANT_ORG_ID } from './lib/constants'; import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh'; +import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter'; const auditService = getAuditService(); const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : []; @@ -153,7 +153,7 @@ export const getProviders = () => { export const { handlers, signIn, signOut, auth } = NextAuth({ secret: env.AUTH_SECRET, - adapter: PrismaAdapter(prisma), + adapter: EncryptedPrismaAdapter(prisma), session: { strategy: "jwt", }, @@ -164,6 +164,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ // Explicitly update the Account record with the OAuth token details. // This is necessary to update the access token when the user // re-authenticates. + // NOTE: Tokens are encrypted before storage for security if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) { await prisma.account.update({ where: { @@ -172,14 +173,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ providerAccountId: account.providerAccountId, }, }, - data: { + data: encryptAccountData({ refresh_token: account.refresh_token, access_token: account.access_token, expires_at: account.expires_at, token_type: account.token_type, scope: account.scope, id_token: account.id_token, - } + }) }) } diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts index 3f0cb14a6..16d6b36a8 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -1,5 +1,5 @@ import { loadConfig } from "@sourcebot/shared"; -import { getTokenFromConfig, createLogger, env } from "@sourcebot/shared"; +import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared"; import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; import { LinkedAccountTokensMap } from "@/auth" const { prisma } = await import('@/prisma'); @@ -42,8 +42,8 @@ export async function refreshLinkedAccountTokens( } }, data: { - access_token: refreshedTokens.accessToken, - refresh_token: refreshedTokens.refreshToken, + access_token: encryptOAuthToken(refreshedTokens.accessToken, env.AUTH_SECRET), + refresh_token: encryptOAuthToken(refreshedTokens.refreshToken, env.AUTH_SECRET), expires_at: refreshedTokens.expiresAt, }, }); diff --git a/packages/web/src/lib/encryptedPrismaAdapter.ts b/packages/web/src/lib/encryptedPrismaAdapter.ts new file mode 100644 index 000000000..5e8590d09 --- /dev/null +++ b/packages/web/src/lib/encryptedPrismaAdapter.ts @@ -0,0 +1,47 @@ +import { PrismaAdapter } from "@auth/prisma-adapter"; +import type { Adapter, AdapterAccount } from "next-auth/adapters"; +import { PrismaClient } from "@sourcebot/db"; +import { encryptOAuthToken, env } from "@sourcebot/shared"; + +/** + * Encrypts OAuth tokens in account data before database storage + */ +function encryptAccountTokens(account: AdapterAccount): AdapterAccount { + return { + ...account, + access_token: encryptOAuthToken(account.access_token, env.AUTH_SECRET), + refresh_token: encryptOAuthToken(account.refresh_token, env.AUTH_SECRET), + id_token: encryptOAuthToken(account.id_token, env.AUTH_SECRET), + }; +} + +/** + * Encrypted Prisma adapter that automatically encrypts OAuth tokens before storage + */ +export function EncryptedPrismaAdapter(prisma: PrismaClient): Adapter { + const baseAdapter = PrismaAdapter(prisma); + + return { + ...baseAdapter, + async linkAccount(account: AdapterAccount) { + return baseAdapter.linkAccount!(encryptAccountTokens(account)); + }, + }; +} + +/** + * Encrypts OAuth tokens in account data (for manual account updates in signIn event) + */ +export function encryptAccountData(data: { + access_token?: string | null; + refresh_token?: string | null; + id_token?: string | null; + [key: string]: any; +}) { + return { + ...data, + access_token: encryptOAuthToken(data.access_token, env.AUTH_SECRET), + refresh_token: encryptOAuthToken(data.refresh_token, env.AUTH_SECRET), + id_token: encryptOAuthToken(data.id_token, env.AUTH_SECRET), + }; +} From 3d2db959469af2cbdc7b58bc477c64880a41d62d Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Feb 2026 20:36:16 -0800 Subject: [PATCH 2/5] feedback --- .../backend/src/ee/accountPermissionSyncer.ts | 2 +- packages/shared/src/crypto.ts | 87 ++++++++++++------- .../permissionSyncing/tokenRefresh.ts | 4 +- .../web/src/lib/encryptedPrismaAdapter.ts | 20 +++-- 4 files changed, 72 insertions(+), 41 deletions(-) diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index e7ddb00ba..1fef909d2 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -167,7 +167,7 @@ export class AccountPermissionSyncer { logger.info(`Syncing permissions for ${account.provider} account (id: ${account.id}) for user ${account.user.email}...`); // Decrypt tokens (stored encrypted in the database) - const accessToken = decryptOAuthToken(account.access_token, env.AUTH_SECRET); + 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 () => { diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index 379b8c3c2..4b381b6f4 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -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"; @@ -113,21 +114,30 @@ export const getTokenFromConfig = async (token: Token): Promise => { const oauthAlgorithm = 'aes-256-gcm'; const oauthIvLength = 16; const oauthSaltLength = 64; -const oauthTagLength = 16; -const oauthTagPosition = oauthSaltLength + oauthIvLength; -const oauthEncryptedPosition = oauthTagPosition + oauthTagLength; -const minEncryptedLength = 128; // Minimum base64-encoded length for encrypted tokens + +/** + * 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; function deriveOAuthKey(authSecret: string, salt: Buffer): Buffer { return crypto.pbkdf2Sync(authSecret, salt, 100000, 32, 'sha256'); } function isOAuthTokenEncrypted(token: string): boolean { - if (token.length < minEncryptedLength) return false; - try { - const decoded = Buffer.from(token, 'base64'); - return decoded.length >= (oauthSaltLength + oauthIvLength + oauthTagLength); + const decoded = Buffer.from(token, 'base64').toString('utf8'); + const parsed = JSON.parse(decoded); + return encryptedOAuthTokenSchema.safeParse(parsed).success; } catch { return false; } @@ -136,40 +146,59 @@ function isOAuthTokenEncrypted(token: string): boolean { /** * Encrypts OAuth token using AUTH_SECRET. Idempotent - returns token unchanged if already encrypted. */ -export function encryptOAuthToken(text: string | null | undefined, authSecret: string): string | null { - if (!text || !authSecret) return null; - if (isOAuthTokenEncrypted(text)) return text; - +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(authSecret, salt); - + 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(); - - return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); + + 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, authSecret: string): string | null { - if (!encryptedText || !authSecret) return null; - if (!isOAuthTokenEncrypted(encryptedText)) return encryptedText; - +export function decryptOAuthToken(encryptedText: string | null | undefined): string | undefined { + if (!encryptedText) { + return undefined; + } + + if (!isOAuthTokenEncrypted(encryptedText)) { + return encryptedText; + } + try { - const data = Buffer.from(encryptedText, 'base64'); - - const salt = data.subarray(0, oauthSaltLength); - const iv = data.subarray(oauthSaltLength, oauthTagPosition); - const tag = data.subarray(oauthTagPosition, oauthEncryptedPosition); - const encrypted = data.subarray(oauthEncryptedPosition); - - const key = deriveOAuthKey(authSecret, salt); + const 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 diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts index 16d6b36a8..3188aad2b 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -42,8 +42,8 @@ export async function refreshLinkedAccountTokens( } }, data: { - access_token: encryptOAuthToken(refreshedTokens.accessToken, env.AUTH_SECRET), - refresh_token: encryptOAuthToken(refreshedTokens.refreshToken, env.AUTH_SECRET), + access_token: encryptOAuthToken(refreshedTokens.accessToken), + refresh_token: encryptOAuthToken(refreshedTokens.refreshToken), expires_at: refreshedTokens.expiresAt, }, }); diff --git a/packages/web/src/lib/encryptedPrismaAdapter.ts b/packages/web/src/lib/encryptedPrismaAdapter.ts index 5e8590d09..5a357fb2f 100644 --- a/packages/web/src/lib/encryptedPrismaAdapter.ts +++ b/packages/web/src/lib/encryptedPrismaAdapter.ts @@ -1,7 +1,7 @@ import { PrismaAdapter } from "@auth/prisma-adapter"; import type { Adapter, AdapterAccount } from "next-auth/adapters"; import { PrismaClient } from "@sourcebot/db"; -import { encryptOAuthToken, env } from "@sourcebot/shared"; +import { encryptOAuthToken } from "@sourcebot/shared"; /** * Encrypts OAuth tokens in account data before database storage @@ -9,9 +9,9 @@ import { encryptOAuthToken, env } from "@sourcebot/shared"; function encryptAccountTokens(account: AdapterAccount): AdapterAccount { return { ...account, - access_token: encryptOAuthToken(account.access_token, env.AUTH_SECRET), - refresh_token: encryptOAuthToken(account.refresh_token, env.AUTH_SECRET), - id_token: encryptOAuthToken(account.id_token, env.AUTH_SECRET), + access_token: encryptOAuthToken(account.access_token), + refresh_token: encryptOAuthToken(account.refresh_token), + id_token: encryptOAuthToken(account.id_token), }; } @@ -23,7 +23,7 @@ export function EncryptedPrismaAdapter(prisma: PrismaClient): Adapter { return { ...baseAdapter, - async linkAccount(account: AdapterAccount) { + linkAccount(account: AdapterAccount) { return baseAdapter.linkAccount!(encryptAccountTokens(account)); }, }; @@ -36,12 +36,14 @@ export function encryptAccountData(data: { access_token?: string | null; refresh_token?: string | null; id_token?: string | null; - [key: string]: any; + expires_at?: number | null; + token_type?: string | null; + scope?: string | null; }) { return { ...data, - access_token: encryptOAuthToken(data.access_token, env.AUTH_SECRET), - refresh_token: encryptOAuthToken(data.refresh_token, env.AUTH_SECRET), - id_token: encryptOAuthToken(data.id_token, env.AUTH_SECRET), + access_token: encryptOAuthToken(data.access_token), + refresh_token: encryptOAuthToken(data.refresh_token), + id_token: encryptOAuthToken(data.id_token), }; } From c6de4f54f5144e88aa88cc406a4d40113a007275 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Feb 2026 20:36:52 -0800 Subject: [PATCH 3/5] JWE token decryption utility --- package.json | 3 ++- packages/web/package.json | 3 ++- packages/web/tools/decryptJWE.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 packages/web/tools/decryptJWE.ts diff --git a/package.json b/package.json index c6621f53e..3cd2d147a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/web/package.json b/packages/web/package.json index 1b198ba22..c40b1bdea 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/tools/decryptJWE.ts b/packages/web/tools/decryptJWE.ts new file mode 100644 index 000000000..a66f8e1d1 --- /dev/null +++ b/packages/web/tools/decryptJWE.ts @@ -0,0 +1,32 @@ +import { decode } from 'next-auth/jwt'; + +const secret = process.env.AUTH_SECRET; +const token = process.argv[2]; +// Salt is the cookie name used by next-auth (used in key derivation) +const salt = process.argv[3] || 'authjs.session-token'; + +if (!secret) { + console.error('AUTH_SECRET environment variable is required'); + process.exit(1); +} + +if (!token) { + console.error('Usage: yarn tool:decrypt-jwe [cookie-name]'); + console.error(' cookie-name defaults to "authjs.session-token"'); + console.error(' use "__Secure-authjs.session-token" for secure cookies (HTTPS)'); + process.exit(1); +} + +async function decryptJWE() { + const decoded = await decode({ + token, + secret: secret!, + salt, + }); + console.log(JSON.stringify(decoded, null, 2)); +} + +decryptJWE().catch(err => { + console.error('Failed to decrypt JWE:', err.message); + process.exit(1); +}); \ No newline at end of file From 7c432e584e3a8eacd69ff415ff15ebf11f387894 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Feb 2026 22:10:07 -0800 Subject: [PATCH 4/5] change token refresh handling to source provider info from DB instead of JWT token --- packages/web/src/auth.ts | 50 +++--------- .../permissionSyncing/tokenRefresh.ts | 81 ++++++++++--------- 2 files changed, 56 insertions(+), 75 deletions(-) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index d781a9b8a..c574e0b55 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -17,7 +17,7 @@ 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(); @@ -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; - declare module 'next-auth' { interface Session { user: { id: string; } & DefaultSession['user']; - linkedAccountProviderErrors?: Record; + linkedAccountProviderErrors?: LinkedAccountErrors; } } declare module 'next-auth/jwt' { interface JWT { userId: string; - linkedAccountTokens?: LinkedAccountTokensMap; + linkedAccountErrors?: LinkedAccountErrors; } } @@ -218,7 +209,7 @@ 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. @@ -226,20 +217,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ 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; @@ -253,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 = {}; - 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; }, }, diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts index 3188aad2b..aac991e5c 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -1,44 +1,64 @@ -import { loadConfig } from "@sourcebot/shared"; +import { loadConfig, decryptOAuthToken } from "@sourcebot/shared"; import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared"; import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; -import { LinkedAccountTokensMap } from "@/auth" const { prisma } = await import('@/prisma'); const logger = createLogger('web-ee-token-refresh'); -export async function refreshLinkedAccountTokens( - currentTokens: LinkedAccountTokensMap | undefined -): Promise { - if (!currentTokens) { - return {}; - } +// Map of providerAccountId -> error message +export type LinkedAccountErrors = Record; + +/** + * Refreshes expiring OAuth tokens for all linked accounts of a user. + * Loads accounts from database, refreshes tokens as needed, and returns any errors. + */ +export async function refreshLinkedAccountTokens(userId: string): Promise { + const accounts = await prisma.account.findMany({ + where: { + userId, + access_token: { not: null }, + refresh_token: { not: null }, + expires_at: { not: null }, + }, + select: { + provider: true, + providerAccountId: true, + access_token: true, + refresh_token: true, + expires_at: true, + }, + }); const now = Math.floor(Date.now() / 1000); const bufferTimeS = 5 * 60; // 5 minutes - - const updatedTokens: LinkedAccountTokensMap = { ...currentTokens }; + const errors: LinkedAccountErrors = {}; await Promise.all( - Object.entries(currentTokens).map(async ([providerAccountId, tokenData]) => { - const provider = tokenData.provider; + accounts.map(async (account) => { + const { provider, providerAccountId, expires_at } = account; + if (provider !== 'github' && provider !== 'gitlab') { return; } - if (tokenData.expiresAt && now >= (tokenData.expiresAt - bufferTimeS)) { + if (expires_at && now >= (expires_at - bufferTimeS)) { + const refreshToken = decryptOAuthToken(account.refresh_token); + if (!refreshToken) { + logger.error(`Failed to decrypt refresh token for providerAccountId: ${providerAccountId}`); + errors[providerAccountId] = 'RefreshTokenError'; + return; + } + try { - logger.info(`Refreshing token for providerAccountId: ${providerAccountId} (${tokenData.provider})`); - const refreshedTokens = await refreshOAuthToken( - provider, - tokenData.refreshToken - ); + logger.info(`Refreshing token for providerAccountId: ${providerAccountId} (${provider})`); + const refreshedTokens = await refreshOAuthToken(provider, refreshToken); if (refreshedTokens) { await prisma.account.update({ where: { provider_providerAccountId: { - provider: provider, - providerAccountId: providerAccountId + provider, + providerAccountId, } }, data: { @@ -47,33 +67,20 @@ export async function refreshLinkedAccountTokens( expires_at: refreshedTokens.expiresAt, }, }); - - updatedTokens[providerAccountId] = { - provider: tokenData.provider, - accessToken: refreshedTokens.accessToken, - refreshToken: refreshedTokens.refreshToken ?? tokenData.refreshToken, - expiresAt: refreshedTokens.expiresAt, - }; logger.info(`Successfully refreshed token for provider: ${provider}`); } else { logger.error(`Failed to refresh token for provider: ${provider}`); - updatedTokens[providerAccountId] = { - ...tokenData, - error: 'RefreshTokenError', - }; + errors[providerAccountId] = 'RefreshTokenError'; } } catch (error) { logger.error(`Error refreshing token for provider ${provider}:`, error); - updatedTokens[providerAccountId] = { - ...tokenData, - error: 'RefreshTokenError', - }; + errors[providerAccountId] = 'RefreshTokenError'; } } }) ); - return updatedTokens; + return errors; } export async function refreshOAuthToken( @@ -92,7 +99,7 @@ export async function refreshOAuthToken( // Loop through all provider configs and return on first successful fetch // - // The reason we have to do this is because 1) we might have multiple providers of the same type (ex. we're connecting to multiple gitlab instances) and 2) there isn't + // The reason we have to do this is because 1) we might have multiple providers of the same type (ex. we're connecting to multiple gitlab instances) and 2) there isn't // a trivial way to map a provider config to the associated Account object in the DB. The reason the config is involved at all here is because we need the client // id/secret in order to refresh the token, and that info is in the config. We could in theory bypass this by storing the client id/secret for the provider in the // Account table but we decided not to do that since these are secret. Instead, we simply try all of the client/id secrets for the associated provider type. This is safe From e7fd7bb38c1ee85f294f93d218499d8453567666 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 4 Feb 2026 22:25:55 -0800 Subject: [PATCH 5/5] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d6c3494..ee731a18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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