# Auth — passwordless code login + session discipline

Passwordless email-code login (customer-accounts card, Shopify Customer
Account API direction). Two calls: **request** emails a 6-digit code,
**verify** exchanges it for a session. Password login
(`supabase.auth.signInWithPassword` client-side) stays available and yields
an equivalent JWT — nothing here is needed for it.

## The session discipline (one rule)

`verify` mints a **real Supabase session**. Its `access_token` is the SAME
`authorization: Bearer <jwt>` every `/api/store/*` route accepts — wire it
into `StorefrontClient` once and every customer surface (customers/me,
orders, documents, cart attach) is authenticated:

```jsonc
// new StorefrontClient({ baseUrl, clientId, getAuthToken: () => storedAccessToken })
```

Persist `access_token` + `refresh_token`; refresh client-side with the
Supabase SDK (`setSession` → `refreshSession`) — the Cartbase API does not
proxy token refresh. `expires_in` is seconds; `expires_at` epoch-seconds.

## Server-enforced security posture (the SDK adds nothing)

- Request NEVER reveals whether the email is registered — `{ok: true}`
  always (no enumeration oracle). The code exists ONLY in the sent email.
- Codes: hashed at rest, TTL **10 min**, single-use, one live code per
  email (a new request supersedes prior codes), **5-wrong-attempt lockout**.
- Rate limits per 15-min window: **5 requests per email**, **20 per IP** →
  `429 rate_limited` — the only distinguishable request failure.
- Every verify failure — wrong code, expired, consumed, locked out, unknown
  email — is the SAME `401 invalid_code`.

## POST /api/store/auth/code/request — step 1

- **Purpose**: email a 6-digit one-time login code. First-time emails
  register lazily at VERIFY, not here.
- **Auth**: anon (`x-client-id` only).
- **Request**: `{email: string}`.
- **Response**: `{ok: true}` — always, by design.
- **Errors**: `429 rate_limited` · `400 validation_failed` (malformed
  email) · `400 missing_client_id`.
- **SDK**: `auth.requestLoginCode(client, {email})`
- **Components**: login form (account pages family).
- **Settings**: the `auth-code` notification template carries the code.

```bash
# The request contract: {ok:true}, nothing else — no code echo, no
# registered-or-not oracle. RUN-stamped email → no rate-limit collisions.
curl -sf -X POST "$BASE/api/store/auth/code/request" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email": "doc-auth-'$RUN'@doc.test"}' \
  | grep -q '^{"ok":true}$'
```

## POST /api/store/auth/code/verify — step 2

- **Purpose**: exchange (email, code) for a session. This is ALSO
  passwordless **registration** — a first-time email lazy-creates the
  customer (`account_status` per store policy) and triggers the welcome
  email.
- **Auth**: anon (`x-client-id` only).
- **Request**: `{email: string, code: string}` — code is exactly 6 digits
  (leading zeros count; any other shape → `400 validation_failed`).
- **Response**:

```jsonc
{
  "access_token": "eyJ…",        // the Bearer JWT for every store route
  "refresh_token": "…",
  "expires_in": 3600,             // seconds
  "expires_at": 1789300000,       // epoch seconds (may be absent)
  "token_type": "bearer",
  "customer": { /* full customer + addresses — see customers.md */ }
}
```

- **Errors**: `401 invalid_code` (every auth failure, generic) ·
  `400 validation_failed` · `400 missing_client_id`.
- **SDK**: `auth.verifyLoginCode(client, {email, code})`
- **Components**: code-entry form (account pages family).
- **Settings**: store approval policy (new-customer `account_status`);
  `account-welcome` template.

```bash
# The 401 invalid_code contract — a well-formed but wrong code against the
# RUN-stamped email (its real code lives only in the email we never read).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  -X POST "$BASE/api/store/auth/code/verify" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email": "doc-auth-'$RUN'@doc.test", "code": "000000"}')
test "$STATUS" = 401
curl -s -X POST "$BASE/api/store/auth/code/verify" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email": "doc-auth-'$RUN'@doc.test", "code": "000000"}' \
  | grep -q '"code":"invalid_code"'
```

> Cleanup note: the code rows this doc creates are single-per-run
> (RUN-stamped email), superseded on any later request and dead after the
> 10-minute TTL — there is no store-surface delete for them by design (they
> are the auth trail). The happy-path session mint is exercised with a real
> inbox-free code by `tests/store/customer-accounts-auth.test.ts` and the
> SDK contract test (server-side code issue via the lib).
