- Password must be at least 8 characters and include uppercase, lowercase, number, and special
- character.
+ Password must be at least 8 characters and include uppercase, lowercase, and a number.
);
diff --git a/apps/portal/src/features/auth/components/PasswordRequirements.tsx b/apps/portal/src/features/auth/components/PasswordRequirements.tsx
index 62c5a2ba..7f3c2a3f 100644
--- a/apps/portal/src/features/auth/components/PasswordRequirements.tsx
+++ b/apps/portal/src/features/auth/components/PasswordRequirements.tsx
@@ -30,7 +30,7 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi
if (showHint) {
return (
- At least 8 characters with uppercase, lowercase, numbers, and a special character
+ At least 8 characters with uppercase, lowercase, and numbers
);
}
@@ -41,7 +41,6 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi
-
);
}
diff --git a/apps/portal/src/features/auth/hooks/usePasswordValidation.ts b/apps/portal/src/features/auth/hooks/usePasswordValidation.ts
index 149363a5..94e440fb 100644
--- a/apps/portal/src/features/auth/hooks/usePasswordValidation.ts
+++ b/apps/portal/src/features/auth/hooks/usePasswordValidation.ts
@@ -5,7 +5,6 @@ export interface PasswordChecks {
hasUppercase: boolean;
hasLowercase: boolean;
hasNumber: boolean;
- hasSpecialChar: boolean;
}
export interface PasswordValidation {
@@ -20,7 +19,6 @@ export function validatePasswordRules(password: string): string | undefined {
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter";
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter";
if (!/[0-9]/.test(password)) return "Password must contain a number";
- if (!/[^A-Za-z0-9]/.test(password)) return "Password must contain a special character";
return undefined;
}
@@ -31,15 +29,10 @@ export function usePasswordValidation(password: string): PasswordValidation {
hasUppercase: /[A-Z]/.test(password),
hasLowercase: /[a-z]/.test(password),
hasNumber: /[0-9]/.test(password),
- hasSpecialChar: /[^A-Za-z0-9]/.test(password),
};
const isValid =
- checks.minLength &&
- checks.hasUppercase &&
- checks.hasLowercase &&
- checks.hasNumber &&
- checks.hasSpecialChar;
+ checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasNumber;
const error = validatePasswordRules(password);
return { checks, isValid, error };
diff --git a/docs/plans/2026-03-02-get-started-login-handoff-plan.md b/docs/plans/2026-03-02-get-started-login-handoff-plan.md
index 5916aeb4..e8c8b0cf 100644
--- a/docs/plans/2026-03-02-get-started-login-handoff-plan.md
+++ b/docs/plans/2026-03-02-get-started-login-handoff-plan.md
@@ -8,6 +8,16 @@
**Tech Stack:** NestJS (BFF), Zod (validation), Redis (sessions), Argon2 (password), XState (frontend state), React (portal)
+**Security Hardening (applied throughout):**
+
+- Session tokens never logged in full — only last 4 chars (`sessionTokenSuffix`) to prevent log exfiltration
+- All auth error responses use a generic "Login failed" message (specific reasons in server logs only) to prevent state enumeration
+- `check-session` returns masked email (`ba***@example.com`), not the full address, to limit information disclosure
+- Password validation runs BEFORE session consumption — wrong passwords don't burn the one-time session, allowing retries (account lockout in `AuthLoginService` protects against brute-force)
+- `gs_verified` cookie cleared on success only — preserved on wrong password so user can retry (stale cookies auto-expire via 5min `maxAge` and `check-session` cleanup)
+- Email removed from redirect URL — login page gets it from `check-session` cookie response, avoiding leakage into browser history, logs, and referrer headers
+- `"login"` added to `SessionData.usedFor` union type for session tracking
+
---
### Task 1: Add domain schemas and types for login handoff
@@ -45,8 +55,8 @@ export const getStartedLoginRequestSchema = z.object({
export const checkSessionResponseSchema = z.object({
/** Whether a valid verified session exists */
valid: z.boolean(),
- /** Email from the session (only present when valid) */
- email: z.string().optional(),
+ /** Masked email from the session, e.g. "ba***@example.com" (only present when valid) */
+ maskedEmail: z.string().optional(),
});
```
@@ -67,6 +77,14 @@ Add types after `MigrateWhmcsAccountRequest` (after line 94):
```typescript
export type GetStartedLoginRequest = z.infer;
export type CheckSessionResponse = z.infer;
+
+/** Mask an email address for safe display (e.g. "ba***@example.com") */
+export function maskEmail(email: string): string {
+ const [local, domain] = email.split("@");
+ if (!local || !domain) return "***@***";
+ const visible = local.slice(0, Math.min(2, local.length));
+ return `${visible}${"*".repeat(Math.max(0, local.length - 2))}@${domain}`;
+}
```
**Step 3: Add exports to `packages/domain/get-started/index.ts`**
@@ -76,6 +94,7 @@ Add to the contract exports section (after `type GetStartedError` on line 37):
```typescript
type GetStartedLoginRequest,
type CheckSessionResponse,
+ maskEmail,
```
Add to the schemas export section (after `getStartedSessionSchema` on line 64):
@@ -108,96 +127,14 @@ git commit -m "feat(domain): add login handoff schemas for portal_exists OTP byp
**Step 1: Create the workflow service**
+> **Note:** This initial version uses `AuthOrchestrator` which creates a circular dependency. See the **revised version in Task 3 Step 1** which replaces it with `GenerateAuthResultStep`. The revised version is the one to implement.
+
This service validates a verified get-started session, checks the user's password, and completes the login. It reuses `AuthLoginService` for password validation and `AuthOrchestrator.completeLogin()` for token generation.
+**Prerequisite:** Add `"login"` to the `usedFor` union type in `apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts` (line 25):
+
```typescript
-/**
- * Get-Started Login Workflow
- *
- * Handles the login-via-verified-session flow for portal_exists users.
- * After OTP verification in the get-started flow, this service validates
- * the user's password and completes login without requiring a second OTP.
- */
-import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
-import { Logger } from "nestjs-pino";
-import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started";
-
-import { GetStartedSessionService } from "../otp/get-started-session.service.js";
-import { AuthLoginService } from "../../application/auth-login.service.js";
-import { AuthOrchestrator } from "../../application/auth-orchestrator.service.js";
-import type { AuthResultInternal } from "../../auth.types.js";
-
-@Injectable()
-export class GetStartedLoginWorkflowService {
- constructor(
- private readonly sessionService: GetStartedSessionService,
- private readonly loginService: AuthLoginService,
- private readonly authOrchestrator: AuthOrchestrator,
- @Inject(Logger) private readonly logger: Logger
- ) {}
-
- /**
- * Validate a verified get-started session and check password.
- * Returns auth tokens on success.
- *
- * @param sessionToken - From the gs_verified cookie
- * @param password - User's portal password
- */
- async execute(sessionToken: string, password: string): Promise {
- // 1. Acquire lock and mark session as used (prevents replay)
- const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login");
-
- if (!lockResult.success) {
- this.logger.warn(
- { sessionToken, reason: lockResult.reason },
- "Login handoff session invalid"
- );
- throw new UnauthorizedException("Session is invalid or has already been used");
- }
-
- const session = lockResult.session;
-
- // 2. Verify the session was for a portal_exists account
- if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
- this.logger.warn(
- { sessionToken, accountStatus: session.accountStatus },
- "Login handoff session has wrong account status"
- );
- throw new UnauthorizedException("Session is not valid for login");
- }
-
- // 3. Validate password via existing login service (handles lockout, audit, etc.)
- const validatedUser = await this.loginService.validateUser(session.email, password);
-
- if (!validatedUser) {
- throw new UnauthorizedException("Invalid password");
- }
-
- // 4. Complete login (generates tokens, updates lastLoginAt, audit log)
- this.logger.log({ email: session.email }, "Login handoff successful — completing login");
- return this.authOrchestrator.completeLogin(validatedUser);
- }
-
- /**
- * Check if a session token is valid for login handoff.
- * Does NOT consume the session.
- */
- async checkSession(
- sessionToken: string
- ): Promise<{ valid: true; email: string } | { valid: false }> {
- const session = await this.sessionService.validateVerifiedSession(sessionToken);
-
- if (!session) {
- return { valid: false };
- }
-
- if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
- return { valid: false };
- }
-
- return { valid: true, email: session.email };
- }
-}
+usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account" | "login";
```
**Step 2: Verify types compile**
@@ -275,7 +212,7 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
```typescript
import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
-import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started";
+import { ACCOUNT_STATUS, maskEmail } from "@customer-portal/domain/get-started";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
import { AuthLoginService } from "../../application/auth-login.service.js";
@@ -296,46 +233,61 @@ export class GetStartedLoginWorkflowService {
) {}
async execute(sessionToken: string, password: string): Promise {
- // 1. Acquire lock and mark session as used (prevents replay)
- const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login");
+ // 1. Validate session exists and is verified (without consuming it)
+ const session = await this.sessionService.validateVerifiedSession(sessionToken);
- if (!lockResult.success) {
+ if (!session) {
this.logger.warn(
- { sessionToken, reason: lockResult.reason },
- "Login handoff session invalid"
+ { sessionTokenSuffix: sessionToken.slice(-4) },
+ "Login handoff session invalid or expired"
);
- throw new UnauthorizedException("Session is invalid or has already been used");
+ throw new UnauthorizedException("Login failed");
}
- const session = lockResult.session;
-
// 2. Verify the session was for a portal_exists account
if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
this.logger.warn(
- { sessionToken, accountStatus: session.accountStatus },
+ { sessionTokenSuffix: sessionToken.slice(-4), accountStatus: session.accountStatus },
"Login handoff session has wrong account status"
);
- throw new UnauthorizedException("Session is not valid for login");
+ throw new UnauthorizedException("Login failed");
}
- // 3. Validate password (handles lockout, audit, failed attempt tracking)
+ // 3. Validate password FIRST (handles lockout, audit, failed attempt tracking)
+ // This does NOT consume the session — wrong passwords allow retries.
+ // Brute-force protection comes from AuthLoginService's account lockout.
const validatedUser = await this.loginService.validateUser(session.email, password);
if (!validatedUser) {
- throw new UnauthorizedException("Invalid password");
+ this.logger.warn(
+ { sessionTokenSuffix: sessionToken.slice(-4), email: session.email },
+ "Login handoff password validation failed"
+ );
+ throw new UnauthorizedException("Login failed");
}
- // 4. Update lastLoginAt and reset failed attempts
+ // 4. Password correct — now consume the session atomically (prevents replay)
+ const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login");
+
+ if (!lockResult.success) {
+ this.logger.warn(
+ { sessionTokenSuffix: sessionToken.slice(-4), reason: lockResult.reason },
+ "Login handoff session consumed during login (race condition)"
+ );
+ throw new UnauthorizedException("Login failed");
+ }
+
+ // 5. Update lastLoginAt and reset failed attempts
await this.usersService.update(validatedUser.id, {
lastLoginAt: new Date(),
failedLoginAttempts: 0,
lockedUntil: null,
});
- // 5. Generate auth tokens
+ // 6. Generate auth tokens
const result = await this.generateAuthResult.execute(validatedUser.id);
- // 6. Audit log
+ // 7. Audit log
await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, {
email: session.email,
method: "get_started_handoff",
@@ -345,16 +297,21 @@ export class GetStartedLoginWorkflowService {
return result;
}
+ /**
+ * Check if a session token is valid for login handoff.
+ * Does NOT consume the session.
+ * Returns masked email to limit information disclosure.
+ */
async checkSession(
sessionToken: string
- ): Promise<{ valid: true; email: string } | { valid: false }> {
+ ): Promise<{ valid: true; maskedEmail: string } | { valid: false }> {
const session = await this.sessionService.validateVerifiedSession(sessionToken);
if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
return { valid: false };
}
- return { valid: true, email: session.email };
+ return { valid: true, maskedEmail: maskEmail(session.email) };
}
}
```
@@ -431,13 +388,16 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
```typescript
/**
- * Check if a valid get-started handoff session exists
- * Used by the login page to detect OTP-bypass mode
+ * Check if a valid get-started handoff session exists.
+ * Used by the login page to detect OTP-bypass mode.
+ * Returns masked email to limit information disclosure.
+ * Clears the stale cookie if the session is no longer valid.
*/
@Public()
@Get("check-session")
async checkSession(
- @Req() req: Request
+ @Req() req: Request,
+ @Res({ passthrough: true }) res: Response
): Promise {
const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
@@ -445,7 +405,14 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
return { valid: false };
}
- return this.loginWorkflow.checkSession(sessionToken);
+ const result = await this.loginWorkflow.checkSession(sessionToken);
+
+ // Clear stale cookie if session is no longer valid
+ if (!result.valid) {
+ res.clearCookie("gs_verified", { path: "/api/auth/get-started" });
+ }
+
+ return result;
}
/**
@@ -454,6 +421,11 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
* For portal_exists users after OTP verification in get-started flow.
* Session token comes from the gs_verified HttpOnly cookie.
* Skips login OTP since email was already verified via get-started OTP.
+ *
+ * Security: all errors return generic "Login failed" to prevent state enumeration.
+ * The gs_verified cookie is cleared only on SUCCESS. On failure (e.g. wrong
+ * password), the cookie is preserved so the user can retry. Stale cookies
+ * are harmless — they auto-expire (5min maxAge) and check-session cleans them.
*/
@Public()
@Post("login")
@@ -468,7 +440,7 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
if (!sessionToken) {
- throw new UnauthorizedException("No verified session found");
+ throw new UnauthorizedException("Login failed");
}
const result = await this.loginWorkflow.execute(sessionToken, body.password);
@@ -476,10 +448,9 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl
// Set auth cookies
setAuthCookies(res, result.tokens);
- // Clear the handoff cookie
- res.clearCookie("gs_verified", {
- path: "/api/auth/get-started",
- });
+ // Clear the handoff cookie only on success
+ // (on failure the cookie stays so the user can retry with correct password)
+ res.clearCookie("gs_verified", { path: "/api/auth/get-started" });
return {
user: result.user,
@@ -541,6 +512,9 @@ import {
type GetStartedLoginRequest,
type CheckSessionResponse,
} from "@customer-portal/domain/get-started";
+
+// Also import the auth response schema for the login function
+import { authResponseSchema } from "@customer-portal/domain/auth"; // adjust path to match existing pattern
```
Add two new functions at the bottom of the file (before the closing):
@@ -602,6 +576,10 @@ Replace the entire component. Both inline and full-page modes now redirect to th
* After OTP verification detects an existing account, the BFF sets
* a gs_verified HttpOnly cookie. This component redirects to the
* login page, which detects the cookie and shows password-only mode.
+ *
+ * Security: email is NOT included in the URL to avoid leaking into
+ * browser history, server logs, and referrer headers. The login page
+ * retrieves the (masked) email from the check-session endpoint instead.
*/
"use client";
@@ -613,18 +591,17 @@ import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
export function AccountStatusStep() {
const { state } = useGetStartedMachine();
- const { formData, redirectTo, serviceContext } = state.context;
+ const { redirectTo, serviceContext } = state.context;
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
useEffect(() => {
const loginUrl =
- `/auth/login?email=${encodeURIComponent(formData.email)}` +
- `&from=get-started` +
+ `/auth/login?from=get-started` +
`&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
window.location.href = loginUrl;
- }, [formData.email, effectiveRedirectTo]);
+ }, [effectiveRedirectTo]);
// Show loading state while redirect happens
return (
@@ -670,7 +647,7 @@ This is the most involved frontend change. The LoginForm needs to:
1. Detect `from=get-started` URL param on mount
2. Call `GET /auth/get-started/check-session` to validate the cookie
-3. If valid: show password-only mode (pre-filled email, no OTP step)
+3. If valid: show password-only mode (display masked email from response, no OTP step)
4. On submit: call `POST /auth/get-started/login` instead of normal login
5. If invalid: fall back to normal login flow
@@ -691,10 +668,10 @@ This depends on the exact LoginForm structure. The implementation should:
1. Add `useEffect` at the top of the component that checks for `from=get-started` URL param
2. If found, call `checkGetStartedSession()` from the get-started API
-3. If valid, set `handoffMode = true` and pre-fill email from response
+3. If valid, set `handoffMode = true` and store the masked email for display
4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode
-5. Hide the OTP step when in handoff mode
-6. Show an info banner: "Email verified — enter your password to sign in"
+5. Hide the email input and OTP step when in handoff mode
+6. Show an info banner with masked email: "Email verified for ba\*\*\*@example.com — enter your password to sign in"
Import the handoff API functions:
@@ -712,6 +689,12 @@ const [handoffMode, setHandoffMode] = useState(false);
const [handoffChecking, setHandoffChecking] = useState(false);
```
+Add state for the masked email display:
+
+```typescript
+const [handoffMaskedEmail, setHandoffMaskedEmail] = useState(null);
+```
+
Add useEffect for handoff detection (near mount):
```typescript
@@ -722,10 +705,10 @@ useEffect(() => {
setHandoffChecking(true);
checkGetStartedSession()
.then(result => {
- if (result.valid && result.email) {
+ if (result.valid && result.maskedEmail) {
setHandoffMode(true);
- // Pre-fill email from session
- form.setValue("email", result.email);
+ // Store masked email for display (not the full email — that stays in the cookie)
+ setHandoffMaskedEmail(result.maskedEmail);
}
})
.catch(() => {
@@ -750,24 +733,22 @@ if (handoffMode) {
// ... existing login flow
```
-When `handoffMode` is true, render an info banner above the password field:
+When `handoffMode` is true, render an info banner above the password field showing the masked email:
```typescript
-{handoffMode && (
+{handoffMode && handoffMaskedEmail && (
- Email verified — enter your password to sign in.
+ Email verified for {handoffMaskedEmail} — enter your password to sign in.
)}
```
-Make the email field read-only in handoff mode:
+Hide the email input entirely in handoff mode (the full email is never exposed to the frontend — only the masked version is shown in the banner above):
```typescript
-
+{!handoffMode && (
+
+)}
```
Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP).
diff --git a/packages/domain/auth/forms.ts b/packages/domain/auth/forms.ts
index eeac6e8f..44a02a84 100644
--- a/packages/domain/auth/forms.ts
+++ b/packages/domain/auth/forms.ts
@@ -18,7 +18,6 @@ export const PASSWORD_REQUIREMENTS = [
{ key: "uppercase", label: "One uppercase letter", regex: /[A-Z]/ },
{ key: "lowercase", label: "One lowercase letter", regex: /[a-z]/ },
{ key: "number", label: "One number", regex: /[0-9]/ },
- { key: "special", label: "One special character", regex: /[^A-Za-z0-9]/ },
] as const;
export type PasswordRequirementKey = (typeof PASSWORD_REQUIREMENTS)[number]["key"];
diff --git a/packages/domain/common/schema.ts b/packages/domain/common/schema.ts
index 4c0d18c8..1dacb7c3 100644
--- a/packages/domain/common/schema.ts
+++ b/packages/domain/common/schema.ts
@@ -18,8 +18,7 @@ export const passwordSchema = z
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
- .regex(/[0-9]/, "Password must contain at least one number")
- .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
+ .regex(/[0-9]/, "Password must contain at least one number");
export const nameSchema = z
.string()
diff --git a/packages/domain/customer/providers/whmcs/mapper.ts b/packages/domain/customer/providers/whmcs/mapper.ts
index 2fef2657..9a01d49e 100644
--- a/packages/domain/customer/providers/whmcs/mapper.ts
+++ b/packages/domain/customer/providers/whmcs/mapper.ts
@@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined {
country: client.country ?? null,
countryCode: client.countrycode ?? null,
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null,
- phoneCountryCode: client.phonecc ?? null,
+ phoneCountryCode: client.phonecc == null ? null : String(client.phonecc),
});
const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");