import { randomUUID } from 'node:crypto'; import { ForbiddenException, Inject, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import type { FastifyReply } from 'fastify'; import { generateSecureToken, hashToken } from '@hexahost/auth'; import type { SsoConsumeResponse, WhmcsCreateSsoRequest, WhmcsCreateSsoResponse, } from '@hexahost/contracts'; import type { AppConfig } from '@hexahost/config'; import { APP_CONFIG } from '../config/config.constants'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from './audit.service'; import { resolveSsoTargetPath } from './sso-target.util'; import { SessionService } from './session.service'; const SSO_TICKET_TTL_MS = 60_000; type InstallationRef = { id: string; integrationId: string; }; @Injectable() export class SsoService { constructor( private readonly prisma: PrismaService, private readonly sessionService: SessionService, private readonly auditService: AuditService, @Inject(APP_CONFIG) private readonly config: AppConfig, ) {} async createWhmcsServiceSso( installation: InstallationRef, externalServiceId: string, input: WhmcsCreateSsoRequest, idempotencyKey: string, ): Promise { const existingOp = await this.prisma.integrationOperation.findUnique({ where: { idempotencyKey }, }); if (existingOp?.result) { return existingOp.result as WhmcsCreateSsoResponse; } const serviceLink = await this.prisma.whmcsServiceLink.findUnique({ where: { installationId_externalServiceId: { installationId: installation.id, externalServiceId, }, }, include: { server: true, }, }); if (!serviceLink) { throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`); } if (serviceLink.status === 'SUSPENDED' || serviceLink.status === 'TERMINATED') { throw new ForbiddenException('Service is not eligible for SSO'); } if (serviceLink.server.billingSuspendedAt) { throw new ForbiddenException('Server is billing suspended'); } const clientLink = await this.prisma.whmcsClientLink.findUnique({ where: { installationId_externalClientId: { installationId: installation.id, externalClientId: input.externalClientId, }, }, }); if (!clientLink || clientLink.userId !== serviceLink.server.userId) { throw new ForbiddenException('WHMCS client is not linked to this service'); } const isAdminSso = input.kind === 'admin'; const targetPath = resolveSsoTargetPath( input.targetPath, serviceLink.serverId, ); const plainToken = generateSecureToken(); const tokenHash = hashToken(plainToken); const expiresAt = new Date(Date.now() + SSO_TICKET_TTL_MS); const audience = this.config.APP_URL.replace(/\/$/, ''); const ticket = await this.prisma.ssoTicket.create({ data: { installationId: installation.id, tokenHash, kind: isAdminSso ? 'ADMIN' : 'SERVICE', userId: serviceLink.server.userId, serverId: serviceLink.serverId, externalServiceId, externalClientId: input.externalClientId, externalUserId: input.externalUserId, targetPath, issuer: installation.integrationId, audience, nonce: randomUUID(), expiresAt, }, }); const redirectUrl = `${audience}/auth/sso?ticket=${encodeURIComponent(plainToken)}`; const response: WhmcsCreateSsoResponse = { ticketId: ticket.id, redirectUrl, expiresAt: expiresAt.toISOString(), targetPath, }; await this.prisma.integrationOperation.create({ data: { installationId: installation.id, idempotencyKey, operation: isAdminSso ? 'sso:admin' : 'sso:service', externalRef: externalServiceId, status: 'COMPLETED', result: response, }, }); await this.auditService.record({ action: isAdminSso ? 'whmcs.sso.admin_created' : 'whmcs.sso.service_created', userId: serviceLink.server.userId, entityType: 'game_server', entityId: serviceLink.serverId, metadata: { externalServiceId, externalClientId: input.externalClientId, externalUserId: input.externalUserId, ticketId: ticket.id, targetPath, }, }); return response; } async consumeTicket( ticket: string, reply: FastifyReply, ipAddress?: string, userAgent?: string, ): Promise { const tokenHash = hashToken(ticket); const redeemed = await this.prisma.$transaction(async (tx) => { const record = await tx.ssoTicket.findUnique({ where: { tokenHash }, include: { user: { include: { platformRoles: { include: { role: true } }, }, }, }, }); if (!record || record.usedAt || record.expiresAt <= new Date()) { throw new UnauthorizedException('Invalid or expired SSO ticket'); } if (record.audience !== this.config.APP_URL.replace(/\/$/, '')) { throw new UnauthorizedException('SSO ticket audience mismatch'); } const updated = await tx.ssoTicket.updateMany({ where: { id: record.id, usedAt: null, expiresAt: { gt: new Date() }, }, data: { usedAt: new Date() }, }); if (updated.count !== 1) { throw new UnauthorizedException('SSO ticket already used'); } return record; }); const isImpersonation = redeemed.kind === 'ADMIN'; const impersonationMetadata = isImpersonation ? { source: 'whmcs', externalServiceId: redeemed.externalServiceId, externalClientId: redeemed.externalClientId, externalUserId: redeemed.externalUserId, impersonatorLabel: `WHMCS admin user ${redeemed.externalUserId ?? 'unknown'}`, } : undefined; const { token, expiresAt } = await this.sessionService.createSession( redeemed.userId, ipAddress, userAgent, { isImpersonation, impersonationMetadata, }, ); this.sessionService.setSessionCookie(reply, token, expiresAt); await this.auditService.record({ action: isImpersonation ? 'whmcs.sso.admin_consumed' : 'whmcs.sso.service_consumed', userId: redeemed.userId, entityType: redeemed.serverId ? 'game_server' : 'user', entityId: redeemed.serverId ?? redeemed.userId, ipAddress, metadata: { ticketId: redeemed.id, externalServiceId: redeemed.externalServiceId, externalUserId: redeemed.externalUserId, targetPath: redeemed.targetPath, isImpersonation, }, }); return { redirectPath: redeemed.targetPath, user: this.sessionService.toAuthUserResponse(redeemed.user), impersonation: isImpersonation ? { isImpersonation: true, label: impersonationMetadata?.impersonatorLabel ?? 'Support impersonation session', } : undefined, }; } }