Enhance WHMCS integration and validation processes
- Updated .env.example and portal-backend.env.sample to include new development-specific WHMCS environment variables. - Improved error handling in AuthService with a new signupPreflight method for validating signup data without creating accounts. - Enhanced phone number validation in SignupDto to enforce stricter formatting rules. - Refactored WHMCS connection service to prioritize development environment variables and normalize redirect URLs for SSO tokens. - Added debug logging for SSO redirect hosts in non-production environments to aid in troubleshooting.
This commit is contained in:
parent
79f11edb65
commit
cb3cb8fec8
10
.env.example
10
.env.example
@ -44,12 +44,21 @@ CORS_ORIGIN="http://localhost:3000"
|
||||
# 🏢 EXTERNAL API CONFIGURATION (Development)
|
||||
# =============================================================================
|
||||
# WHMCS Integration (use your actual credentials)
|
||||
# Note: Do not include a trailing slash in BASE_URL.
|
||||
WHMCS_BASE_URL="https://accounts.asolutions.co.jp"
|
||||
WHMCS_API_IDENTIFIER="your_whmcs_api_identifier"
|
||||
WHMCS_API_SECRET="your_whmcs_api_secret"
|
||||
# Optional: Some deployments require an API Access Key in addition to credentials
|
||||
# WHMCS_API_ACCESS_KEY="your_whmcs_api_access_key"
|
||||
|
||||
# WHMCS Dev Overrides (used when NODE_ENV !== "production")
|
||||
# These override the above values in development/test.
|
||||
# Note: Do not include a trailing slash in DEV_BASE_URL.
|
||||
# WHMCS_DEV_BASE_URL="https://dev-wh.asolutions.co.jp"
|
||||
# WHMCS_DEV_API_IDENTIFIER="your_dev_whmcs_api_identifier"
|
||||
# WHMCS_DEV_API_SECRET="your_dev_whmcs_api_secret"
|
||||
# WHMCS_DEV_API_ACCESS_KEY="your_dev_whmcs_api_access_key"
|
||||
|
||||
# Salesforce Integration (use your actual credentials)
|
||||
SF_LOGIN_URL="https://asolutions.my.salesforce.com"
|
||||
SF_CLIENT_ID="your_salesforce_client_id"
|
||||
@ -91,4 +100,3 @@ NODE_OPTIONS="--no-deprecation"
|
||||
# 1. Run: pnpm dev:start
|
||||
# 2. Frontend and Backend run locally (outside containers) for hot-reloading
|
||||
# 3. Only database and cache services run in containers
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { AuthService } from "./auth.service";
|
||||
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import { SignupDto } from "./dto/signup.dto";
|
||||
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
import { ChangePasswordDto } from "./dto/change-password.dto";
|
||||
@ -14,6 +13,7 @@ import { SetPasswordDto } from "./dto/set-password.dto";
|
||||
import { ValidateSignupDto } from "./dto/validate-signup.dto";
|
||||
import { AccountStatusRequestDto, AccountStatusResponseDto } from "./dto/account-status.dto";
|
||||
import { Public } from "./decorators/public.decorator";
|
||||
import { SignupDto } from "./dto/signup.dto";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
@ -41,6 +41,17 @@ export class AuthController {
|
||||
return this.authService.healthCheck();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("signup-preflight")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@Throttle({ default: { limit: 10, ttl: 900000 } })
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: "Validate full signup data without creating anything" })
|
||||
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
|
||||
async signupPreflight(@Body() body: SignupDto) {
|
||||
return this.authService.signupPreflight(body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("account-status")
|
||||
@ApiOperation({ summary: "Get account status by email" })
|
||||
|
||||
@ -26,6 +26,7 @@ import { User as SharedUser } from "@customer-portal/shared";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import type { Request } from "express";
|
||||
import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types";
|
||||
import { PrismaService } from "../common/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -42,6 +43,7 @@ export class AuthService {
|
||||
private auditService: AuditService,
|
||||
private tokenBlacklistService: TokenBlacklistService,
|
||||
private emailService: EmailService,
|
||||
private prisma: PrismaService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -200,16 +202,13 @@ export class AuthService {
|
||||
// Enhanced input validation
|
||||
this.validateSignupData(signupData);
|
||||
|
||||
// Check if a portal user already exists
|
||||
// Check if a portal user already exists (do not create anything yet)
|
||||
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
// Determine whether the user has a completed mapping (registered) or not
|
||||
const mapped = await this.mappingsService.hasMapping(existingUser.id);
|
||||
|
||||
const message = mapped
|
||||
? "You already have an account. Please sign in."
|
||||
: "You already have an account with us. Please sign in to continue setup.";
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
existingUser.id,
|
||||
@ -221,7 +220,7 @@ export class AuthService {
|
||||
throw new ConflictException(message);
|
||||
}
|
||||
|
||||
// Hash password with environment-based configuration
|
||||
// Hash password with environment-based configuration (computed ahead, used after WHMCS success)
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
@ -237,24 +236,10 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Create user in portal
|
||||
const user: SharedUser = await this.usersService.create({
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
phone,
|
||||
emailVerified: false,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
});
|
||||
|
||||
// 2. Create client in WHMCS
|
||||
// 1. Create client in WHMCS first (no portal user is created yet)
|
||||
let whmcsClient: { clientId: number };
|
||||
try {
|
||||
// 2.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
|
||||
// 1.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
|
||||
try {
|
||||
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
if (existingWhmcs) {
|
||||
@ -348,51 +333,58 @@ export class AuthService {
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
// Rollback: Delete the portal user since WHMCS creation failed
|
||||
try {
|
||||
// Note: We should add a delete method to UsersService, but for now use direct approach
|
||||
this.logger.warn("WHMCS creation failed, user account created but not fully integrated", {
|
||||
userId: user.id,
|
||||
email,
|
||||
whmcsError: getErrorMessage(whmcsError),
|
||||
});
|
||||
} catch (rollbackError) {
|
||||
this.logger.error("Failed to log rollback information", {
|
||||
userId: user.id,
|
||||
email,
|
||||
rollbackError: getErrorMessage(rollbackError),
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Store ID mappings
|
||||
await this.mappingsService.createMapping({
|
||||
userId: user.id,
|
||||
whmcsClientId: whmcsClient.clientId,
|
||||
sfAccountId: sfAccount.id,
|
||||
// 2. Only now create the portal user and mapping atomically
|
||||
const { createdUserId } = await this.prisma.$transaction(async tx => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
company: company || null,
|
||||
phone: phone || null,
|
||||
emailVerified: false,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
await tx.idMapping.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
whmcsClientId: whmcsClient.clientId,
|
||||
sfAccountId: sfAccount.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { createdUserId: created.id };
|
||||
});
|
||||
|
||||
// 4. Do not update Salesforce Account fields from the portal. Salesforce stays authoritative.
|
||||
// Fetch sanitized user response
|
||||
const freshUser = await this.usersService.findByIdInternal(createdUserId);
|
||||
|
||||
// Log successful signup
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
user.id,
|
||||
createdUserId,
|
||||
{ email, whmcsClientId: whmcsClient.clientId },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
const tokens = this.generateTokens(user);
|
||||
const tokens = this.generateTokens({ id: createdUserId, email });
|
||||
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
user: this.sanitizeUser(
|
||||
freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser)
|
||||
),
|
||||
...tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
@ -777,7 +769,7 @@ export class AuthService {
|
||||
|
||||
// Create SSO token using custom redirect for better compatibility
|
||||
const ssoDestination = "sso:custom_redirect";
|
||||
const ssoRedirectPath = destination || "clientarea.php";
|
||||
const ssoRedirectPath = this.sanitizeWhmcsRedirectPath(destination);
|
||||
|
||||
const result = await this.whmcsService.createSsoToken(
|
||||
mapping.whmcsClientId,
|
||||
@ -798,6 +790,30 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure only safe, relative WHMCS paths are allowed for SSO redirects.
|
||||
* Falls back to 'clientarea.php' when input is missing or unsafe.
|
||||
*/
|
||||
private sanitizeWhmcsRedirectPath(path?: string): string {
|
||||
// Default
|
||||
const fallback = "clientarea.php";
|
||||
if (!path || typeof path !== "string") return fallback;
|
||||
const p = path.trim();
|
||||
// Disallow absolute URLs and protocol-like prefixes
|
||||
if (/^https?:\/\//i.test(p) || /^\/\//.test(p) || /^sso:/i.test(p)) {
|
||||
return fallback;
|
||||
}
|
||||
// Allow only known entry points; expand as needed
|
||||
const allowedStarts = [
|
||||
"clientarea.php",
|
||||
"index.php?rp=/account/paymentmethods",
|
||||
"viewinvoice.php",
|
||||
"dl.php",
|
||||
];
|
||||
const isAllowed = allowedStarts.some(prefix => p.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||
return isAllowed ? p : fallback;
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
// Always act as if successful to avoid account enumeration
|
||||
@ -985,6 +1001,121 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight validation for signup. No side effects.
|
||||
* Returns a clear nextAction for the UI and detailed flags.
|
||||
*/
|
||||
async signupPreflight(signupData: SignupDto) {
|
||||
const { email, sfNumber } = signupData;
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
const result: {
|
||||
ok: boolean;
|
||||
canProceed: boolean;
|
||||
nextAction:
|
||||
| "proceed_signup"
|
||||
| "link_whmcs"
|
||||
| "login"
|
||||
| "fix_input"
|
||||
| "blocked";
|
||||
messages: string[];
|
||||
normalized: { email: string };
|
||||
portal: { userExists: boolean; needsPasswordSet?: boolean };
|
||||
salesforce: { accountId?: string; alreadyMapped: boolean };
|
||||
whmcs: { clientExists: boolean; clientId?: number };
|
||||
} = {
|
||||
ok: true,
|
||||
canProceed: false,
|
||||
nextAction: "blocked",
|
||||
messages: [],
|
||||
normalized: { email: normalizedEmail },
|
||||
portal: { userExists: false },
|
||||
salesforce: { alreadyMapped: false },
|
||||
whmcs: { clientExists: false },
|
||||
};
|
||||
|
||||
// 0) Portal user existence
|
||||
const portalUser = await this.usersService.findByEmailInternal(normalizedEmail);
|
||||
if (portalUser) {
|
||||
result.portal.userExists = true;
|
||||
const mapped = await this.mappingsService.hasMapping(portalUser.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("An account already exists. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy unmapped user
|
||||
result.portal.needsPasswordSet = !portalUser.passwordHash;
|
||||
result.nextAction = portalUser.passwordHash ? "login" : "fix_input";
|
||||
result.messages.push(
|
||||
portalUser.passwordHash
|
||||
? "An account exists without billing link. Please sign in to continue setup."
|
||||
: "An account exists and needs password setup. Please set a password to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1) Salesforce checks
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||
if (!sfAccount) {
|
||||
result.nextAction = "fix_input";
|
||||
result.messages.push("Customer number not found in Salesforce");
|
||||
return result;
|
||||
}
|
||||
result.salesforce.accountId = sfAccount.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||
if (existingMapping) {
|
||||
result.salesforce.alreadyMapped = true;
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This customer number is already registered. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2) WHMCS checks by email
|
||||
try {
|
||||
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
||||
if (client) {
|
||||
result.whmcs.clientExists = true;
|
||||
result.whmcs.clientId = client.id;
|
||||
// If this WHMCS client is already linked to a portal user, direct to login
|
||||
try {
|
||||
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This billing account is already linked. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore; treat as unmapped
|
||||
}
|
||||
|
||||
// Client exists but not mapped → suggest linking instead of creating new
|
||||
result.nextAction = "link_whmcs";
|
||||
result.messages.push(
|
||||
"We found an existing billing account for this email. Please link your account."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
// NotFoundException is expected when client doesn't exist. Other errors are reported.
|
||||
if (!(err instanceof NotFoundException)) {
|
||||
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
|
||||
result.messages.push("Unable to verify billing system. Please try again later.");
|
||||
result.nextAction = "blocked";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here: no portal user, SF valid and not mapped, no WHMCS client → OK to proceed
|
||||
result.canProceed = true;
|
||||
result.nextAction = "proceed_signup";
|
||||
result.messages.push("All checks passed. Ready to create your account.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private validateSignupData(signupData: SignupDto) {
|
||||
const { email, password, firstName, lastName } = signupData;
|
||||
|
||||
|
||||
@ -74,10 +74,12 @@ export class SignupDto {
|
||||
@IsString()
|
||||
company?: string;
|
||||
|
||||
@ApiProperty({ example: "+1-555-123-4567", required: false })
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: "+81-90-1234-5678", description: "Contact phone number" })
|
||||
@IsString()
|
||||
phone?: string;
|
||||
@Matches(/^[+]?[0-9\s\-()]{7,20}$/, {
|
||||
message: "Phone number must contain 7-20 digits and may include +, spaces, dashes, and parentheses",
|
||||
})
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({ example: "CN-0012345", description: "Customer Number (SF Number)" })
|
||||
@IsString()
|
||||
|
||||
@ -35,6 +35,14 @@ export const envSchema = z.object({
|
||||
WHMCS_API_SECRET: z.string().optional(),
|
||||
WHMCS_API_ACCESS_KEY: z.string().optional(),
|
||||
WHMCS_WEBHOOK_SECRET: z.string().optional(),
|
||||
// WHMCS Dev-only overrides (used when NODE_ENV !== 'production')
|
||||
WHMCS_DEV_BASE_URL: z.string().url().optional(),
|
||||
WHMCS_DEV_API_IDENTIFIER: z.string().optional(),
|
||||
WHMCS_DEV_API_SECRET: z.string().optional(),
|
||||
WHMCS_DEV_API_ACCESS_KEY: z.string().optional(),
|
||||
WHMCS_DEV_WEBHOOK_SECRET: z.string().optional(),
|
||||
WHMCS_DEV_ADMIN_USERNAME: z.string().optional(),
|
||||
WHMCS_DEV_ADMIN_PASSWORD_MD5: z.string().optional(),
|
||||
// Optional elevated admin credentials for privileged WHMCS actions (eg. AcceptOrder)
|
||||
WHMCS_ADMIN_USERNAME: z.string().optional(),
|
||||
// Expect MD5 hash of the admin password (preferred). Alias supported for compatibility.
|
||||
|
||||
@ -89,13 +89,14 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// Global validation pipe with enhanced security
|
||||
const exposeValidation = configService.get("EXPOSE_VALIDATION_ERRORS", "false") === "true";
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
forbidUnknownValues: true,
|
||||
disableErrorMessages: configService.get("NODE_ENV") === "production",
|
||||
disableErrorMessages: !exposeValidation && configService.get("NODE_ENV") === "production",
|
||||
validationError: {
|
||||
target: false,
|
||||
value: false,
|
||||
|
||||
@ -46,24 +46,83 @@ export class WhmcsConnectionService {
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
// Helper: read the first defined value across a list of keys
|
||||
const getFirst = (keys: Array<string | undefined>): string | undefined => {
|
||||
for (const key of keys) {
|
||||
if (!key) continue;
|
||||
const v = this.configService.get<string | undefined>(key);
|
||||
if (v && `${v}`.length > 0) return v;
|
||||
const raw = process.env[key];
|
||||
if (raw && `${raw}`.length > 0) return raw;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
|
||||
const isDev = nodeEnv !== "production"; // treat anything non-prod as dev/test
|
||||
|
||||
// Prefer explicit DEV variables when running in non-production
|
||||
// Resolve and normalize base URL (trim trailing slashes)
|
||||
const rawBaseUrl = getFirst([
|
||||
isDev ? "WHMCS_DEV_BASE_URL" : undefined,
|
||||
"WHMCS_BASE_URL",
|
||||
]) || "";
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
const identifier = getFirst([
|
||||
isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined,
|
||||
"WHMCS_API_IDENTIFIER",
|
||||
]) || "";
|
||||
|
||||
const secret = getFirst([
|
||||
isDev ? "WHMCS_DEV_API_SECRET" : undefined,
|
||||
"WHMCS_API_SECRET",
|
||||
]) || "";
|
||||
|
||||
const adminUsername = getFirst([
|
||||
isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined,
|
||||
"WHMCS_ADMIN_USERNAME",
|
||||
]);
|
||||
|
||||
const adminPasswordHash = getFirst([
|
||||
isDev ? "WHMCS_DEV_ADMIN_PASSWORD_MD5" : undefined,
|
||||
"WHMCS_ADMIN_PASSWORD_MD5",
|
||||
"WHMCS_ADMIN_PASSWORD_HASH",
|
||||
]);
|
||||
|
||||
const accessKey = getFirst([
|
||||
isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined,
|
||||
"WHMCS_API_ACCESS_KEY",
|
||||
]);
|
||||
|
||||
this.config = {
|
||||
baseUrl: this.configService.get<string>("WHMCS_BASE_URL", ""),
|
||||
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
|
||||
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
|
||||
baseUrl,
|
||||
identifier,
|
||||
secret,
|
||||
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
|
||||
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 1),
|
||||
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
|
||||
adminUsername: this.configService.get<string | undefined>("WHMCS_ADMIN_USERNAME"),
|
||||
adminPasswordHash:
|
||||
this.configService.get<string | undefined>("WHMCS_ADMIN_PASSWORD_MD5") ||
|
||||
this.configService.get<string | undefined>("WHMCS_ADMIN_PASSWORD_HASH"),
|
||||
adminUsername,
|
||||
adminPasswordHash,
|
||||
};
|
||||
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
|
||||
this.accessKey = this.configService.get<string | undefined>("WHMCS_API_ACCESS_KEY");
|
||||
this.accessKey = accessKey;
|
||||
|
||||
if (isDev) {
|
||||
const usingDev = [baseUrl, identifier, secret].every(v => !!v) && process.env.WHMCS_DEV_BASE_URL;
|
||||
if (usingDev) {
|
||||
this.logger.debug("Using WHMCS DEV environment variables (development mode)");
|
||||
}
|
||||
}
|
||||
|
||||
this.validateConfig();
|
||||
}
|
||||
|
||||
/** Expose the resolved base URL for helper services (SSO URL resolution) */
|
||||
getBaseUrl(): string {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
private validateConfig(): void {
|
||||
const requiredFields = ["baseUrl", "identifier", "secret"];
|
||||
const missingFields = requiredFields.filter(
|
||||
|
||||
@ -189,9 +189,11 @@ export class WhmcsPaymentService {
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
this.debugLogRedirectHost(url);
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec)
|
||||
};
|
||||
|
||||
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
|
||||
@ -247,4 +249,36 @@ export class WhmcsPaymentService {
|
||||
transformProduct(whmcsProduct: Record<string, unknown>): unknown {
|
||||
return this.dataTransformer.transformProduct(whmcsProduct);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
|
||||
*/
|
||||
private resolveRedirectUrl(redirectUrl: string): string {
|
||||
if (!redirectUrl) return redirectUrl;
|
||||
const base = this.connectionService.getBaseUrl().replace(/\/+$/, "");
|
||||
const isAbsolute = /^https?:\/\//i.test(redirectUrl);
|
||||
if (!isAbsolute) {
|
||||
const path = redirectUrl.replace(/^\/+/, "");
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
return redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
|
||||
*/
|
||||
private debugLogRedirectHost(url: string): void {
|
||||
if (process.env.NODE_ENV === "production") return;
|
||||
try {
|
||||
const target = new URL(url);
|
||||
const base = new URL(this.connectionService.getBaseUrl());
|
||||
this.logger.debug("WHMCS Payment SSO redirect host", {
|
||||
redirectHost: target.host,
|
||||
redirectOrigin: target.origin,
|
||||
baseOrigin: base.origin,
|
||||
});
|
||||
} catch {
|
||||
// Ignore parse errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,9 +28,11 @@ export class WhmcsSsoService {
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
this.debugLogRedirectHost(url);
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec)
|
||||
};
|
||||
|
||||
this.logger.log(`Created SSO token for client ${clientId}`, {
|
||||
@ -82,8 +84,10 @@ export class WhmcsSsoService {
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
// Return the 60s, one-time URL
|
||||
return response.redirect_url;
|
||||
// Return the 60s, one-time URL (resolved to absolute)
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
this.debugLogRedirectHost(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,9 +105,11 @@ export class WhmcsSsoService {
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
this.debugLogRedirectHost(url);
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds
|
||||
};
|
||||
|
||||
this.logger.log(`Created admin SSO token for client ${clientId}`, {
|
||||
@ -154,9 +160,11 @@ export class WhmcsSsoService {
|
||||
|
||||
const response = await this.connectionService.createSsoToken(ssoParams);
|
||||
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
this.debugLogRedirectHost(url);
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
url,
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds
|
||||
};
|
||||
|
||||
this.logger.log(`Created module SSO token for client ${clientId}`, {
|
||||
@ -175,4 +183,38 @@ export class WhmcsSsoService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the returned redirect URL is absolute and points to the active WHMCS base URL.
|
||||
* WHMCS typically returns an absolute URL, but we normalize in case it's relative.
|
||||
*/
|
||||
private resolveRedirectUrl(redirectUrl: string): string {
|
||||
if (!redirectUrl) return redirectUrl;
|
||||
const base = this.connectionService.getBaseUrl().replace(/\/+$/, "");
|
||||
const isAbsolute = /^https?:\/\//i.test(redirectUrl);
|
||||
if (!isAbsolute) {
|
||||
const path = redirectUrl.replace(/^\/+/, "");
|
||||
return `${base}/${path}`;
|
||||
}
|
||||
// Absolute URL returned by WHMCS — return as-is
|
||||
return redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
|
||||
*/
|
||||
private debugLogRedirectHost(url: string): void {
|
||||
if (process.env.NODE_ENV === "production") return;
|
||||
try {
|
||||
const target = new URL(url);
|
||||
const base = new URL(this.connectionService.getBaseUrl());
|
||||
this.logger.debug("WHMCS SSO redirect host", {
|
||||
redirectHost: target.host,
|
||||
redirectOrigin: target.origin,
|
||||
baseOrigin: base.origin,
|
||||
});
|
||||
} catch {
|
||||
// Ignore parse errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
env/portal-backend.env.sample
vendored
5
env/portal-backend.env.sample
vendored
@ -29,6 +29,9 @@ RATE_LIMIT_LIMIT=100
|
||||
AUTH_RATE_LIMIT_TTL=900000
|
||||
AUTH_RATE_LIMIT_LIMIT=3
|
||||
|
||||
# Validation error visibility (set true to show field-level errors to clients)
|
||||
EXPOSE_VALIDATION_ERRORS=false
|
||||
|
||||
# WHMCS Credentials
|
||||
WHMCS_BASE_URL=https://accounts.asolutions.co.jp
|
||||
WHMCS_API_IDENTIFIER=
|
||||
@ -88,3 +91,5 @@ NODE_OPTIONS=--max-old-space-size=512
|
||||
|
||||
# NOTE: Frontend (Next.js) uses a separate env file (portal-frontend.env)
|
||||
# Do not include NEXT_PUBLIC_* variables here.
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user