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 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/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 78209adea..1fef909d2 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"; @@ -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 = 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.`); } @@ -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) { @@ -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.`); } @@ -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, }); diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index 7a88e5ab2..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"; @@ -104,4 +105,103 @@ 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; + +/** + * 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 { + 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; + } +} 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/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/src/auth.ts b/packages/web/src/auth.ts index b1f9c720b..c574e0b55 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'; @@ -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() : []; @@ -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; } } @@ -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", }, @@ -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: { @@ -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, - } + }) }) } @@ -217,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. @@ -225,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; @@ -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 = {}; - 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 3f0cb14a6..aac991e5c 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -1,79 +1,86 @@ -import { loadConfig } from "@sourcebot/shared"; -import { getTokenFromConfig, createLogger, env } 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: { - access_token: refreshedTokens.accessToken, - refresh_token: refreshedTokens.refreshToken, + access_token: encryptOAuthToken(refreshedTokens.accessToken), + refresh_token: encryptOAuthToken(refreshedTokens.refreshToken), 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 diff --git a/packages/web/src/lib/encryptedPrismaAdapter.ts b/packages/web/src/lib/encryptedPrismaAdapter.ts new file mode 100644 index 000000000..5a357fb2f --- /dev/null +++ b/packages/web/src/lib/encryptedPrismaAdapter.ts @@ -0,0 +1,49 @@ +import { PrismaAdapter } from "@auth/prisma-adapter"; +import type { Adapter, AdapterAccount } from "next-auth/adapters"; +import { PrismaClient } from "@sourcebot/db"; +import { encryptOAuthToken } 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), + refresh_token: encryptOAuthToken(account.refresh_token), + id_token: encryptOAuthToken(account.id_token), + }; +} + +/** + * Encrypted Prisma adapter that automatically encrypts OAuth tokens before storage + */ +export function EncryptedPrismaAdapter(prisma: PrismaClient): Adapter { + const baseAdapter = PrismaAdapter(prisma); + + return { + ...baseAdapter, + 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; + expires_at?: number | null; + token_type?: string | null; + scope?: string | null; +}) { + return { + ...data, + access_token: encryptOAuthToken(data.access_token), + refresh_token: encryptOAuthToken(data.refresh_token), + id_token: encryptOAuthToken(data.id_token), + }; +} 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