import { createHmac, randomBytes } from "node:crypto"; import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { eq } from "drizzle-orm"; import { DATABASE, type Database } from "../database/database.provider"; import { sessions, users } from "../database/schema/core"; const SESSION_COOKIE_NAME_INSECURE = "better-auth.session_token"; const SESSION_COOKIE_NAME_SECURE = "__Secure-better-auth.session_token"; const DEFAULT_TTL_MINUTES = 15; const MAX_TTL_MINUTES = 60; @Injectable() export class ImpersonationService { private readonly logger = new Logger(ImpersonationService.name); constructor( @Inject(DATABASE) private db: Database, private configService: ConfigService, ) {} async createReadonlySession(params: { targetUserId: string; founderId: string; ttlMinutes: number; reason: string; ipAddress?: string; userAgent?: string; }): Promise<{ cookieName: string; cookieValue: string; expiresAt: Date; sessionId: string; }> { const ttl = Math.min(Math.max(params.ttlMinutes || DEFAULT_TTL_MINUTES, 1), MAX_TTL_MINUTES); // Verify target exists (and isn't an admin — defense in depth) const [target] = await this.db .select({ id: users.id, role: users.role }) .from(users) .where(eq(users.id, params.targetUserId)) .limit(1); if (!target) throw new NotFoundException("Kullanıcı bulunamadı"); if (target.role === "admin") { throw new NotFoundException("Admin kullanıcılar impersonate edilemez"); } const secret = this.configService.get("auth.secret"); if (!secret) throw new Error("BETTER_AUTH_SECRET not configured"); // Better Auth session shape: id is a text random id, token is text random. // Both must be unique. Use 32 random bytes hex for plenty of entropy. const sessionId = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex"); const now = new Date(); const expiresAt = new Date(now.getTime() + ttl * 60_000); await this.db.insert(sessions).values({ id: sessionId, userId: params.targetUserId, token, expiresAt, ipAddress: params.ipAddress ?? null, userAgent: params.userAgent ? `${params.userAgent} (impersonated:readonly)` : "impersonated:readonly", impersonatedBy: params.founderId, impersonationReadonly: true, }); // Cookie format matches better-call's signCookieValue: // urlEncode(`${token}.${standardBase64(HMAC_SHA256(token, secret))}`) const signature = createHmac("sha256", secret).update(token).digest("base64"); const signedValue = `${token}.${signature}`; const cookieValue = encodeURIComponent(signedValue); const isHttps = (this.configService.get("auth.url") ?? "").startsWith("https://"); const cookieName = isHttps ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_INSECURE; this.logger.log( `Impersonation session created: founder=${params.founderId} target=${params.targetUserId} ttl=${ttl}m reason="${params.reason.slice(0, 80)}" sessionId=${sessionId.slice(0, 8)}…`, ); return { cookieName, cookieValue, expiresAt, sessionId }; } }