import { createHmac, timingSafeEqual } from "node:crypto"; import { Body, Controller, Get, HttpCode, HttpStatus, Logger, Param, Post, Query, Req, Res, UnauthorizedException, UseGuards, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { Request, Response } from "express"; import { Public } from "../common/decorators/public.decorator"; import { InternalTokenGuard } from "../common/guards/internal-token.guard"; import { ImpersonationService } from "./impersonation.service"; const CONSUME_TOKEN_TTL_SECONDS = 60; type ConsumePayload = { cookieName: string; cookieValue: string; redirect: string; expiresAtMs: number; notBeforeMs: number; }; @Controller() export class ImpersonationController { private readonly logger = new Logger(ImpersonationController.name); constructor( private impersonation: ImpersonationService, private configService: ConfigService, ) {} // Internal: panel → spoke. Returns a short-lived "consume URL" the panel // opens in a new tab. Hand-off via a signed payload avoids cross-site // Set-Cookie restrictions (panel domain ≠ sase.tr domain). @Post("internal/admin/users/:id/impersonate-readonly") @Public() @UseGuards(InternalTokenGuard) @HttpCode(HttpStatus.OK) async impersonateReadonly( @Param("id") userId: string, @Body() body: { ttlMinutes?: number; reason?: string; founderId?: string }, @Req() req: Request, ) { if (!body.founderId) { throw new UnauthorizedException("founderId required"); } const reason = (body.reason ?? "").slice(0, 500); if (!reason) { throw new UnauthorizedException("reason required"); } const { cookieName, cookieValue, expiresAt, sessionId } = await this.impersonation.createReadonlySession({ targetUserId: userId, founderId: body.founderId, ttlMinutes: body.ttlMinutes ?? 15, reason, ipAddress: this.getIp(req), userAgent: typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined, }); const baseUrl = this.configService.get("auth.url") ?? ""; const payload: ConsumePayload = { cookieName, cookieValue, redirect: "/", expiresAtMs: expiresAt.getTime(), notBeforeMs: Date.now() - 1000, }; const consumeToken = this.signConsumePayload(payload); const redirectUrl = `${baseUrl}/api/admin/impersonate/consume?t=${encodeURIComponent(consumeToken)}`; return { success: true, redirectUrl, expiresAt: expiresAt.toISOString(), sessionIdPrefix: sessionId.slice(0, 8), }; } // Public: founder's browser GET. Verifies the signed payload, sets the // Better Auth session cookie, redirects to the spoke. One-shot, time-boxed. @Get("admin/impersonate/consume") @Public() async consume(@Query("t") token: string, @Res() res: Response) { if (!token) { res.status(HttpStatus.BAD_REQUEST).send("missing token"); return; } const payload = this.verifyConsumePayload(token); if (!payload) { res.status(HttpStatus.UNAUTHORIZED).send("invalid or expired consume token"); return; } const now = Date.now(); if (now < payload.notBeforeMs || now > payload.expiresAtMs) { res.status(HttpStatus.UNAUTHORIZED).send("consume token outside validity window"); return; } const isHttps = (this.configService.get("auth.url") ?? "").startsWith("https://"); res.cookie(payload.cookieName, payload.cookieValue, { httpOnly: true, secure: isHttps, sameSite: "lax", // Path scope: site-wide path: "/", // maxAge in ms, browser will expire alongside server-side session row maxAge: payload.expiresAtMs - now, }); res.redirect(payload.redirect || "/"); } // Sign a JSON payload with HMAC-SHA256(secret) and embed: base64(json).base64(sig) private signConsumePayload(payload: ConsumePayload): string { const secret = this.consumeSecret(); const json = JSON.stringify(payload); const body = Buffer.from(json, "utf8").toString("base64url"); const sig = createHmac("sha256", secret).update(body).digest("base64url"); return `${body}.${sig}`; } private verifyConsumePayload(token: string): ConsumePayload | null { const [body, sig] = token.split("."); if (!body || !sig) return null; const secret = this.consumeSecret(); const expected = createHmac("sha256", secret).update(body).digest("base64url"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { return JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { return null; } } private consumeSecret(): string { const secret = process.env.INTERNAL_IMPERSONATION_SECRET ?? this.configService.get("auth.secret"); if (!secret) throw new Error("INTERNAL_IMPERSONATION_SECRET or auth.secret required"); return secret; } private getIp(req: Request): string | undefined { const xff = req.headers["x-forwarded-for"]; if (typeof xff === "string") return xff.split(",")[0]?.trim(); if (req.socket?.remoteAddress) return req.socket.remoteAddress; return undefined; } }