# 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
