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

@@ -23,14 +23,16 @@ import {
twoFactorDisableRequestSchema, twoFactorDisableRequestSchema,
twoFactorVerifyRequestSchema, twoFactorVerifyRequestSchema,
verifyEmailRequestSchema, verifyEmailRequestSchema,
ssoConsumeRequestSchema,
} from '@hexahost/contracts'; } from '@hexahost/contracts';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AuthService } from './auth.service'; 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 { SessionGuard } from './guards/session.guard';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
import { SsoService } from './sso.service';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -40,6 +42,7 @@ export class AuthController {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
private readonly ssoService: SsoService,
) {} ) {}
@Public() @Public()
@@ -105,6 +108,24 @@ export class AuthController {
return this.authService.verifyEmail({ token }, request.ip); 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) @UseGuards(SessionGuard)
@Post('resend-verification') @Post('resend-verification')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@@ -17,6 +17,7 @@ import {
import { SessionGuard } from './guards/session.guard'; import { SessionGuard } from './guards/session.guard';
import { MeController } from './me.controller'; import { MeController } from './me.controller';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
import { SsoService } from './sso.service';
@Module({ @Module({
imports: [AppConfigModule, BillingModule], imports: [AppConfigModule, BillingModule],
@@ -24,6 +25,7 @@ import { SessionService } from './session.service';
providers: [ providers: [
AuthService, AuthService,
SessionService, SessionService,
SsoService,
AuditService, AuditService,
SessionGuard, SessionGuard,
{ {
@@ -48,6 +50,6 @@ import { SessionService } from './session.service';
}, },
}, },
], ],
exports: [AuthService, SessionService, SessionGuard, AuditService], exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -26,8 +26,14 @@ export class MeController {
@Get() @Get()
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'Current user' }) @ApiResponse({ status: 200, description: 'Current user' })
getMe(@CurrentUser() user: { id: string }) { getMe(
return { user }; @CurrentUser() user: { id: string },
@CurrentSession() session: { impersonation?: { isImpersonation: boolean; impersonatorLabel?: string } },
) {
return {
user,
impersonation: session.impersonation,
};
} }
@Get('sessions') @Get('sessions')

View File

@@ -17,6 +17,7 @@ import {
type SessionContext, type SessionContext,
} from '@hexahost/auth'; } from '@hexahost/auth';
import type { AppConfig } from '@hexahost/config'; import type { AppConfig } from '@hexahost/config';
import { Prisma } from '@hexahost/database';
import { APP_CONFIG } from '../config/config.constants'; import { APP_CONFIG } from '../config/config.constants';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -32,6 +33,10 @@ export class SessionService {
userId: string, userId: string,
ipAddress?: string, ipAddress?: string,
userAgent?: string, userAgent?: string,
options?: {
isImpersonation?: boolean;
impersonationMetadata?: Record<string, unknown>;
},
): Promise<{ token: string; sessionId: string; expiresAt: Date }> { ): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
const token = generateSecureToken(); const token = generateSecureToken();
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
@@ -44,6 +49,8 @@ export class SessionService {
expiresAt, expiresAt,
ipAddress, ipAddress,
userAgent, userAgent,
isImpersonation: options?.isImpersonation ?? false,
impersonationMetadata: options?.impersonationMetadata as Prisma.InputJsonValue | undefined,
}, },
}); });
@@ -107,6 +114,53 @@ export class SessionService {
issuedAt: session.createdAt, issuedAt: session.createdAt,
expiresAt: session.expiresAt, expiresAt: session.expiresAt,
user: this.toAuthenticatedUser(session.user), 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], 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);
}
} }

View 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);
}

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,
};
}
}

View File

@@ -13,12 +13,15 @@ import { SkipThrottle } from '@nestjs/throttler';
import { import {
whmcsChangePackageRequestSchema, whmcsChangePackageRequestSchema,
whmcsCreateSsoRequestSchema,
whmcsProvisionServiceRequestSchema, whmcsProvisionServiceRequestSchema,
whmcsSuspendRequestSchema, whmcsSuspendRequestSchema,
whmcsUpsertClientRequestSchema, whmcsUpsertClientRequestSchema,
} from '@hexahost/contracts'; } from '@hexahost/contracts';
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth'; import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
import { SsoService } from '../../auth/sso.service';
import { import {
WhmcsIntegrationGuard, WhmcsIntegrationGuard,
type WhmcsAuthenticatedRequest, type WhmcsAuthenticatedRequest,
@@ -29,7 +32,10 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
@UseGuards(WhmcsIntegrationGuard) @UseGuards(WhmcsIntegrationGuard)
@SkipThrottle() @SkipThrottle()
export class WhmcsIntegrationController { export class WhmcsIntegrationController {
constructor(private readonly whmcs: WhmcsIntegrationService) {} constructor(
private readonly whmcs: WhmcsIntegrationService,
private readonly sso: SsoService,
) {}
@Get('health') @Get('health')
health(@Req() request: WhmcsAuthenticatedRequest) { health(@Req() request: WhmcsAuthenticatedRequest) {
@@ -127,6 +133,21 @@ export class WhmcsIntegrationController {
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, 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 { function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {

View File

@@ -4,6 +4,7 @@ import Redis from 'ioredis';
import { getConfig } from '@hexahost/config'; import { getConfig } from '@hexahost/config';
import { BillingModule } from '../../billing/billing.module'; import { BillingModule } from '../../billing/billing.module';
import { AuthModule } from '../../auth/auth.module';
import { ServersModule } from '../../servers/servers.module'; import { ServersModule } from '../../servers/servers.module';
import { WhmcsIntegrationController } from './whmcs-integration.controller'; import { WhmcsIntegrationController } from './whmcs-integration.controller';
@@ -14,7 +15,7 @@ import { WhmcsIntegrationService } from './whmcs-integration.service';
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants'; export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
@Module({ @Module({
imports: [ServersModule, BillingModule], imports: [ServersModule, BillingModule, AuthModule],
controllers: [WhmcsIntegrationController], controllers: [WhmcsIntegrationController],
providers: [ providers: [
WhmcsIntegrationService, WhmcsIntegrationService,

View 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'));
});
});

View File

@@ -86,6 +86,12 @@
"securityTitle": "Sicherheit", "securityTitle": "Sicherheit",
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.", "securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
"backToLogin": "Zurück zur Anmeldung", "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", "sessionNone": "Sitzung: keine",
"errors": { "errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." "generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."

View File

@@ -86,6 +86,12 @@
"securityTitle": "Security", "securityTitle": "Security",
"securitySubtitle": "Manage authentication and security settings for your account.", "securitySubtitle": "Manage authentication and security settings for your account.",
"backToLogin": "Back to sign in", "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", "sessionNone": "session: none",
"errors": { "errors": {
"generic": "Something went wrong. Please try again." "generic": "Something went wrong. Please try again."

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ImpersonationBanner } from "@/components/auth/impersonation-banner";
import { Footer } from "./footer"; import { Footer } from "./footer";
import { Header } from "./header"; import { Header } from "./header";
@@ -10,6 +11,7 @@ export function SiteLayout({ children }: SiteLayoutProps) {
return ( return (
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50"> <div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
<Header /> <Header />
<ImpersonationBanner />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<Footer /> <Footer />
</div> </div>

View File

@@ -48,8 +48,23 @@ export interface CurrentUser {
username: string; username: string;
displayName?: string | null; displayName?: string | null;
emailVerifiedAt?: string | null; emailVerifiedAt?: string | null;
emailVerified?: boolean;
roles?: string[]; roles?: string[];
twoFactorEnabled?: boolean; twoFactorEnabled?: boolean;
impersonation?: {
isImpersonation: boolean;
label?: string;
impersonatorLabel?: string;
};
}
export interface MeResponse {
user: CurrentUser;
impersonation?: {
isImpersonation: boolean;
impersonatorLabel?: string;
label?: string;
};
} }
export interface TwoFactorSetupResponse { export interface TwoFactorSetupResponse {
@@ -149,8 +164,30 @@ export async function submitLogout(): Promise<void> {
}); });
} }
export async function getCurrentUser(): Promise<CurrentUser> { export async function getCurrentUser(): Promise<CurrentUser | null> {
return apiFetch<CurrentUser>("/me"); 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> { export async function verifyEmail(token: string): Promise<void> {

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,42 @@
Last updated: 2026-06-26 Last updated: 2026-06-26
## Current phase: Phase 9WHMCS integration and production hardening ## Current phase: WHMCS Phase DSSO
### 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 ### Phase 9 completed
@@ -156,4 +191,4 @@ Server entry fields:
### Next phase: Post-MVP hardening ### 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

View 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

View File

@@ -54,6 +54,8 @@ Handles global configuration:
**Phase 9 (WHMCS Phase AB + production DNS)** — implemented. **Phase 9 (WHMCS Phase AB + 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/` - Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
- Addon module: planned for global mappings and reconciliation UI - Addon module: planned for global mappings and reconciliation UI

View File

@@ -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 function hexagamecloud_TestConnection(array $params): array
{ {
try { try {
@@ -142,3 +152,31 @@ function hexagamecloud_lifecycleAction(array $params, string $action, array $bod
return $exception->getMessage(); 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(),
];
}
}

View File

@@ -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 private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
{ {
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR); $bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);

View File

@@ -15,11 +15,21 @@ export interface AuthenticatedUser {
roles: UserRole[]; roles: UserRole[];
} }
export interface SessionImpersonationContext {
isImpersonation: boolean;
source?: string;
externalServiceId?: string | null;
externalClientId?: string | null;
externalUserId?: string | null;
impersonatorLabel?: string;
}
export interface SessionContext { export interface SessionContext {
user: AuthenticatedUser; user: AuthenticatedUser;
sessionId: string; sessionId: string;
issuedAt: Date; issuedAt: Date;
expiresAt: Date; expiresAt: Date;
impersonation?: SessionImpersonationContext;
} }
export interface AuthTokenPayload { export interface AuthTokenPayload {

File diff suppressed because one or more lines are too long

View File

@@ -228,6 +228,10 @@ export {
whmcsServiceResponseSchema, whmcsServiceResponseSchema,
whmcsChangePackageRequestSchema, whmcsChangePackageRequestSchema,
whmcsSuspendRequestSchema, whmcsSuspendRequestSchema,
whmcsCreateSsoRequestSchema,
whmcsCreateSsoResponseSchema,
ssoConsumeRequestSchema,
ssoConsumeResponseSchema,
type WhmcsHealthResponse, type WhmcsHealthResponse,
type WhmcsPlanCatalogResponse, type WhmcsPlanCatalogResponse,
type WhmcsProvisionServiceRequest, type WhmcsProvisionServiceRequest,
@@ -235,4 +239,8 @@ export {
type WhmcsUpsertClientRequest, type WhmcsUpsertClientRequest,
type WhmcsUpsertClientResponse, type WhmcsUpsertClientResponse,
type WhmcsChangePackageRequest, type WhmcsChangePackageRequest,
type WhmcsCreateSsoRequest,
type WhmcsCreateSsoResponse,
type SsoConsumeRequest,
type SsoConsumeResponse,
} from './whmcs'; } from './whmcs';

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { authUserSchema } from './auth';
import { serverResponseSchema } from './servers'; import { serverResponseSchema } from './servers';
export const whmcsHealthResponseSchema = z.object({ export const whmcsHealthResponseSchema = z.object({
@@ -61,6 +62,40 @@ export const whmcsSuspendRequestSchema = z.object({
reason: z.string().max(256).optional(), 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 WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>; export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>; 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 WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>; export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>; 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

View File

@@ -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;

View File

@@ -147,6 +147,11 @@ enum IntegrationOperationStatus {
FAILED FAILED
} }
enum SsoTicketKind {
SERVICE
ADMIN
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
@@ -169,6 +174,7 @@ model User {
totpCredential TotpCredential? totpCredential TotpCredential?
creditWallet CreditWallet? creditWallet CreditWallet?
whmcsClientLink WhmcsClientLink? whmcsClientLink WhmcsClientLink?
ssoTickets SsoTicket[]
@@map("users") @@map("users")
} }
@@ -194,6 +200,8 @@ model UserSession {
revokedAt DateTime? @db.Timestamptz(3) revokedAt DateTime? @db.Timestamptz(3)
ipAddress String? ipAddress String?
userAgent String? userAgent String?
isImpersonation Boolean @default(false)
impersonationMetadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -332,6 +340,7 @@ model GameServer {
usageRecords UsageRecord[] usageRecords UsageRecord[]
dnsRecords ServerDnsRecord[] dnsRecords ServerDnsRecord[]
whmcsServiceLink WhmcsServiceLink? whmcsServiceLink WhmcsServiceLink?
ssoTickets SsoTicket[]
@@index([userId]) @@index([userId])
@@index([nodeId]) @@index([nodeId])
@@ -714,6 +723,7 @@ model WhmcsInstallation {
clientLinks WhmcsClientLink[] clientLinks WhmcsClientLink[]
serviceLinks WhmcsServiceLink[] serviceLinks WhmcsServiceLink[]
operations IntegrationOperation[] operations IntegrationOperation[]
ssoTickets SsoTicket[]
@@map("whmcs_installations") @@map("whmcs_installations")
} }
@@ -770,3 +780,30 @@ model IntegrationOperation {
@@index([installationId, createdAt]) @@index([installationId, createdAt])
@@map("integration_operations") @@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")
}

View File

@@ -28,6 +28,7 @@ export type {
WhmcsClientLink, WhmcsClientLink,
WhmcsServiceLink, WhmcsServiceLink,
IntegrationOperation, IntegrationOperation,
SsoTicket,
Plan, Plan,
FeatureFlag, FeatureFlag,
SystemSetting, SystemSetting,
@@ -35,7 +36,7 @@ export type {
JobRecord, JobRecord,
} from '@prisma/client'; } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient, Prisma } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined; prisma: PrismaClient | undefined;

File diff suppressed because one or more lines are too long