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:

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

Persist access_token + refresh_token; refresh client-side with the Supabase SDK (setSessionrefreshSession) — 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 IP429 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.
# 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:
{
  "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.
# 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).