/** * Account Encryption Middleware * * Prisma middleware that transparently encrypts/decrypts OAuth tokens * in the Account table using AES-256-GCM encryption. * * Encryption happens on: * - create: New account records * - update/updateMany: Token updates * - upsert: Both create and update data * * Decryption happens on: * - findUnique/findMany/findFirst: Read operations * * Format detection: * - encryptionVersion field is the primary discriminator * - `aes` = AES-256-GCM encrypted * - `vault` = OpenBao Transit encrypted (future, Phase 2) * - null/undefined = Legacy plaintext (backward compatible) */ import { Logger } from "@nestjs/common"; import type { PrismaClient } from "@prisma/client"; import type { VaultService } from "../vault/vault.service"; import { TransitKey } from "../vault/vault.constants"; /** * Token fields to encrypt/decrypt in Account model */ const TOKEN_FIELDS = ["accessToken", "refreshToken", "idToken"] as const; /** * Prisma middleware parameters interface */ interface MiddlewareParams { model?: string; action: string; args: { data?: Record; where?: Record; select?: Record; create?: Record; update?: Record; }; dataPath: string[]; runInTransaction: boolean; } /** * Account data with token fields */ interface AccountData extends Record { accessToken?: string | null; refreshToken?: string | null; idToken?: string | null; encryptionVersion?: string | null; } /** * Register account encryption middleware on Prisma client * * @param prisma - Prisma client instance * @param vaultService - Vault service for encryption/decryption */ export function registerAccountEncryptionMiddleware( prisma: PrismaClient, vaultService: VaultService ): void { const logger = new Logger("AccountEncryptionMiddleware"); // TODO: Replace with Prisma Client Extensions (https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions) // when stable. Client extensions provide a type-safe alternative to middleware without requiring // type assertions or eslint-disable directives. Migration path: // 1. Wait for Prisma 6.x stable release with full extension support // 2. Create extension using prisma.$extends({ query: { account: { ... } } }) // 3. Remove this middleware and eslint-disable comments // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access (prisma as any).$use( async (params: MiddlewareParams, next: (params: MiddlewareParams) => Promise) => { // Only process Account model operations if (params.model !== "Account") { return next(params); } // Encrypt on write operations if ( params.action === "create" || params.action === "update" || params.action === "updateMany" ) { if (params.args.data) { await encryptTokens(params.args.data as AccountData, vaultService); } } else if (params.action === "upsert") { // Handle upsert - encrypt both create and update data if (params.args.create) { await encryptTokens(params.args.create as AccountData, vaultService); } if (params.args.update) { await encryptTokens(params.args.update as AccountData, vaultService); } } // Execute query const result = await next(params); // Decrypt on read operations if (params.action === "findUnique" || params.action === "findFirst") { if (result && typeof result === "object") { await decryptTokens(result as AccountData, vaultService, logger); } } else if (params.action === "findMany") { if (Array.isArray(result)) { for (const account of result) { if (account && typeof account === "object") { await decryptTokens(account as AccountData, vaultService, logger); } } } } return result; } ); } /** * Encrypt token fields in account data * Modifies data in-place * * @param data - Account data object * @param vaultService - Vault service */ async function encryptTokens(data: AccountData, vaultService: VaultService): Promise { let encrypted = false; let encryptionVersion: "aes" | "vault" | null = null; for (const field of TOKEN_FIELDS) { const value = data[field]; // Skip null/undefined values if (value == null) { continue; } // Skip if already encrypted (idempotent) if (typeof value === "string" && isEncrypted(value)) { continue; } // Encrypt plaintext value if (typeof value === "string") { const ciphertext = await vaultService.encrypt(value, TransitKey.ACCOUNT_TOKENS); data[field] = ciphertext; encrypted = true; // Determine encryption version from ciphertext format if (ciphertext.startsWith("vault:v1:")) { encryptionVersion = "vault"; } else { encryptionVersion = "aes"; } } } // Mark encryption version if any tokens were encrypted if (encrypted && encryptionVersion) { data.encryptionVersion = encryptionVersion; } } /** * Decrypt token fields in account record * Modifies record in-place * * Uses encryptionVersion field as primary discriminator to determine * if decryption is needed, falling back to pattern matching for * records without the field (migration compatibility). * * Throws errors on decryption failure to prevent silent corruption. * * @param account - Account record * @param vaultService - Vault service * @param _logger - NestJS logger (unused, kept for compatibility with middleware signature) * @throws Error with user-facing message when decryption fails */ async function decryptTokens( account: AccountData, vaultService: VaultService, _logger: Logger ): Promise { // Check encryptionVersion field first (primary discriminator) const shouldDecrypt = account.encryptionVersion === "aes" || account.encryptionVersion === "vault"; for (const field of TOKEN_FIELDS) { const value = account[field]; // Skip null/undefined values if (value == null) { continue; } if (typeof value === "string") { // Primary path: Use encryptionVersion field if (shouldDecrypt) { try { account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS); } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error"; throw new Error( `Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}` ); } } // Fallback: For records without encryptionVersion (migration compatibility) else if (!account.encryptionVersion && isEncrypted(value)) { try { account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS); } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error"; throw new Error( `Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}` ); } } // Legacy plaintext (no encryptionVersion) - pass through unchanged } } } /** * Check if a value is encrypted (any format) * * @param value - String value to check * @returns true if value appears to be encrypted */ function isEncrypted(value: string): boolean { if (!value || typeof value !== "string") { return false; } // AES format: iv:authTag:encrypted (3 colon-separated hex parts) if (isAESEncrypted(value)) { return true; } // Vault format: vault:v1:... if (value.startsWith("vault:v1:")) { return true; } return false; } /** * Check if a value is AES-256-GCM encrypted * * @param value - String value to check * @returns true if value is in AES format */ function isAESEncrypted(value: string): boolean { if (!value || typeof value !== "string") { return false; } // AES format: iv:authTag:encrypted (3 parts, all hex) const parts = value.split(":"); if (parts.length !== 3) { return false; } // Verify all parts are hex strings return parts.every((part) => /^[0-9a-f]+$/i.test(part)); }