Phase D
This commit is contained in:
255
apps/api/src/auth/sso.service.ts
Normal file
255
apps/api/src/auth/sso.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user