# 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-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 **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: ```typescript // ============================================================================ // 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): ```typescript import type { // ... existing imports ... getStartedLoginRequestSchema, checkSessionResponseSchema, } from "./schema.js"; ``` 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`** 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): ```typescript // 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** ```bash 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 `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 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): ```typescript import { GetStartedLoginWorkflowService } from "../infra/workflows/get-started-login-workflow.service.js"; ``` Add to the providers array (after `WhmcsMigrationWorkflowService` on line 51): ```typescript 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): ```typescript import { LoginModule } from "../login/login.module.js"; ``` And in the `imports` array: ```typescript 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: 1. `usersService.update()` — available via `UsersModule` 2. `auditService.logAuthEvent()` — available via `AuditModule` 3. `tokenService.generateTokenPair()` — available via `TokensModule` 4. `updateAccountLastSignIn()` — 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): ```typescript // 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`: ```typescript 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 { // 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): ```typescript import { // ... existing imports ... getStartedLoginRequestSchema, checkSessionResponseSchema, } from "@customer-portal/domain/get-started"; ``` Add DTO classes (after `MigrateWhmcsAccountRequestDto` around line 40): ```typescript class GetStartedLoginRequestDto extends createZodDto(getStartedLoginRequestSchema) {} class CheckSessionResponseDto extends createZodDto(checkSessionResponseSchema) {} ``` Import the new workflow service (after `GetStartedCoordinator` import on line 27): ```typescript import { GetStartedLoginWorkflowService } from "../../infra/workflows/get-started-login-workflow.service.js"; ``` Add to the controller constructor: ```typescript 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: ```typescript @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 { 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): ```typescript /** * 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 { 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 { 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): ```typescript import { Controller, Post, Get, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/common"; ``` Also add `UnauthorizedException`: ```typescript 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** ```bash 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`): ```typescript 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): ```typescript /** * Check if a valid get-started handoff session exists * Used by login page to detect OTP-bypass mode */ export async function checkGetStartedSession(): Promise { const response = await apiClient.GET(`${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 { const response = await apiClient.POST(`${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** ```bash 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): ```typescript /** * 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 (

Redirecting to sign in...

); } ``` 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: ```typescript return (

Redirecting to sign in...

); ``` **Step 2: Verify** Run: `pnpm type-check` Expected: Clean. No more `LoginForm` import needed. **Step 3: Commit** ```bash 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: 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 (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 **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: 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 store the masked email for display 4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode 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: ```typescript import { checkGetStartedSession, loginWithGetStartedSession, } from "@/features/get-started/api/get-started.api"; ``` Add state for handoff mode: ```typescript 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 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: ```typescript // 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: ```typescript {handoffMode && handoffMaskedEmail && (
Email verified for {handoffMaskedEmail} — enter your password to sign in.
)} ``` 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). **Step 3: Verify** Run: `pnpm type-check` Expected: Clean. **Step 4: Commit** ```bash 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)** ```bash 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.