256 lines
7.1 KiB
TypeScript
256 lines
7.1 KiB
TypeScript
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<WhmcsCreateSsoResponse> {
|
|
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<SsoConsumeResponse> {
|
|
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,
|
|
};
|
|
}
|
|
}
|