diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 277590e9..a76f8094 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -30,6 +30,7 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even // Feature Modules import { AuthModule } from "@bff/modules/auth/auth.module.js"; +import { GetStartedModule } from "@bff/modules/auth/get-started/get-started.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; @@ -107,6 +108,7 @@ function conditionalVaultImports(): Array { // === FEATURE MODULES === AuthModule, + GetStartedModule, UsersModule, MeStatusModule, MappingsModule, diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index be59e0b7..1a208f85 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -1,5 +1,6 @@ import type { Routes } from "@nestjs/core"; import { AuthModule } from "@bff/modules/auth/auth.module.js"; +import { GetStartedModule } from "@bff/modules/auth/get-started/get-started.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js"; import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; @@ -23,6 +24,7 @@ export const apiRoutes: Routes = [ path: "api", children: [ { path: "", module: AuthModule }, + { path: "", module: GetStartedModule }, { path: "", module: UsersModule }, { path: "", module: MeStatusModule }, { path: "", module: MappingsModule }, diff --git a/docs/plans/2026-03-02-get-started-login-handoff-design.md b/docs/plans/2026-03-02-get-started-login-handoff-design.md new file mode 100644 index 00000000..e906645f --- /dev/null +++ b/docs/plans/2026-03-02-get-started-login-handoff-design.md @@ -0,0 +1,226 @@ +# Get-Started Login Handoff Design + +**Date**: 2026-03-02 +**Status**: Approved + +## Problem + +When an existing portal user enters the "Get Started" flow, they verify their email via OTP only to be told "you already have an account — go sign in." The login page then requires a second OTP. Two OTPs for a single sign-in is poor UX. + +## Solution + +Cookie-based handoff: after get-started OTP verification detects `portal_exists`, the BFF sets a short-lived HttpOnly cookie and the frontend redirects to the login page. The login page detects the cookie, shows a password-only form (no second OTP), and authenticates via a new get-started/login endpoint. + +## Flow + +``` +Get Started Flow Login Page + +email → OTP → verify-code + │ + ▼ +[portal_exists detected] + │ + ├─ BFF sets HttpOnly cookie: + │ gs_verified={sessionToken} + │ (5-min TTL, SameSite=strict) + │ + └─ Frontend redirects to + /auth/login?email=... + ▼ + [Login page mounts] + [GET /auth/get-started/check-session] + [Detects valid gs_verified cookie] + [Shows password-only mode] + │ + ▼ + [User enters password] + [POST /auth/get-started/login] + [Cookie sent automatically] + │ + ▼ + [Backend validates session + password] + [Issues auth tokens, clears cookie] + [User logged in, redirected] +``` + +## Design Principles + +- **No email enumeration**: OTP is always sent regardless of account status. Account status is only revealed after OTP verification. +- **No double OTP**: The get-started OTP serves as email ownership proof. The login page only requires a password. +- **Single login experience**: All login UI lives on the login page. No duplicated password/forgot-password/remember-device UI in get-started. +- **Secure handoff**: HttpOnly cookie (no JS access), short-lived (5 min), one-time use, SameSite=strict. +- **Graceful fallback**: If the cookie expires or is invalid, the login page falls back to its normal flow. + +## Backend Changes + +### 1. Modify verify-code to set handoff cookie + +**File**: `get-started.controller.ts` (verifyCode method) + +When the `VerificationWorkflowService.verifyCode()` returns `accountStatus: portal_exists`, set an HttpOnly cookie before returning the response: + +- Cookie name: `gs_verified` +- Value: the sessionToken from the get-started session +- Attributes: `HttpOnly`, `Secure` (prod), `SameSite=strict`, `Path=/api/auth/get-started`, `Max-Age=300` (5 min) + +The response body stays unchanged — `{ verified: true, accountStatus: "portal_exists", sessionToken }`. + +### 2. New endpoint: POST /auth/get-started/login + +**File**: `get-started.controller.ts` + +Purpose: Authenticate an existing portal user using a verified get-started session + password. + +Request body: + +```typescript +{ password: string, rememberDevice?: boolean } +``` + +Session token comes from the `gs_verified` cookie (not the request body). + +Flow: + +1. Read `gs_verified` cookie to get sessionToken +2. Validate session via `GetStartedSessionService`: must exist, be email-verified, have `accountStatus: portal_exists`, and not already used +3. Load portal user by the session's email +4. Validate password via existing `PasswordService` (Argon2 verify) +5. Track failed attempts (same lockout logic as login: 5 attempts, 15-min lockout) +6. Mark session as used via `acquireAndMarkAsUsed(sessionToken, "login")` +7. Generate auth tokens via `AuthTokenService.generateTokenPair(user)` +8. Set auth cookies via `setAuthCookies(res, tokens)` +9. If `rememberDevice`, create trusted device cookie +10. Clear the `gs_verified` cookie +11. Log audit event: `LOGIN_SUCCESS` (via get-started handoff) +12. Return `{ user, session }` (same shape as regular login) + +Error responses: + +- 401: Invalid/expired session, wrong password +- 423: Account locked (too many failed attempts) +- 400: Missing cookie or invalid request + +### 3. New endpoint: GET /auth/get-started/check-session + +**File**: `get-started.controller.ts` + +Purpose: Let the login page check if a valid get-started handoff session exists. + +No request body. Reads the `gs_verified` cookie. + +Response: + +```typescript +{ valid: true, email: string } | { valid: false } +``` + +Validates session exists and is still valid (not expired, not used). Does NOT consume the session. + +### 4. Domain schema additions + +**File**: `packages/domain/get-started/schema.ts` + +```typescript +// Login with verified get-started session +export const getStartedLoginRequestSchema = z.object({ + password: z.string().min(1), + rememberDevice: z.boolean().optional().default(false), +}); + +// Check session response +export const checkSessionResponseSchema = z.discriminatedUnion("valid", [ + z.object({ valid: z.literal(true), email: z.string().email() }), + z.object({ valid: z.literal(false) }), +]); +``` + +### 5. New workflow service + +**File**: `apps/bff/src/modules/auth/infra/workflows/get-started-login-workflow.service.ts` + +Encapsulates the login-via-get-started logic: + +- Depends on: `GetStartedSessionService`, `UsersService`, `PasswordService`, `AuthTokenService`, `TrustedDeviceService`, `AuditService` +- Single method: `loginWithVerifiedSession(sessionToken, password, rememberDevice?, deviceInfo?)` +- Returns: `{ user, tokens, trustedDeviceToken? }` + +Registered in `GetStartedModule` providers. + +## Frontend Changes + +### 6. AccountStatusStep redirect + +**File**: `AccountStatusStep.tsx` + +For `portal_exists`, replace the current LoginForm/button with a redirect: + +```typescript +// Redirect to login page — cookie is already set by BFF +const loginUrl = `/auth/login?email=${encodeURIComponent(formData.email)}&from=get-started`; +window.location.href = loginUrl; +``` + +The `from=get-started` param tells the login page to check for the handoff session. + +### 7. Login page adaptation + +**File**: `LoginForm.tsx` + +On mount, check for handoff mode: + +1. If URL has `from=get-started` param, call `GET /auth/get-started/check-session` +2. If response is `{ valid: true, email }`: + - Pre-fill email (read-only) + - Hide OTP section + - Show info banner: "Email verified — enter your password to sign in" + - On password submit, call `POST /auth/get-started/login` instead of normal login + - On success, redirect (same as normal login) +3. If response is `{ valid: false }`: + - Clear `from` param from URL + - Fall back to normal login flow (user will need full login with OTP) + +### 8. Get-started API client + +**File**: `apps/portal/src/features/get-started/api/get-started.api.ts` + +Add two new functions: + +- `checkSession(): Promise` +- `loginWithSession(password, rememberDevice?): Promise` + +### 9. State machine + +**File**: `get-started.machine.ts` + +`loginRedirect` remains `type: "final"`. The redirect happens in the UI component (AccountStatusStep), not in the machine. No machine changes needed. + +## Security Properties + +| Property | Implementation | +| -------------------- | ---------------------------------------------------------- | +| No enumeration | OTP always sent; status only revealed post-verification | +| Cookie security | HttpOnly, Secure (prod), SameSite=strict, Path-scoped | +| Short-lived | 5-min Max-Age on cookie, 1-hour session TTL | +| One-time use | Session marked used after login via `acquireAndMarkAsUsed` | +| Password protection | Argon2 verify, failed attempt tracking, account lockout | +| Replay prevention | Session consumed on use; cookie cleared after login | +| Graceful degradation | Invalid/expired cookie → normal login flow | +| Rate limiting | 5 req/5 min on all get-started endpoints | + +## Files to Create/Modify + +### New files + +- `apps/bff/src/modules/auth/infra/workflows/get-started-login-workflow.service.ts` + +### Modified files + +- `packages/domain/get-started/schema.ts` — new schemas +- `packages/domain/get-started/contract.ts` — new types +- `packages/domain/get-started/index.ts` — new exports +- `apps/bff/src/modules/auth/presentation/http/get-started.controller.ts` — new endpoints + cookie on verify-code +- `apps/bff/src/modules/auth/get-started/get-started.module.ts` — register new service +- `apps/portal/src/features/get-started/api/get-started.api.ts` — new API functions +- `apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx` — redirect instead of LoginForm +- `apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx` — handoff mode detection + password-only UI