Phase D
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
smueller
2026-06-26 14:03:57 +02:00
parent ed8334328e
commit 333ad1cc7d
31 changed files with 964 additions and 15 deletions

View File

@@ -0,0 +1,255 @@
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,
};
}
}