- Removed special character requirement from password validation across various components and schemas. - Updated user-facing messages to reflect the new password criteria. - Adjusted related validation logic in the domain and portal to ensure consistency.
28 KiB
Get-Started Login Handoff Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: After OTP verification detects an existing portal account, hand off to the login page via a secure cookie so the user only enters their password (no second OTP).
Architecture: BFF sets an HttpOnly gs_verified cookie during verify-code when portal_exists is detected. Login page calls a check-session endpoint to detect handoff mode, then calls a new login endpoint that validates the session + password and issues auth tokens. Frontend redirects from get-started to login page instead of rendering inline LoginForm.
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-sessionreturns 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
AuthLoginServiceprotects against brute-force) gs_verifiedcookie cleared on success only — preserved on wrong password so user can retry (stale cookies auto-expire via 5minmaxAgeandcheck-sessioncleanup)- Email removed from redirect URL — login page gets it from
check-sessioncookie response, avoiding leakage into browser history, logs, and referrer headers "login"added toSessionData.usedForunion type for session tracking
Task 1: Add domain schemas and types for login handoff
Files:
- Modify:
packages/domain/get-started/schema.ts - Modify:
packages/domain/get-started/contract.ts - Modify:
packages/domain/get-started/index.ts
Step 1: Add schemas to packages/domain/get-started/schema.ts
Add after the migrateWhmcsAccountRequestSchema (after line 310), before the Session Schema section:
// ============================================================================
// Login Handoff Schemas (for portal_exists users after OTP verification)
// ============================================================================
/**
* Request to log in via verified get-started session
* Session token comes from gs_verified cookie, not request body
*/
export const getStartedLoginRequestSchema = z.object({
/** Password for the existing portal account */
password: z.string().min(1, "Password is required"),
/** Whether to set a trusted device cookie for future logins */
rememberDevice: z.boolean().optional().default(false),
});
/**
* Response from check-session endpoint
* Tells the login page whether a valid get-started handoff session exists
*/
export const checkSessionResponseSchema = z.object({
/** Whether a valid verified session exists */
valid: z.boolean(),
/** Masked email from the session, e.g. "ba***@example.com" (only present when valid) */
maskedEmail: z.string().optional(),
});
Step 2: Add types to packages/domain/get-started/contract.ts
Add the schema imports to the existing import block (line 14-28):
import type {
// ... existing imports ...
getStartedLoginRequestSchema,
checkSessionResponseSchema,
} from "./schema.js";
Add types after MigrateWhmcsAccountRequest (after line 94):
export type GetStartedLoginRequest = z.infer<typeof getStartedLoginRequestSchema>;
export type CheckSessionResponse = z.infer<typeof checkSessionResponseSchema>;
/** 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
Add to the contract exports section (after type GetStartedError on line 37):
type GetStartedLoginRequest,
type CheckSessionResponse,
maskEmail,
Add to the schemas export section (after getStartedSessionSchema on line 64):
// Login handoff schemas (for portal_exists OTP bypass)
getStartedLoginRequestSchema,
checkSessionResponseSchema,
Step 4: Build domain and verify
Run: pnpm domain:build && pnpm type-check
Expected: Clean build, no errors.
Step 5: Commit
git add packages/domain/get-started/
git commit -m "feat(domain): add login handoff schemas for portal_exists OTP bypass"
Task 2: Create the login handoff workflow service
Files:
- Create:
apps/bff/src/modules/auth/infra/workflows/get-started-login-workflow.service.ts
Step 1: Create the workflow service
Note: This initial version uses
AuthOrchestratorwhich creates a circular dependency. See the revised version in Task 3 Step 1 which replaces it withGenerateAuthResultStep. 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):
usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account" | "login";
Step 2: Verify types compile
Run: pnpm type-check
Expected: Clean — the new file is not imported yet so it won't affect compilation, but any import errors in the file itself will be caught when registered in the module (Task 3).
Task 3: Register workflow service and add controller endpoints
Files:
- Modify:
apps/bff/src/modules/auth/get-started/get-started.module.ts - Modify:
apps/bff/src/modules/auth/presentation/http/get-started.controller.ts
Step 1: Register the workflow in the module
In apps/bff/src/modules/auth/get-started/get-started.module.ts:
Add import after line 15 (after WhmcsMigrationWorkflowService import):
import { GetStartedLoginWorkflowService } from "../infra/workflows/get-started-login-workflow.service.js";
Add to the providers array (after WhmcsMigrationWorkflowService on line 51):
GetStartedLoginWorkflowService,
Add to the module imports array — the service needs AuthLoginService and AuthOrchestrator, which live in LoginModule and AuthModule respectively. Since GetStartedModule is imported by AuthModule, and LoginModule is also imported by AuthModule, we need LoginModule and the orchestrator available. Check: LoginModule exports AuthLoginService. The AuthOrchestrator is provided in AuthModule itself. To avoid circular dependencies, we'll inject AuthOrchestrator and AuthLoginService directly. LoginModule already exports AuthLoginService, and GetStartedModule already imports from AuthModule's sibling modules.
Add LoginModule to the imports array (after OtpModule on line 37):
import { LoginModule } from "../login/login.module.js";
And in the imports array:
LoginModule,
The AuthOrchestrator is provided in AuthModule but not exported. We need to either export it or inject it differently. Looking at the existing pattern: GetStartedModule doesn't currently depend on AuthOrchestrator. Let's check if we can avoid it.
Actually, looking more closely at the code, AuthOrchestrator.completeLogin() does:
usersService.update()— available viaUsersModuleauditService.logAuthEvent()— available viaAuditModuletokenService.generateTokenPair()— available viaTokensModuleupdateAccountLastSignIn()— SF call (nice-to-have)
The cleanest approach: have the workflow service call completeLogin directly using the same dependencies, without depending on AuthOrchestrator. But that duplicates logic.
Better approach: Export AuthOrchestrator from AuthModule. In apps/bff/src/modules/auth/auth.module.ts, it's already exported on line 46: exports: [AuthOrchestrator, TokensModule, SharedAuthModule]. So this should work via module resolution.
Wait — that creates a circular dependency: AuthModule imports GetStartedModule, and GetStartedModule would need AuthModule for AuthOrchestrator.
Simplest fix: Move the token generation into the workflow service directly, reusing AuthTokenService (already available via TokensModule) and UsersService (already available via UsersModule). This matches the pattern in WhmcsMigrationWorkflowService which uses GenerateAuthResultStep.
Let's use the existing GenerateAuthResultStep (already registered in GetStartedModule providers):
// In get-started-login-workflow.service.ts, replace AuthOrchestrator with:
import { GenerateAuthResultStep } from "./steps/index.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
Revised workflow service — update the execute method to use GenerateAuthResultStep + UsersService instead of AuthOrchestrator:
import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
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";
import { GenerateAuthResultStep } from "./steps/index.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import type { AuthResultInternal } from "../../auth.types.js";
@Injectable()
export class GetStartedLoginWorkflowService {
constructor(
private readonly sessionService: GetStartedSessionService,
private readonly loginService: AuthLoginService,
private readonly generateAuthResult: GenerateAuthResultStep,
private readonly usersService: UsersService,
private readonly auditService: AuditService,
@Inject(Logger) private readonly logger: Logger
) {}
async execute(sessionToken: string, password: string): Promise<AuthResultInternal> {
// 1. Validate session exists and is verified (without consuming it)
const session = await this.sessionService.validateVerifiedSession(sessionToken);
if (!session) {
this.logger.warn(
{ sessionTokenSuffix: sessionToken.slice(-4) },
"Login handoff session invalid or expired"
);
throw new UnauthorizedException("Login failed");
}
// 2. Verify the session was for a portal_exists account
if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
this.logger.warn(
{ sessionTokenSuffix: sessionToken.slice(-4), accountStatus: session.accountStatus },
"Login handoff session has wrong account status"
);
throw new UnauthorizedException("Login failed");
}
// 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) {
this.logger.warn(
{ sessionTokenSuffix: sessionToken.slice(-4), email: session.email },
"Login handoff password validation failed"
);
throw new UnauthorizedException("Login failed");
}
// 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,
});
// 6. Generate auth tokens
const result = await this.generateAuthResult.execute(validatedUser.id);
// 7. Audit log
await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, {
email: session.email,
method: "get_started_handoff",
});
this.logger.log({ email: session.email }, "Login handoff completed");
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; 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, maskedEmail: maskEmail(session.email) };
}
}
Step 2: Add controller endpoints
In apps/bff/src/modules/auth/presentation/http/get-started.controller.ts:
Add imports at top (after existing schema imports around line 24):
import {
// ... existing imports ...
getStartedLoginRequestSchema,
checkSessionResponseSchema,
} from "@customer-portal/domain/get-started";
Add DTO classes (after MigrateWhmcsAccountRequestDto around line 40):
class GetStartedLoginRequestDto extends createZodDto(getStartedLoginRequestSchema) {}
class CheckSessionResponseDto extends createZodDto(checkSessionResponseSchema) {}
Import the new workflow service (after GetStartedCoordinator import on line 27):
import { GetStartedLoginWorkflowService } from "../../infra/workflows/get-started-login-workflow.service.js";
Add to the controller constructor:
constructor(
private readonly workflow: GetStartedCoordinator,
private readonly loginWorkflow: GetStartedLoginWorkflowService
) {}
Modify the verifyCode method to set the handoff cookie when portal_exists is detected. Add @Res({ passthrough: true }) to the method signature:
@Public()
@Post("verify-code")
@HttpCode(200)
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 })
async verifyCode(
@Body() body: VerifyCodeRequestDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
): Promise<VerifyCodeResponseDto> {
const fingerprint = getRateLimitFingerprint(req);
const result = await this.workflow.verifyCode(body, fingerprint);
// Set handoff cookie for portal_exists users
if (result.verified && result.accountStatus === "portal_exists" && result.sessionToken) {
const isProduction = process.env["NODE_ENV"] === "production";
res.cookie("gs_verified", result.sessionToken, {
httpOnly: true,
secure: isProduction,
sameSite: "strict",
path: "/api/auth/get-started",
maxAge: 5 * 60 * 1000, // 5 minutes
});
}
return result;
}
Add the two new endpoints (after the migrateWhmcsAccount method, before the closing } of the class):
/**
* 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,
@Res({ passthrough: true }) res: Response
): Promise<CheckSessionResponseDto> {
const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
if (!sessionToken) {
return { valid: false };
}
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;
}
/**
* Login with verified get-started session + password
*
* 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")
@HttpCode(200)
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 })
async loginWithSession(
@Body() body: GetStartedLoginRequestDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
): Promise<AuthSuccessResponse> {
const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
if (!sessionToken) {
throw new UnauthorizedException("Login failed");
}
const result = await this.loginWorkflow.execute(sessionToken, body.password);
// Set auth cookies
setAuthCookies(res, result.tokens);
// 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,
session: buildSessionInfo(result.tokens),
};
}
Add the Get and UnauthorizedException imports from @nestjs/common (line 1):
import { Controller, Post, Get, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/common";
Also add UnauthorizedException:
import {
Controller,
Post,
Get,
Body,
UseGuards,
Res,
Req,
HttpCode,
UnauthorizedException,
} from "@nestjs/common";
Step 3: Build and verify
Run: pnpm domain:build && pnpm type-check
Expected: Clean build.
Step 4: Commit
git add apps/bff/src/modules/auth/
git commit -m "feat(bff): add login handoff endpoints and workflow service"
Task 4: Add get-started API client functions (portal)
Files:
- Modify:
apps/portal/src/features/get-started/api/get-started.api.ts
Step 1: Add API functions
Add imports for the new types (extend existing import from @customer-portal/domain/get-started):
import {
// ... existing imports ...
checkSessionResponseSchema,
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):
/**
* Check if a valid get-started handoff session exists
* Used by login page to detect OTP-bypass mode
*/
export async function checkGetStartedSession(): Promise<CheckSessionResponse> {
const response = await apiClient.GET<CheckSessionResponse>(`${BASE_PATH}/check-session`);
const data = getDataOrThrow(response, "Failed to check session");
return checkSessionResponseSchema.parse(data);
}
/**
* Login with verified get-started session + password
* Session token is sent via HttpOnly cookie automatically
*/
export async function loginWithGetStartedSession(
request: GetStartedLoginRequest
): Promise<AuthResponse> {
const response = await apiClient.POST<AuthResponse>(`${BASE_PATH}/login`, {
body: request,
});
const data = getDataOrThrow(response, "Failed to log in");
return authResponseSchema.parse(data);
}
Step 2: Verify
Run: pnpm type-check
Expected: Clean.
Step 3: Commit
git add apps/portal/src/features/get-started/api/get-started.api.ts
git commit -m "feat(portal): add login handoff API client functions"
Task 5: Update AccountStatusStep to redirect to login page
Files:
- Modify:
apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx
Step 1: Replace the component
Replace the entire component. Both inline and full-page modes now redirect to the login page (the cookie is already set by the BFF):
/**
* AccountStatusStep - Redirects portal_exists users to login page
*
* 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";
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
export function AccountStatusStep() {
const { state } = useGetStartedMachine();
const { redirectTo, serviceContext } = state.context;
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
useEffect(() => {
const loginUrl =
`/auth/login?from=get-started` +
`&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
window.location.href = loginUrl;
}, [effectiveRedirectTo]);
// Show loading state while redirect happens
return (
<div className="flex flex-col items-center justify-center space-y-4 py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Redirecting to sign in...</p>
</div>
);
}
Check if lucide-react is available (it should be since the project uses shadcn/ui). If not, use a simple spinner or the existing ArrowRightIcon spinner pattern. Alternatively, just use text:
return (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">Redirecting to sign in...</p>
</div>
);
Step 2: Verify
Run: pnpm type-check
Expected: Clean. No more LoginForm import needed.
Step 3: Commit
git add apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx
git commit -m "feat(portal): redirect portal_exists users to login page with cookie handoff"
Task 6: Adapt LoginForm for handoff mode
Files:
- Modify:
apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx
This is the most involved frontend change. The LoginForm needs to:
- Detect
from=get-startedURL param on mount - Call
GET /auth/get-started/check-sessionto validate the cookie - If valid: show password-only mode (display masked email from response, no OTP step)
- On submit: call
POST /auth/get-started/logininstead of normal login - If invalid: fall back to normal login flow
Step 1: Read the current LoginForm to understand its exact structure
Read: apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx
The key modification is adding a handoff mode that:
- Adds state:
handoffMode: boolean,handoffLoading: boolean - On mount: checks URL params, calls check-session API
- When
handoffMode=true: skips the OTP step, uses the handoff login API - When handoff fails: clears handoff state, falls back to normal
Step 2: Add the handoff detection and password-only login
This depends on the exact LoginForm structure. The implementation should:
- Add
useEffectat the top of the component that checks forfrom=get-startedURL param - If found, call
checkGetStartedSession()from the get-started API - If valid, set
handoffMode = trueand store the masked email for display - Replace the
handleLoginto callloginWithGetStartedSession({ password })when in handoff mode - Hide the email input and OTP step when in handoff mode
- Show an info banner with masked email: "Email verified for ba***@example.com — enter your password to sign in"
Import the handoff API functions:
import {
checkGetStartedSession,
loginWithGetStartedSession,
} from "@/features/get-started/api/get-started.api";
Add state for handoff mode:
const [handoffMode, setHandoffMode] = useState(false);
const [handoffChecking, setHandoffChecking] = useState(false);
Add state for the masked email display:
const [handoffMaskedEmail, setHandoffMaskedEmail] = useState<string | null>(null);
Add useEffect for handoff detection (near mount):
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("from") !== "get-started") return;
setHandoffChecking(true);
checkGetStartedSession()
.then(result => {
if (result.valid && result.maskedEmail) {
setHandoffMode(true);
// Store masked email for display (not the full email — that stays in the cookie)
setHandoffMaskedEmail(result.maskedEmail);
}
})
.catch(() => {
// Cookie invalid or expired — fall back to normal login
})
.finally(() => setHandoffChecking(false));
}, []);
In the login handler, add handoff path:
// Inside handleLogin or the form submit handler
if (handoffMode) {
const result = await loginWithGetStartedSession({
password: data.password,
rememberDevice: data.rememberMe ?? false,
});
// Handle success (redirect, update auth state)
return;
}
// ... existing login flow
When handoffMode is true, render an info banner above the password field showing the masked email:
{handoffMode && handoffMaskedEmail && (
<div className="rounded-lg bg-success/10 p-3 text-sm text-success">
Email verified for <strong>{handoffMaskedEmail}</strong> — enter your password to sign in.
</div>
)}
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):
{!handoffMode && (
<Input {...field} />
)}
Hide the OTP section when in handoff mode (the existing step === "otp" check should never trigger since we bypass OTP).
Step 3: Verify
Run: pnpm type-check
Expected: Clean.
Step 4: Commit
git add apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx
git commit -m "feat(portal): add handoff mode to LoginForm for get-started OTP bypass"
Task 7: Verify full flow and type-check
Step 1: Run full type-check
Run: pnpm domain:build && pnpm type-check
Expected: Clean build across all packages.
Step 2: Run lint
Run: pnpm lint
Expected: No new lint errors. Fix any that arise.
Step 3: Final commit (if any fixes needed)
git add -A
git commit -m "fix: address lint and type-check issues from login handoff"
Dependency Graph
Task 1 (domain schemas)
│
├──→ Task 2 (workflow service)
│ │
│ └──→ Task 3 (module + controller)
│ │
│ └──→ Task 7 (verify)
│
├──→ Task 4 (portal API client)
│ │
│ ├──→ Task 5 (AccountStatusStep redirect)
│ │
│ └──→ Task 6 (LoginForm handoff mode)
│ │
│ └──→ Task 7 (verify)
Tasks 2+3 (backend) and Tasks 4+5+6 (frontend) can be done in parallel after Task 1.