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
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:*,http://*.localhost:*,https://bina
GOOGLE_CLIENT_ID=your_client_id_here
GOOGLE_CLIENT_SECRET=your_client_secret_here

# Field-level encryption for OAuth tokens at rest (AES-256-GCM).
# Generate with: openssl rand -hex 32
FIELD_ENCRYPTION_KEY=your_64_char_hex_key_here

# Email (Resend)
RESEND_API_KEY=re_your_api_key_here
RESEND_FROM_EMAIL=onboarding@resend.dev
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { parseNodeEnvironment } from './lib/types/environment.type';
import { TenantModule } from './modules/tenant/tenant.module';
import { UserProfileModule } from './modules/user-profile/user-profile.module';
import { GlobalCrudModule } from './modules/global-crud/global-crud.module';
import { GdprModule } from './modules/gdpr/gdpr.module';

@Module({
imports: [
Expand Down Expand Up @@ -70,6 +71,7 @@ import { GlobalCrudModule } from './modules/global-crud/global-crud.module';
CrudModule,
GlobalCrudModule,
EmailModule,
GdprModule,
],
controllers: [],
providers: [AppContext],
Expand Down
81 changes: 81 additions & 0 deletions apps/api/src/lib/field-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { Logger } from '@repo/utils-core';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const PREFIX = 'enc:';

let cachedKey: Buffer | null = null;

function getKey(): Buffer | null {
if (cachedKey) return cachedKey;

const hex = process.env.FIELD_ENCRYPTION_KEY;
if (!hex) return null;

if (hex.length !== 64) {
Logger.instance
.withContext('FieldEncryption')
.critical(
'FIELD_ENCRYPTION_KEY must be a 64-char hex string (32 bytes). Encryption disabled.',
);
return null;
}

cachedKey = Buffer.from(hex, 'hex');
return cachedKey;
}

export function encryptField(plaintext: string): string {
const key = getKey();
if (!key) return plaintext;

const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();

return `${PREFIX}${iv.toString('base64')}.${authTag.toString('base64')}.${encrypted.toString('base64')}`;
}

/**
* Decrypts a field value. If the value wasn't encrypted (no prefix),
* returns it as-is — this provides a smooth migration path for
* pre-existing plaintext data.
*/
export function decryptField(ciphertext: string): string {
if (!ciphertext.startsWith(PREFIX)) return ciphertext;

const key = getKey();
if (!key) return ciphertext;

try {
const payload = ciphertext.slice(PREFIX.length);
const [ivB64, tagB64, dataB64] = payload.split('.');
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(tagB64, 'base64');
const encrypted = Buffer.from(dataB64, 'base64');

const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
decipher.setAuthTag(authTag);

const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch {
Logger.instance
.withContext('FieldEncryption')
.critical('Failed to decrypt field — returning raw value');
return ciphertext;
}
}
19 changes: 17 additions & 2 deletions apps/api/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { BetterAuthLogger } from '../logger/logger-better-auth';
import { AuthService } from './auth.service';
import { AuthMiddleware } from './auth.middleware';
import { createBetterAuth } from './auth';
import { UserPrismaRepository } from './repositories/prisma/user.prisma-repository';
import { AccountPrismaRepository } from './repositories/prisma/account.prisma-repository';
import { SessionPrismaRepository } from './repositories/prisma/session.prisma-repository';

@Module({
imports: [
Expand All @@ -29,7 +32,19 @@ import { createBetterAuth } from './auth';
}),
EmailModule,
],
providers: [AuthService, AuthMiddleware],
exports: [AuthService, AuthMiddleware],
providers: [
AuthService,
AuthMiddleware,
UserPrismaRepository,
AccountPrismaRepository,
SessionPrismaRepository,
],
exports: [
AuthService,
AuthMiddleware,
UserPrismaRepository,
AccountPrismaRepository,
SessionPrismaRepository,
],
})
export class AuthModule {}
5 changes: 5 additions & 0 deletions apps/api/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export class AuthService {
return session?.session ?? null;
}

async deleteUserForContext(ctx: AppContextType): Promise<void> {
const headers = fromNodeHeaders(ctx.req.headers);
await this.auth.api.deleteUser({ headers, body: {} });
}

async getAccessTokenForContext(
provider: 'google',
ctx: AppContextType,
Expand Down
171 changes: 170 additions & 1 deletion apps/api/src/modules/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,107 @@ import {
NodeEnvironment,
parseNodeEnvironment,
} from '../../lib/types/environment.type';
import { Logger } from '@repo/utils-core';
import { encryptField, decryptField } from '../../lib/field-encryption';

const TOKEN_FIELDS = ['accessToken', 'refreshToken', 'idToken'] as const;

function encryptTokenFields(data: Record<string, unknown>): void {
for (const field of TOKEN_FIELDS) {
if (typeof data[field] === 'string') {
data[field] = encryptField(data[field]);
}
}
}

function decryptTokenFields(record: Record<string, unknown>): void {
for (const field of TOKEN_FIELDS) {
if (typeof record[field] === 'string') {
record[field] = decryptField(record[field]);
}
}
}

function decryptResult(result: unknown): void {
if (Array.isArray(result)) {
for (const r of result) decryptTokenFields(r as Record<string, unknown>);
} else if (result && typeof result === 'object') {
decryptTokenFields(result as Record<string, unknown>);
}
}

const basePrisma = new PrismaClient();

/**
* Wraps a Prisma model delegate so that token fields on the Account
* model are encrypted before writes and decrypted after reads.
* Uses a JS Proxy so it works regardless of how the caller accesses
* methods (typed or dynamic string indexing).
*/
function wrapAccountDelegate(delegate: Record<string, unknown>): unknown {
const WRITE_METHODS = new Set(['create', 'update', 'upsert']);
const READ_METHODS = new Set([
'findUnique',
'findFirst',
'findMany',
'create',
'update',
'upsert',
]);

return new Proxy(delegate, {
get(target, prop) {
const original = target[prop as string];
if (typeof original !== 'function') return original;

const method = String(prop);

if (WRITE_METHODS.has(method) || READ_METHODS.has(method)) {
return async (...args: unknown[]) => {
const params = args[0] as Record<string, unknown> | undefined;

if (WRITE_METHODS.has(method) && params) {
if (method === 'upsert') {
if (params.create)
encryptTokenFields(params.create as Record<string, unknown>);
if (params.update)
encryptTokenFields(params.update as Record<string, unknown>);
} else if (params.data) {
encryptTokenFields(params.data as Record<string, unknown>);
}
}

const result: unknown = await original.apply(target, args);

if (READ_METHODS.has(method)) {
decryptResult(result);
}

return result;
};
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return original.bind(target);
},
});
}

const prisma = new Proxy(basePrisma, {
get(target, prop) {
const value = (target as unknown as Record<string | symbol, unknown>)[prop];
if (prop === 'account') {
return wrapAccountDelegate(value as Record<string, unknown>);
}
if (typeof value === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value.bind(target);
}
return value;
},
}) as unknown as PrismaClient;

const createDatabaseAdapter = () => {
const prisma = new PrismaClient();
return prismaAdapter(prisma, {
provider: 'postgresql',
});
Expand All @@ -30,6 +128,77 @@ export const createBetterAuth = (
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [],
basePath: '/api/auth',
database: createDatabaseAdapter(),
databaseHooks: {
user: {
create: {
after: async (user, ctx) => {
const ipAddress =
ctx?.headers?.get?.('x-forwarded-for')?.split(',')[0]?.trim() ??
ctx?.headers?.get?.('x-real-ip') ??
null;

try {
await basePrisma.user.update({
where: { id: user.id },
data: {
consentGiven: true,
consentAt: new Date(),
consentIp: ipAddress,
},
});
await basePrisma.gdprAuditLog.create({
data: {
userId: user.id,
action: 'CONSENT_GIVEN',
details: 'Consent recorded at account creation',
ipAddress,
},
});
} catch (err) {
Logger.instance
.withContext('Auth')
.critical('Failed to record GDPR consent', err);
}
},
},
},
},
user: {
deleteUser: {
enabled: true,
beforeDelete: async (user, request) => {
const email = user.email?.trim().toLowerCase();
const ipAddress =
request?.headers?.get?.('x-forwarded-for')?.split(',')[0]?.trim() ??
request?.headers?.get?.('x-real-ip') ??
null;

try {
await basePrisma.gdprAuditLog.create({
data: {
userId: user.id,
action: 'DATA_DELETION',
details: 'Account deletion requested and executed',
ipAddress,
},
});

if (email) {
await basePrisma.tenantMembership.deleteMany({
where: { email },
});
await basePrisma.verification.deleteMany({
where: { identifier: email },
});
}
} catch (err) {
Logger.instance
.withContext('Auth')
.critical('Failed to execute pre-deletion cleanup', err);
}
},
},
},
emailAndPassword: {
enabled: true,
requireEmailVerification: !isDevelopment,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Prisma } from '@repo/prisma-db';

export interface IAccountPrismaRepository {
findMany(
args?: Prisma.AccountFindManyArgs,
): Promise<Prisma.AccountGetPayload<Prisma.AccountFindManyArgs>[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import {
TransactionHost,
InjectTransactionHost,
} from '@nestjs-cls/transactional';
import { Prisma } from '@repo/prisma-db';
import { IAccountPrismaRepository } from './account.prisma-repository.interface';
import { PrismaTransactionAdapter } from '../../../prisma/prisma.module';
import { ServerConstants } from '../../../../constants/server.constants';

@Injectable()
export class AccountPrismaRepository implements IAccountPrismaRepository {
constructor(
@InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma)
protected readonly prismaTxHost: TransactionHost<PrismaTransactionAdapter>,
) {}

protected get delegate(): Prisma.AccountDelegate {
return this.prismaTxHost.tx.account;
}

findMany(
args?: Prisma.AccountFindManyArgs,
): Promise<Prisma.AccountGetPayload<Prisma.AccountFindManyArgs>[]> {
return this.delegate.findMany(args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Prisma } from '@repo/prisma-db';

export interface ISessionPrismaRepository {
findMany(
args?: Prisma.SessionFindManyArgs,
): Promise<Prisma.SessionGetPayload<Prisma.SessionFindManyArgs>[]>;
}
Loading