docs: add get-started login handoff design

Cookie-based handoff pattern for existing users in the get-started
flow. After OTP verification detects portal_exists, BFF sets a
short-lived HttpOnly cookie and redirects to the login page, which
shows a password-only form (no second OTP).
This commit is contained in:
barsa 2026-03-02 16:06:45 +09:00
parent cc8aa917c2
commit 65bdadc5c8
3 changed files with 230 additions and 0 deletions

View File

@ -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<DynamicModule | Type> {
// === FEATURE MODULES ===
AuthModule,
GetStartedModule,
UsersModule,
MeStatusModule,
MappingsModule,

View File

@ -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 },

View File

@ -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<CheckSessionResponse>`
- `loginWithSession(password, rememberDevice?): Promise<AuthResponse>`
### 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