Phase D
This commit is contained in:
@@ -23,14 +23,16 @@ import {
|
||||
twoFactorDisableRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
ssoConsumeRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser, Public } from './decorators/current-user.decorator';
|
||||
import { Public, CurrentUser } from './decorators/current-user.decorator';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { SessionService } from './session.service';
|
||||
import { SsoService } from './sso.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -40,6 +42,7 @@ export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly ssoService: SsoService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -105,6 +108,24 @@ export class AuthController {
|
||||
return this.authService.verifyEmail({ token }, request.ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('sso/consume')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Redeem a one-time WHMCS SSO ticket' })
|
||||
consumeSso(
|
||||
@Body(new ZodValidationPipe(ssoConsumeRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
const payload = body as { ticket: string };
|
||||
return this.ssoService.consumeTicket(
|
||||
payload.ticket,
|
||||
reply,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { MeController } from './me.controller';
|
||||
import { SessionService } from './session.service';
|
||||
import { SsoService } from './sso.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule, BillingModule],
|
||||
@@ -24,6 +25,7 @@ import { SessionService } from './session.service';
|
||||
providers: [
|
||||
AuthService,
|
||||
SessionService,
|
||||
SsoService,
|
||||
AuditService,
|
||||
SessionGuard,
|
||||
{
|
||||
@@ -48,6 +50,6 @@ import { SessionService } from './session.service';
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SessionService, SessionGuard, AuditService],
|
||||
exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -26,8 +26,14 @@ export class MeController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiResponse({ status: 200, description: 'Current user' })
|
||||
getMe(@CurrentUser() user: { id: string }) {
|
||||
return { user };
|
||||
getMe(
|
||||
@CurrentUser() user: { id: string },
|
||||
@CurrentSession() session: { impersonation?: { isImpersonation: boolean; impersonatorLabel?: string } },
|
||||
) {
|
||||
return {
|
||||
user,
|
||||
impersonation: session.impersonation,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SessionContext,
|
||||
} from '@hexahost/auth';
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
import { Prisma } from '@hexahost/database';
|
||||
|
||||
import { APP_CONFIG } from '../config/config.constants';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -32,6 +33,10 @@ export class SessionService {
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
options?: {
|
||||
isImpersonation?: boolean;
|
||||
impersonationMetadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
@@ -44,6 +49,8 @@ export class SessionService {
|
||||
expiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
isImpersonation: options?.isImpersonation ?? false,
|
||||
impersonationMetadata: options?.impersonationMetadata as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,6 +114,53 @@ export class SessionService {
|
||||
issuedAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
user: this.toAuthenticatedUser(session.user),
|
||||
impersonation: session.isImpersonation
|
||||
? {
|
||||
isImpersonation: true,
|
||||
source:
|
||||
typeof session.impersonationMetadata === 'object' &&
|
||||
session.impersonationMetadata !== null &&
|
||||
'source' in session.impersonationMetadata
|
||||
? String((session.impersonationMetadata as { source?: string }).source)
|
||||
: undefined,
|
||||
externalServiceId:
|
||||
typeof session.impersonationMetadata === 'object' &&
|
||||
session.impersonationMetadata !== null &&
|
||||
'externalServiceId' in session.impersonationMetadata
|
||||
? String(
|
||||
(session.impersonationMetadata as { externalServiceId?: string })
|
||||
.externalServiceId ?? '',
|
||||
) || null
|
||||
: null,
|
||||
externalClientId:
|
||||
typeof session.impersonationMetadata === 'object' &&
|
||||
session.impersonationMetadata !== null &&
|
||||
'externalClientId' in session.impersonationMetadata
|
||||
? String(
|
||||
(session.impersonationMetadata as { externalClientId?: string })
|
||||
.externalClientId ?? '',
|
||||
) || null
|
||||
: null,
|
||||
externalUserId:
|
||||
typeof session.impersonationMetadata === 'object' &&
|
||||
session.impersonationMetadata !== null &&
|
||||
'externalUserId' in session.impersonationMetadata
|
||||
? String(
|
||||
(session.impersonationMetadata as { externalUserId?: string })
|
||||
.externalUserId ?? '',
|
||||
) || null
|
||||
: null,
|
||||
impersonatorLabel:
|
||||
typeof session.impersonationMetadata === 'object' &&
|
||||
session.impersonationMetadata !== null &&
|
||||
'impersonatorLabel' in session.impersonationMetadata
|
||||
? String(
|
||||
(session.impersonationMetadata as { impersonatorLabel?: string })
|
||||
.impersonatorLabel ?? '',
|
||||
) || undefined
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,4 +246,15 @@ export class SessionService {
|
||||
roles: roles.length > 0 ? roles : [UserRole.USER],
|
||||
};
|
||||
}
|
||||
|
||||
toAuthUserResponse(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
platformRoles: Array<{ role: { name: string } }>;
|
||||
}) {
|
||||
return this.toAuthenticatedUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
65
apps/api/src/auth/sso-target.util.ts
Normal file
65
apps/api/src/auth/sso-target.util.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
const ALLOWED_EXACT_PATHS = new Set(['/dashboard', '/servers']);
|
||||
|
||||
const ALLOWED_SERVER_SUBPAGES = new Set([
|
||||
'console',
|
||||
'files',
|
||||
'properties',
|
||||
'players',
|
||||
'worlds',
|
||||
'backups',
|
||||
'addons',
|
||||
]);
|
||||
|
||||
export function resolveSsoTargetPath(
|
||||
requestedPath: string | undefined,
|
||||
serverId: string,
|
||||
): string {
|
||||
const defaultPath = `/servers/${serverId}`;
|
||||
|
||||
if (!requestedPath || requestedPath.trim() === '') {
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
const trimmed = requestedPath.trim();
|
||||
|
||||
if (
|
||||
trimmed.includes('://') ||
|
||||
trimmed.startsWith('//') ||
|
||||
trimmed.includes('\\') ||
|
||||
trimmed.includes('\0')
|
||||
) {
|
||||
throw new BadRequestException('Target path must be a relative application path');
|
||||
}
|
||||
|
||||
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
|
||||
if (ALLOWED_EXACT_PATHS.has(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (!path.startsWith(`/servers/${serverId}`)) {
|
||||
throw new BadRequestException('Target path must reference the linked server');
|
||||
}
|
||||
|
||||
if (path === `/servers/${serverId}`) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const suffix = path.slice(`/servers/${serverId}`.length);
|
||||
const subpage = suffix.startsWith('/') ? suffix.slice(1) : suffix;
|
||||
|
||||
if (subpage && ALLOWED_SERVER_SUBPAGES.has(subpage)) {
|
||||
return `/servers/${serverId}/${subpage}`;
|
||||
}
|
||||
|
||||
throw new BadRequestException('Target path is not allowlisted');
|
||||
}
|
||||
|
||||
export function isUuid(value: string): boolean {
|
||||
return UUID_PATTERN.test(value);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,15 @@ import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
whmcsChangePackageRequestSchema,
|
||||
whmcsCreateSsoRequestSchema,
|
||||
whmcsProvisionServiceRequestSchema,
|
||||
whmcsSuspendRequestSchema,
|
||||
whmcsUpsertClientRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
|
||||
|
||||
import { SsoService } from '../../auth/sso.service';
|
||||
|
||||
import {
|
||||
WhmcsIntegrationGuard,
|
||||
type WhmcsAuthenticatedRequest,
|
||||
@@ -29,7 +32,10 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
|
||||
@UseGuards(WhmcsIntegrationGuard)
|
||||
@SkipThrottle()
|
||||
export class WhmcsIntegrationController {
|
||||
constructor(private readonly whmcs: WhmcsIntegrationService) {}
|
||||
constructor(
|
||||
private readonly whmcs: WhmcsIntegrationService,
|
||||
private readonly sso: SsoService,
|
||||
) {}
|
||||
|
||||
@Get('health')
|
||||
health(@Req() request: WhmcsAuthenticatedRequest) {
|
||||
@@ -127,6 +133,21 @@ export class WhmcsIntegrationController {
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('services/:externalServiceId/sso')
|
||||
createServiceSso(
|
||||
@Req() request: WhmcsAuthenticatedRequest,
|
||||
@Param('externalServiceId') externalServiceId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const parsed = whmcsCreateSsoRequestSchema.parse(body);
|
||||
return this.sso.createWhmcsServiceSso(
|
||||
request.whmcsInstallation!,
|
||||
externalServiceId,
|
||||
parsed,
|
||||
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Redis from 'ioredis';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { BillingModule } from '../../billing/billing.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { ServersModule } from '../../servers/servers.module';
|
||||
|
||||
import { WhmcsIntegrationController } from './whmcs-integration.controller';
|
||||
@@ -14,7 +15,7 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
|
||||
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule, BillingModule],
|
||||
imports: [ServersModule, BillingModule, AuthModule],
|
||||
controllers: [WhmcsIntegrationController],
|
||||
providers: [
|
||||
WhmcsIntegrationService,
|
||||
|
||||
42
apps/api/test/whmcs-phase-d.test.js
Normal file
42
apps/api/test/whmcs-phase-d.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('whmcs phase d sso contract', () => {
|
||||
it('documents SSO create and consume routes', () => {
|
||||
const whmcsSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(whmcsSource.includes("'services/:externalServiceId/sso'"));
|
||||
|
||||
const authSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'auth/auth.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(authSource.includes("'sso/consume'"));
|
||||
|
||||
const ssoServiceSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'auth/sso.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(ssoServiceSource.includes('createWhmcsServiceSso'));
|
||||
assert.ok(ssoServiceSource.includes('consumeTicket'));
|
||||
assert.ok(ssoServiceSource.includes('isImpersonation'));
|
||||
});
|
||||
|
||||
it('documents WHMCS PHP SSO hooks', () => {
|
||||
const phpSource = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'..',
|
||||
'..',
|
||||
'integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(phpSource.includes('ServiceSingleSignOn'));
|
||||
assert.ok(phpSource.includes('AdminSingleSignOn'));
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,12 @@
|
||||
"securityTitle": "Sicherheit",
|
||||
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"ssoTitle": "Anmeldung wird vorbereitet",
|
||||
"ssoLoading": "Sicherer Anmeldelink wird geprüft…",
|
||||
"ssoError": "Anmeldung fehlgeschlagen. Der Link ist abgelaufen oder wurde bereits verwendet.",
|
||||
"ssoMissingTicket": "Es wurde kein Anmeldeticket übergeben.",
|
||||
"impersonationBanner": "Support-Sitzung aktiv: {label}",
|
||||
"impersonationDefaultLabel": "WHMCS-Admin-Impersonation",
|
||||
"sessionNone": "Sitzung: keine",
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
"securityTitle": "Security",
|
||||
"securitySubtitle": "Manage authentication and security settings for your account.",
|
||||
"backToLogin": "Back to sign in",
|
||||
"ssoTitle": "Signing you in",
|
||||
"ssoLoading": "Validating your secure sign-in link…",
|
||||
"ssoError": "Sign-in failed. The link may be expired or already used.",
|
||||
"ssoMissingTicket": "No sign-in ticket was provided.",
|
||||
"impersonationBanner": "Support session active: {label}",
|
||||
"impersonationDefaultLabel": "WHMCS admin impersonation",
|
||||
"sessionNone": "session: none",
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again."
|
||||
|
||||
20
apps/web/src/app/[locale]/auth/sso/page.tsx
Normal file
20
apps/web/src/app/[locale]/auth/sso/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { SsoContent } from "@/components/auth/sso-content";
|
||||
|
||||
interface SsoPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function SsoPage({ params }: SsoPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<SsoContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/auth/impersonation-banner.tsx
Normal file
25
apps/web/src/components/auth/impersonation-banner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
const { user } = useAuth();
|
||||
const t = useTranslations("auth");
|
||||
|
||||
const impersonation = user?.impersonation;
|
||||
if (!impersonation?.isImpersonation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
||||
role="status"
|
||||
>
|
||||
{t("impersonationBanner", {
|
||||
label: impersonation.label ?? t("impersonationDefaultLabel"),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/components/auth/sso-content.tsx
Normal file
101
apps/web/src/components/auth/sso-content.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, consumeSsoTicket } from "@/lib/api/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
type SsoState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function SsoContent() {
|
||||
const t = useTranslations("auth");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { refresh } = useAuth();
|
||||
const ticket = searchParams.get("ticket");
|
||||
const [state, setState] = useState<SsoState>(ticket ? "loading" : "idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticket) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function redeem() {
|
||||
try {
|
||||
const result = await consumeSsoTicket(ticket!);
|
||||
await refresh();
|
||||
if (!cancelled) {
|
||||
setState("success");
|
||||
router.replace(result.redirectPath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setState("error");
|
||||
if (error instanceof ApiError) {
|
||||
setErrorMessage(error.detail ?? error.title);
|
||||
} else {
|
||||
setErrorMessage(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redeem();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ticket, refresh, router, t]);
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssoTitle")}</CardTitle>
|
||||
<CardDescription>{t("ssoMissingTicket")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "loading" || state === "idle" || state === "success") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssoTitle")}</CardTitle>
|
||||
<CardDescription>{t("ssoLoading")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssoTitle")}</CardTitle>
|
||||
<CardDescription>{t("ssoError")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errorMessage ? (
|
||||
<p className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ImpersonationBanner } from "@/components/auth/impersonation-banner";
|
||||
import { Footer } from "./footer";
|
||||
import { Header } from "./header";
|
||||
|
||||
@@ -10,6 +11,7 @@ export function SiteLayout({ children }: SiteLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
|
||||
<Header />
|
||||
<ImpersonationBanner />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -48,8 +48,23 @@ export interface CurrentUser {
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
emailVerifiedAt?: string | null;
|
||||
emailVerified?: boolean;
|
||||
roles?: string[];
|
||||
twoFactorEnabled?: boolean;
|
||||
impersonation?: {
|
||||
isImpersonation: boolean;
|
||||
label?: string;
|
||||
impersonatorLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: CurrentUser;
|
||||
impersonation?: {
|
||||
isImpersonation: boolean;
|
||||
impersonatorLabel?: string;
|
||||
label?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResponse {
|
||||
@@ -149,8 +164,30 @@ export async function submitLogout(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<CurrentUser> {
|
||||
return apiFetch<CurrentUser>("/me");
|
||||
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
||||
try {
|
||||
const response = await apiFetch<MeResponse>("/me");
|
||||
return {
|
||||
...response.user,
|
||||
impersonation: response.impersonation
|
||||
? {
|
||||
isImpersonation: response.impersonation.isImpersonation,
|
||||
label:
|
||||
response.impersonation.label ??
|
||||
response.impersonation.impersonatorLabel,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeSsoTicket(ticket: string): Promise<{ redirectPath: string }> {
|
||||
return apiFetch<{ redirectPath: string }>("/auth/sso/consume", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticket }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string): Promise<void> {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,42 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: Phase 9 — WHMCS integration and production hardening ✅
|
||||
## Current phase: WHMCS Phase D — SSO ✅
|
||||
|
||||
### WHMCS Phase D completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `SsoTicket` model | Done | Hashed token, 60s TTL, single use |
|
||||
| Impersonation sessions | Done | `UserSession.isImpersonation` + audit |
|
||||
| API create SSO | Done | `POST .../services/:id/sso` |
|
||||
| API consume SSO | Done | `POST /auth/sso/consume` → session cookie |
|
||||
| Path allowlist | Done | Server panel routes only |
|
||||
| Web SSO page | Done | `/auth/sso?ticket=` + impersonation banner |
|
||||
| WHMCS PHP SSO | Done | `ServiceSingleSignOn`, `AdminSingleSignOn` |
|
||||
| Docs | Done | `docs/integrations/whmcs/sso.md` |
|
||||
|
||||
### Abnahmekriterium Phase D
|
||||
|
||||
- [x] WHMCS „Server verwalten“ erzeugt einmaliges SSO-Ticket
|
||||
- [x] Ticket-Einlösung setzt Session-Cookie und leitet auf Server-Panel weiter
|
||||
- [x] Admin-SSO markiert Impersonation-Session sichtbar im Panel
|
||||
- [x] Gesperrte/beendete Services erhalten kein SSO-Ticket
|
||||
- [ ] Manual E2E: WHMCS client area → panel → server detail
|
||||
|
||||
### Local SSO test
|
||||
|
||||
1. `pnpm db:migrate && pnpm db:seed` (includes `local-dev-whmcs`)
|
||||
2. Provision a service via WHMCS API (`PUT .../clients/1`, `POST .../services`)
|
||||
3. `POST /api/v1/integrations/whmcs/services/{externalServiceId}/sso` (signed)
|
||||
4. Open `{APP_URL}/auth/sso?ticket={token}` → redirects to `/servers/{id}`
|
||||
5. Admin SSO: same flow with `kind=admin` → impersonation banner visible
|
||||
|
||||
See [docs/integrations/whmcs/sso.md](integrations/whmcs/sso.md).
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — WHMCS integration and production hardening ✅
|
||||
|
||||
### Phase 9 completed
|
||||
|
||||
@@ -156,4 +191,4 @@ Server entry fields:
|
||||
|
||||
### Next phase: Post-MVP hardening
|
||||
|
||||
- Stripe webhooks, SSO (WHMCS Phase D), reconciliation dashboard, penetration test
|
||||
- WHMCS Phase E (reconciliation), Phase F (usage billing), Stripe webhooks, penetration test
|
||||
|
||||
51
docs/integrations/whmcs/sso.md
Normal file
51
docs/integrations/whmcs/sso.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# WHMCS single sign-on (Phase D)
|
||||
|
||||
HexaHost GameCloud uses one-time SSO tickets to bridge WHMCS and the customer panel without long-lived tokens in URLs.
|
||||
|
||||
## Flow
|
||||
|
||||
1. WHMCS calls `ServiceSingleSignOn` or `AdminSingleSignOn` in the server module.
|
||||
2. The module requests `POST /api/v1/integrations/whmcs/services/:externalServiceId/sso`.
|
||||
3. GameCloud stores a hashed ticket (60s TTL, single use) and returns `redirectUrl`.
|
||||
4. WHMCS redirects the browser to `{APP_URL}/auth/sso?ticket=...`.
|
||||
5. The web app calls `POST /api/v1/integrations/whmcs/auth/sso/consume` — actually `/auth/sso/consume`.
|
||||
6. GameCloud sets a normal HTTP-only session cookie and returns an allowlisted `redirectPath`.
|
||||
|
||||
## Ticket properties
|
||||
|
||||
Stored server-side (hashed):
|
||||
|
||||
- GameCloud user and server IDs
|
||||
- WHMCS installation, client, user, and service IDs
|
||||
- Target path (allowlisted)
|
||||
- Issuer (`integrationId`) and audience (`APP_URL`)
|
||||
- Nonce (replay protection at ticket layer)
|
||||
|
||||
## Admin SSO / impersonation
|
||||
|
||||
`kind=admin` creates an impersonation session:
|
||||
|
||||
- `UserSession.isImpersonation = true`
|
||||
- Metadata includes WHMCS admin user reference
|
||||
- Full audit events: `whmcs.sso.admin_created`, `whmcs.sso.admin_consumed`
|
||||
- Panel shows an amber support banner
|
||||
|
||||
## Allowlisted redirect paths
|
||||
|
||||
- `/dashboard`
|
||||
- `/servers`
|
||||
- `/servers/{serverId}`
|
||||
- `/servers/{serverId}/{console|files|properties|players|worlds|backups|addons}`
|
||||
|
||||
Arbitrary external URLs are rejected.
|
||||
|
||||
## WHMCS configuration
|
||||
|
||||
Server module hostname must match `APP_URL` origin used for ticket audience validation.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Tickets expire after **60 seconds**
|
||||
- Each ticket is **single-use**
|
||||
- Only the WHMCS integration API (HMAC) can mint tickets
|
||||
- Suspended or terminated services cannot obtain SSO tickets
|
||||
@@ -54,6 +54,8 @@ Handles global configuration:
|
||||
|
||||
**Phase 9 (WHMCS Phase A–B + production DNS)** — implemented.
|
||||
|
||||
**Phase D (SSO)** — implemented. See [docs/integrations/whmcs/sso.md](../../docs/integrations/whmcs/sso.md).
|
||||
|
||||
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
|
||||
- Addon module: planned for global mappings and reconciliation UI
|
||||
|
||||
|
||||
@@ -110,6 +110,16 @@ function hexagamecloud_ChangePackage(array $params): string
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_ServiceSingleSignOn(array $params)
|
||||
{
|
||||
return hexagamecloud_requestSso($params, 'service');
|
||||
}
|
||||
|
||||
function hexagamecloud_AdminSingleSignOn(array $params)
|
||||
{
|
||||
return hexagamecloud_requestSso($params, 'admin');
|
||||
}
|
||||
|
||||
function hexagamecloud_TestConnection(array $params): array
|
||||
{
|
||||
try {
|
||||
@@ -142,3 +152,31 @@ function hexagamecloud_lifecycleAction(array $params, string $action, array $bod
|
||||
return $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function hexagamecloud_requestSso(array $params, string $kind)
|
||||
{
|
||||
try {
|
||||
$client = new HexaGameCloudApiClient($params);
|
||||
$externalServiceId = (string) $params['serviceid'];
|
||||
$externalClientId = (string) ($params['clientsdetails']['userid'] ?? $params['userid'] ?? '');
|
||||
$externalUserId = (string) ($params['clientsdetails']['userid'] ?? $params['adminid'] ?? $externalClientId);
|
||||
|
||||
$response = $client->createServiceSso($externalServiceId, [
|
||||
'externalClientId' => $externalClientId,
|
||||
'externalUserId' => $externalUserId,
|
||||
'role' => $kind === 'admin' ? 'admin' : 'owner',
|
||||
'kind' => $kind,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'redirectTo' => $response['redirectUrl'],
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
logModuleCall('hexagamecloud', 'sso:' . $kind, $params, $exception->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $exception->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,16 @@ final class HexaGameCloudApiClient
|
||||
);
|
||||
}
|
||||
|
||||
public function createServiceSso(string $externalServiceId, array $payload): array
|
||||
{
|
||||
return $this->request(
|
||||
'POST',
|
||||
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/sso',
|
||||
$payload,
|
||||
'service:sso:' . $externalServiceId . ':' . ($payload['kind'] ?? 'service')
|
||||
);
|
||||
}
|
||||
|
||||
private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
|
||||
{
|
||||
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -15,11 +15,21 @@ export interface AuthenticatedUser {
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
export interface SessionImpersonationContext {
|
||||
isImpersonation: boolean;
|
||||
source?: string;
|
||||
externalServiceId?: string | null;
|
||||
externalClientId?: string | null;
|
||||
externalUserId?: string | null;
|
||||
impersonatorLabel?: string;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
user: AuthenticatedUser;
|
||||
sessionId: string;
|
||||
issuedAt: Date;
|
||||
expiresAt: Date;
|
||||
impersonation?: SessionImpersonationContext;
|
||||
}
|
||||
|
||||
export interface AuthTokenPayload {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -228,6 +228,10 @@ export {
|
||||
whmcsServiceResponseSchema,
|
||||
whmcsChangePackageRequestSchema,
|
||||
whmcsSuspendRequestSchema,
|
||||
whmcsCreateSsoRequestSchema,
|
||||
whmcsCreateSsoResponseSchema,
|
||||
ssoConsumeRequestSchema,
|
||||
ssoConsumeResponseSchema,
|
||||
type WhmcsHealthResponse,
|
||||
type WhmcsPlanCatalogResponse,
|
||||
type WhmcsProvisionServiceRequest,
|
||||
@@ -235,4 +239,8 @@ export {
|
||||
type WhmcsUpsertClientRequest,
|
||||
type WhmcsUpsertClientResponse,
|
||||
type WhmcsChangePackageRequest,
|
||||
type WhmcsCreateSsoRequest,
|
||||
type WhmcsCreateSsoResponse,
|
||||
type SsoConsumeRequest,
|
||||
type SsoConsumeResponse,
|
||||
} from './whmcs';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authUserSchema } from './auth';
|
||||
import { serverResponseSchema } from './servers';
|
||||
|
||||
export const whmcsHealthResponseSchema = z.object({
|
||||
@@ -61,6 +62,40 @@ export const whmcsSuspendRequestSchema = z.object({
|
||||
reason: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const whmcsSsoRoleSchema = z.enum(['owner', 'admin']);
|
||||
|
||||
export const whmcsSsoKindSchema = z.enum(['service', 'admin']);
|
||||
|
||||
export const whmcsCreateSsoRequestSchema = z.object({
|
||||
externalClientId: z.string().min(1).max(64),
|
||||
externalUserId: z.string().min(1).max(64),
|
||||
role: whmcsSsoRoleSchema.default('owner'),
|
||||
kind: whmcsSsoKindSchema.default('service'),
|
||||
targetPath: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const whmcsCreateSsoResponseSchema = z.object({
|
||||
ticketId: z.string().uuid(),
|
||||
redirectUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
targetPath: z.string(),
|
||||
});
|
||||
|
||||
export const ssoConsumeRequestSchema = z.object({
|
||||
ticket: z.string().min(32).max(256),
|
||||
});
|
||||
|
||||
export const ssoImpersonationSchema = z.object({
|
||||
isImpersonation: z.boolean(),
|
||||
label: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ssoConsumeResponseSchema = z.object({
|
||||
redirectPath: z.string(),
|
||||
user: authUserSchema,
|
||||
impersonation: ssoImpersonationSchema.optional(),
|
||||
});
|
||||
|
||||
export type WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
|
||||
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
|
||||
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>;
|
||||
@@ -69,3 +104,7 @@ export type WhmcsProvisionServiceRequest = z.infer<typeof whmcsProvisionServiceR
|
||||
export type WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
|
||||
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
|
||||
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>;
|
||||
export type WhmcsCreateSsoRequest = z.infer<typeof whmcsCreateSsoRequestSchema>;
|
||||
export type WhmcsCreateSsoResponse = z.infer<typeof whmcsCreateSsoResponseSchema>;
|
||||
export type SsoConsumeRequest = z.infer<typeof ssoConsumeRequestSchema>;
|
||||
export type SsoConsumeResponse = z.infer<typeof ssoConsumeResponseSchema>;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
||||
-- WHMCS Phase D: SSO tickets and impersonation sessions
|
||||
|
||||
CREATE TYPE "SsoTicketKind" AS ENUM ('SERVICE', 'ADMIN');
|
||||
|
||||
ALTER TABLE "user_sessions"
|
||||
ADD COLUMN IF NOT EXISTS "isImpersonation" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "impersonationMetadata" JSONB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sso_tickets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"installationId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"kind" "SsoTicketKind" NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"serverId" TEXT,
|
||||
"externalServiceId" TEXT,
|
||||
"externalClientId" TEXT,
|
||||
"externalUserId" TEXT,
|
||||
"targetPath" TEXT NOT NULL,
|
||||
"issuer" TEXT NOT NULL,
|
||||
"audience" TEXT NOT NULL,
|
||||
"nonce" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "sso_tickets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "sso_tickets_tokenHash_key" ON "sso_tickets"("tokenHash");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "sso_tickets_nonce_key" ON "sso_tickets"("nonce");
|
||||
CREATE INDEX IF NOT EXISTS "sso_tickets_expiresAt_idx" ON "sso_tickets"("expiresAt");
|
||||
CREATE INDEX IF NOT EXISTS "sso_tickets_installationId_createdAt_idx"
|
||||
ON "sso_tickets"("installationId", "createdAt");
|
||||
|
||||
ALTER TABLE "sso_tickets"
|
||||
ADD CONSTRAINT "sso_tickets_installationId_fkey"
|
||||
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sso_tickets"
|
||||
ADD CONSTRAINT "sso_tickets_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sso_tickets"
|
||||
ADD CONSTRAINT "sso_tickets_serverId_fkey"
|
||||
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -147,6 +147,11 @@ enum IntegrationOperationStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum SsoTicketKind {
|
||||
SERVICE
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -169,6 +174,7 @@ model User {
|
||||
totpCredential TotpCredential?
|
||||
creditWallet CreditWallet?
|
||||
whmcsClientLink WhmcsClientLink?
|
||||
ssoTickets SsoTicket[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -194,6 +200,8 @@ model UserSession {
|
||||
revokedAt DateTime? @db.Timestamptz(3)
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
isImpersonation Boolean @default(false)
|
||||
impersonationMetadata Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -332,6 +340,7 @@ model GameServer {
|
||||
usageRecords UsageRecord[]
|
||||
dnsRecords ServerDnsRecord[]
|
||||
whmcsServiceLink WhmcsServiceLink?
|
||||
ssoTickets SsoTicket[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -714,6 +723,7 @@ model WhmcsInstallation {
|
||||
clientLinks WhmcsClientLink[]
|
||||
serviceLinks WhmcsServiceLink[]
|
||||
operations IntegrationOperation[]
|
||||
ssoTickets SsoTicket[]
|
||||
|
||||
@@map("whmcs_installations")
|
||||
}
|
||||
@@ -770,3 +780,30 @@ model IntegrationOperation {
|
||||
@@index([installationId, createdAt])
|
||||
@@map("integration_operations")
|
||||
}
|
||||
|
||||
model SsoTicket {
|
||||
id String @id @default(uuid())
|
||||
installationId String
|
||||
tokenHash String @unique
|
||||
kind SsoTicketKind
|
||||
userId String
|
||||
serverId String?
|
||||
externalServiceId String?
|
||||
externalClientId String?
|
||||
externalUserId String?
|
||||
targetPath String
|
||||
issuer String
|
||||
audience String
|
||||
nonce String @unique
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
server GameServer? @relation(fields: [serverId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([expiresAt])
|
||||
@@index([installationId, createdAt])
|
||||
@@map("sso_tickets")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export type {
|
||||
WhmcsClientLink,
|
||||
WhmcsServiceLink,
|
||||
IntegrationOperation,
|
||||
SsoTicket,
|
||||
Plan,
|
||||
FeatureFlag,
|
||||
SystemSetting,
|
||||
@@ -35,7 +36,7 @@ export type {
|
||||
JobRecord,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user