Skip to content

Commit 3d2db95

Browse files
feedback
1 parent 09b06c1 commit 3d2db95

File tree

4 files changed

+72
-41
lines changed

4 files changed

+72
-41
lines changed

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export class AccountPermissionSyncer {
167167
logger.info(`Syncing permissions for ${account.provider} account (id: ${account.id}) for user ${account.user.email}...`);
168168

169169
// Decrypt tokens (stored encrypted in the database)
170-
const accessToken = decryptOAuthToken(account.access_token, env.AUTH_SECRET);
170+
const accessToken = decryptOAuthToken(account.access_token);
171171

172172
// Get a list of all repos that the user has access to from all connected accounts.
173173
const repoIds = await (async () => {

packages/shared/src/crypto.ts

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import crypto from 'crypto';
22
import fs from 'fs';
3+
import { z } from 'zod';
34
import { env } from './env.server.js';
45
import { Token } from '@sourcebot/schemas/v3/shared.type';
56
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
@@ -113,21 +114,30 @@ export const getTokenFromConfig = async (token: Token): Promise<string> => {
113114
const oauthAlgorithm = 'aes-256-gcm';
114115
const oauthIvLength = 16;
115116
const oauthSaltLength = 64;
116-
const oauthTagLength = 16;
117-
const oauthTagPosition = oauthSaltLength + oauthIvLength;
118-
const oauthEncryptedPosition = oauthTagPosition + oauthTagLength;
119-
const minEncryptedLength = 128; // Minimum base64-encoded length for encrypted tokens
117+
118+
/**
119+
* Schema for encrypted OAuth token structure.
120+
* Stored as base64-encoded JSON in the database.
121+
*/
122+
const encryptedOAuthTokenSchema = z.object({
123+
v: z.literal(1), // Version for future format changes
124+
salt: z.string(), // hex-encoded salt for key derivation
125+
iv: z.string(), // hex-encoded initialization vector
126+
tag: z.string(), // hex-encoded auth tag
127+
data: z.string(), // hex-encoded encrypted data
128+
});
129+
130+
type EncryptedOAuthToken = z.infer<typeof encryptedOAuthTokenSchema>;
120131

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

125136
function isOAuthTokenEncrypted(token: string): boolean {
126-
if (token.length < minEncryptedLength) return false;
127-
128137
try {
129-
const decoded = Buffer.from(token, 'base64');
130-
return decoded.length >= (oauthSaltLength + oauthIvLength + oauthTagLength);
138+
const decoded = Buffer.from(token, 'base64').toString('utf8');
139+
const parsed = JSON.parse(decoded);
140+
return encryptedOAuthTokenSchema.safeParse(parsed).success;
131141
} catch {
132142
return false;
133143
}
@@ -136,40 +146,59 @@ function isOAuthTokenEncrypted(token: string): boolean {
136146
/**
137147
* Encrypts OAuth token using AUTH_SECRET. Idempotent - returns token unchanged if already encrypted.
138148
*/
139-
export function encryptOAuthToken(text: string | null | undefined, authSecret: string): string | null {
140-
if (!text || !authSecret) return null;
141-
if (isOAuthTokenEncrypted(text)) return text;
142-
149+
export function encryptOAuthToken(text: string | null | undefined): string | undefined {
150+
if (!text) {
151+
return undefined;
152+
}
153+
154+
if (isOAuthTokenEncrypted(text)) {
155+
return text;
156+
}
157+
143158
const iv = crypto.randomBytes(oauthIvLength);
144159
const salt = crypto.randomBytes(oauthSaltLength);
145-
const key = deriveOAuthKey(authSecret, salt);
146-
160+
const key = deriveOAuthKey(env.AUTH_SECRET, salt);
161+
147162
const cipher = crypto.createCipheriv(oauthAlgorithm, key, iv);
148163
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
149164
const tag = cipher.getAuthTag();
150-
151-
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
165+
166+
const tokenData: EncryptedOAuthToken = {
167+
v: 1,
168+
salt: salt.toString('hex'),
169+
iv: iv.toString('hex'),
170+
tag: tag.toString('hex'),
171+
data: encrypted.toString('hex'),
172+
};
173+
174+
return Buffer.from(JSON.stringify(tokenData)).toString('base64');
152175
}
153176

154177
/**
155178
* Decrypts OAuth token using AUTH_SECRET. Returns plaintext tokens unchanged during migration.
156179
*/
157-
export function decryptOAuthToken(encryptedText: string | null | undefined, authSecret: string): string | null {
158-
if (!encryptedText || !authSecret) return null;
159-
if (!isOAuthTokenEncrypted(encryptedText)) return encryptedText;
160-
180+
export function decryptOAuthToken(encryptedText: string | null | undefined): string | undefined {
181+
if (!encryptedText) {
182+
return undefined;
183+
}
184+
185+
if (!isOAuthTokenEncrypted(encryptedText)) {
186+
return encryptedText;
187+
}
188+
161189
try {
162-
const data = Buffer.from(encryptedText, 'base64');
163-
164-
const salt = data.subarray(0, oauthSaltLength);
165-
const iv = data.subarray(oauthSaltLength, oauthTagPosition);
166-
const tag = data.subarray(oauthTagPosition, oauthEncryptedPosition);
167-
const encrypted = data.subarray(oauthEncryptedPosition);
168-
169-
const key = deriveOAuthKey(authSecret, salt);
190+
const decoded = Buffer.from(encryptedText, 'base64').toString('utf8');
191+
const tokenData = encryptedOAuthTokenSchema.parse(JSON.parse(decoded));
192+
193+
const salt = Buffer.from(tokenData.salt, 'hex');
194+
const iv = Buffer.from(tokenData.iv, 'hex');
195+
const tag = Buffer.from(tokenData.tag, 'hex');
196+
const encrypted = Buffer.from(tokenData.data, 'hex');
197+
198+
const key = deriveOAuthKey(env.AUTH_SECRET, salt);
170199
const decipher = crypto.createDecipheriv(oauthAlgorithm, key, iv);
171200
decipher.setAuthTag(tag);
172-
201+
173202
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
174203
} catch {
175204
// Decryption failed - likely a plaintext token, return as-is

packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export async function refreshLinkedAccountTokens(
4242
}
4343
},
4444
data: {
45-
access_token: encryptOAuthToken(refreshedTokens.accessToken, env.AUTH_SECRET),
46-
refresh_token: encryptOAuthToken(refreshedTokens.refreshToken, env.AUTH_SECRET),
45+
access_token: encryptOAuthToken(refreshedTokens.accessToken),
46+
refresh_token: encryptOAuthToken(refreshedTokens.refreshToken),
4747
expires_at: refreshedTokens.expiresAt,
4848
},
4949
});
Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { PrismaAdapter } from "@auth/prisma-adapter";
22
import type { Adapter, AdapterAccount } from "next-auth/adapters";
33
import { PrismaClient } from "@sourcebot/db";
4-
import { encryptOAuthToken, env } from "@sourcebot/shared";
4+
import { encryptOAuthToken } from "@sourcebot/shared";
55

66
/**
77
* Encrypts OAuth tokens in account data before database storage
88
*/
99
function encryptAccountTokens(account: AdapterAccount): AdapterAccount {
1010
return {
1111
...account,
12-
access_token: encryptOAuthToken(account.access_token, env.AUTH_SECRET),
13-
refresh_token: encryptOAuthToken(account.refresh_token, env.AUTH_SECRET),
14-
id_token: encryptOAuthToken(account.id_token, env.AUTH_SECRET),
12+
access_token: encryptOAuthToken(account.access_token),
13+
refresh_token: encryptOAuthToken(account.refresh_token),
14+
id_token: encryptOAuthToken(account.id_token),
1515
};
1616
}
1717

@@ -23,7 +23,7 @@ export function EncryptedPrismaAdapter(prisma: PrismaClient): Adapter {
2323

2424
return {
2525
...baseAdapter,
26-
async linkAccount(account: AdapterAccount) {
26+
linkAccount(account: AdapterAccount) {
2727
return baseAdapter.linkAccount!(encryptAccountTokens(account));
2828
},
2929
};
@@ -36,12 +36,14 @@ export function encryptAccountData(data: {
3636
access_token?: string | null;
3737
refresh_token?: string | null;
3838
id_token?: string | null;
39-
[key: string]: any;
39+
expires_at?: number | null;
40+
token_type?: string | null;
41+
scope?: string | null;
4042
}) {
4143
return {
4244
...data,
43-
access_token: encryptOAuthToken(data.access_token, env.AUTH_SECRET),
44-
refresh_token: encryptOAuthToken(data.refresh_token, env.AUTH_SECRET),
45-
id_token: encryptOAuthToken(data.id_token, env.AUTH_SECRET),
45+
access_token: encryptOAuthToken(data.access_token),
46+
refresh_token: encryptOAuthToken(data.refresh_token),
47+
id_token: encryptOAuthToken(data.id_token),
4648
};
4749
}

0 commit comments

Comments
 (0)