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
Reference in New Issue
Block a user