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:
parent
cc8aa917c2
commit
65bdadc5c8
@ -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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
226
docs/plans/2026-03-02-get-started-login-handoff-design.md
Normal file
226
docs/plans/2026-03-02-get-started-login-handoff-design.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user