# Flow — auth-login-otp

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** everyone (guest → customer / nurse / admin / partner owner) · **Status:** built
**Client:** real · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [docs/integration/domains/auth.md](../integration/domains/auth.md) ·
[api-contract.md](../integration/api-contract.md)

## What it does

Phone number plus a six-digit SMS code is the only way into Balinyaar — there are no passwords. A verified
phone mints a rotating refresh-token session; `/me` then says who you are, and the client sends you to the
family app, the nurse app, the admin console, or a first-use role picker. A deep link you were bounced off
survives the round trip.

**This is the one flow verified end to end live.** Every status code below was observed on
`http://localhost:5002` at the stamp date, not inferred.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| Bounce | any private route | [`middleware.ts:44-55`](../../client/middleware.ts) → `/{locale}/login?next=<path>` |
| Guest front door | `/fa` (exact) | `middleware.ts:35-37` — **rewrite** to `/fa/welcome`, never a redirect; URL stays `/` |
| A1 / B1 phone | `/fa/login` | `login/page.tsx` → `LoginScreen` → [`LoginFlow`](../../client/src/components/auth/LoginFlow.tsx) → `PhoneStep`. `?role=nurse` seeds the intent; the switch link toggles copy only — **one login tree, not two** |
| A2 / B2 code | `/fa/login` | [`OtpStep.tsx`](../../client/src/components/auth/OtpStep.tsx) — 6 boxes, auto-verify on the last digit, resend countdown, `useWebOtp` autofill (REQ-039: never fires, template not conformant) |
| Routing splash | `/fa/login` | [`RoleRouter.tsx`](../../client/src/components/auth/RoleRouter.tsx) — renders `AuthSplash` while `/me` is in flight so the wrong shell never flashes |
| First-use role pick | `/fa/select-role` | [`SelectRole.tsx`](../../client/src/components/auth/SelectRole.tsx). Deliberately outside every actor group and carries **no `RoleGuard`** — this page is what resolves the role |
| Sign out | `/fa/profile`, `/fa/nurse/more`, `/fa/admin/system`, `/fa/partner/more` | `SignOutRow` → `useLogout` |

## API

Shapes live in [domains/auth.md](../integration/domains/auth.md) — not restated here. Client seam
[`services/auth/apis/index.ts:10`](../../client/src/services/auth/apis/index.ts), `USE_AUTH_MOCK = false`
([constants.ts:6](../../client/src/services/auth/constants.ts)) — **real**.

| Call | Endpoint | Observed | Notes |
| --- | --- | --- | --- |
| `useRequestOtp` | `POST /api/v1/auth/request_otp` | **200** | anon · `otp` policy. Returns `codeLength: 6`, `expiresInSeconds: 60`, `resendAvailableInSeconds: 120` |
| — (dev only) | `GET /api/v1/dev/last_otp/{phone}` | **200** | the only way to read a code — see [testing-setup.md](testing-setup.md#getting-an-otp) |
| `useVerifyOtp` | `POST /api/v1/auth/verify_otp` | **200** | anon · `otp` policy. Returns `accessToken` (5-part JWE), `refreshToken`, `accessExpiresAt`, `refreshExpiresAt`, `isNewUser`, `roles` |
| `useMe` | `GET /api/v1/me` | **200** | phone comes back **masked** — first four + last two, so `09120000010` → `0912*****10` (`IdentityDefaults.MaskPhone`); carries `roles`, `hasNurseProfile`, `nurseVerificationStatus` |
| `useSelectRole` | `POST /api/v1/me/select_role` | **200** (already held → idempotent) · **403** (`admin`) | 403 message: `"Only the customer or nurse role can be self-selected."` Returns **`Me`, not tokens** — see the rotation note below |
| `useRefresh` / fetch layer | `POST /api/v1/auth/refresh` | **200** rotated · **401** on replay | `auth` policy 10/min; called by both `services/auth` **and** [`lib/api/refresh.ts`](../../client/src/lib/api/refresh.ts) |
| `useLogout` | `POST /api/v1/auth/logout` | wired (not probed) | empty envelope — the client awaits it and never `unwrap()`s |

Chain confirmed link by link: hook → [`apis/clientApi.ts:24-62`](../../client/src/services/auth/apis/clientApi.ts)
→ [`lib/api/client.ts:34`](../../client/src/lib/api/client.ts) → `AuthController` / `MeController` →
`Baya.Application/Features/Identity/**`.

> **`select_role` does not rotate anything server-side.** `SelectRoleCommandHandler` grants the role and
> returns `MeResult` — no token, no security-stamp bump (verified: the 200 body carries no `accessToken`).
> The rotation is a *client-side follow-up*: [`useSelectRole.ts`](../../client/src/services/auth/hooks/useSelectRole.ts)
> fires a second `POST /auth/refresh` on success, because role claims live inside the JWE. So the one
> user action is **two** calls, and a failed rotation is swallowed by design.

## Rules that must hold

| Rule | Value | Source |
| --- | --- | --- |
| OTP length / validity | 6 digits · **60 s** | `IdentityDefaults.cs:26,30` |
| Resend window | 120 s per phone | `platform_configs.auth_otp_resend_seconds` |
| Wrong attempts before lockout | **5**, then envelope `code: "otp_locked"` + `retryAfterSeconds` | a **config row**, not a constant: `platform_configs.auth_otp_max_attempts` (seeded `5` — `PlatformConfigConfig.cs:44`), read at compute time by `VerifyOtpCommand.Handler.cs:36-44` |
| Rate limit — **`request_otp` AND `verify_otp` share one policy** | 5 / 60 s **per IP** | `RateLimitingServiceExtension.cs:52`; `AuthController.cs:26,32` |
| Refresh rotation | every refresh revokes the presented session and mints a new pair | `RefreshTokenCommand.Handler.cs:61` |
| Reuse = theft | replaying a revoked token revokes **every** active session for that user and returns 401 | `RefreshTokenCommand.Handler.cs:35-47`; [business §as-built](../../product/business/01-actors-and-onboarding.md) |
| Self-assignable roles | `customer`, `nurse` only; any admin sub-role → **403** | `RoleNames.SelfAssignable`; `SelectRoleCommand.Handler.cs:23-24` |
| No account enumeration | wrong phone, wrong code and expired code all collapse to one message | `VerifyOtpCommand.Handler.cs:24,53-59` |
| `/me` is the only identity source | the JWE is opaque; never decode a claim client-side | [`token.ts:44-58`](../../client/src/lib/auth/token.ts) |
| `?next=` is not an open redirect | same-origin relative **and** role-owned, else fall through | [`routing.ts:49-82`](../../client/src/services/auth/routing.ts); `routing.test.ts` has **17** tests, **7** of them on this guard |
| Phone lookup is by HMAC hash | `users.PhoneHash` from `Seams:FieldEncryption:HashKey` — immutable | [testing-setup.md](testing-setup.md#the-four-crypto-values-and-why-they-are-load-bearing) |

## How to test

1. Log in as **`09120000010`** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
   Boot the API with `Seams__Sms__Provider=mock` or step 3 will 500.
2. Open `http://localhost:3000/fa/nurse/visits` while signed out.
   **Expect:** a 307 to `/fa/login?next=/nurse/visits`.
3. Enter the phone, submit. **Expect:** the six-box code screen with «۰۲:۰۰» counting down.
4. `curl --noproxy '*' http://localhost:5002/api/v1/dev/last_otp/09120000010` → paste the code.
   **Expect:** auto-verify on the sixth digit, the branded splash, then `/fa` (the family home) — **not**
   `/fa/nurse/visits`, because a customer does not own that path. This is the `?next=` guard working.
5. Repeat step 2–4 as **`09120000001`** (زهرا عزیزی, nurse) with `?role=nurse`. **Expect:** you land on
   `/fa/nurse/visits` — the same deep link now survives.
6. Enter a wrong code five times. **Expect:** «کد وارد شده صحیح نیست» each time, then the lockout copy
   (`otp_locked`) with resend still enabled — resend is the way out (`OtpStep.tsx:94-95`).
7. Role picker: no seeded account is role-less, so `/fa/select-role` cannot be reached naturally. Visit it
   directly while signed in. **Expect:** two cards, «خانواده» pre-selected; «ادامه» calls `select_role`,
   *then* a second `/auth/refresh` from the client to pick up the new claim, and routes. Selecting a role
   you already hold is a safe no-op (verified live: `{"role":"customer"}` on `09120000010` → **200**).
8. Admins: `09120000020` logs in fine and `/me` returns 200, but **every admin endpoint then 403s** — the
   console renders on `USE_ADMIN_MOCK` data. See [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
9. `09120000030` (partner owner) has `/me` roles `["customer"]` only — you land on the family home and must
   navigate to `/fa/partner` by hand.

**Live rotation/reuse walk (executed):** `refresh(RT1)` → **200**, new token differs. Replay `RT1` →
**401** `"Refresh token is no longer valid. Sign in again."` Then `refresh(RT2)` — the *legitimate* token
issued moments earlier — also → **401**. The whole session family dies, exactly as the business doc
specifies. The access token minted before all of that still returned **200** from `/me` afterwards.

## Known gaps

- `POST /auth/request_otp` **500s on a fresh clone**: the committed `Seams:Sms:Provider` is `telegram` with
  nothing on `:5010`. Login is unreachable in the browser until you boot with `Seams__Sms__Provider=mock`.
- **Reuse detection cannot tell theft from two tabs.** The single-flight guard in
  `lib/api/refresh.ts:17` is module-level, so it is per-tab. Two tabs 401ing at once both POST the same
  cookie value; the second is classified as reuse and **every session dies** — a silent full logout with no
  explanation. Verified live: the freshly-rotated `RT2` was already dead.
- **`useSelectRole`'s rotation bypasses the single-flight guard.** It calls `authApi.refresh` directly,
  not `attemptTokenRefresh`, so its `/auth/refresh` is not coalesced with a concurrent 401-retry from
  `clientFetch`. Two refreshes racing on the same cookie value is exactly the reuse pattern above — the
  loser kills every session. Narrow window (first-run role pick only), but it is the same defect twice.
- **Session revocation does not kill access tokens.** `RefreshTokenCommandHandler` revokes sessions but
  never rotates the security stamp (contrast `LogoutCommand.Handler.cs:42`, which does). Verified: after
  reuse-detection revoked all sessions, `GET /me` with the pre-existing access token still returned **200**.
  "Logged out everywhere" is up to 60 minutes late (`IdentitySettings:ExpirationMinutes: "60"` in
  `appsettings.json` / `appsettings.Development.json` — a config value, not a code constant).
- **The access cookie expires 45 minutes before the token does.** `AUTH_ACCESS_COOKIE_OPTIONS.maxAge = 900`
  (`lib/cookies/constants.ts:24`) vs a 60-minute server token. After 15 idle minutes any full page load hits
  the middleware with no cookie and is bounced to `/login`, even though the 7-day refresh cookie could have
  recovered the session. The API path self-heals via `clientFetch`'s 401 retry; the navigation gate does not.
- **A 429 on `verify_otp` is shown to the user as "wrong code."** `OtpStep.tsx:134-137` renders
  `otp_invalid` for *any* verify error, and a rate-limited `verify_otp` returns an **empty body** so there is
  no `code` to branch on. `PhoneStep.tsx:50` handles 429 correctly — `OtpStep` does not.
- **The client ignores the server's OTP metadata.** `request_otp` returns `codeLength` and
  `expiresInSeconds` (REQ-002, delivered) but `RequestOtpResult` (`services/auth/types.ts:37-40`) declares
  neither, and `OTP_CODE_LENGTH = 6` is hardcoded. The 60-second code expiry is never shown at all — the
  only timer on screen is the 120-second resend cooldown, so an expired code just looks wrong.
- Stale comments in `services/auth/constants.ts:15-28` claim the live server exposes no code length and no
  machine-readable failure code. It exposes both (`codeLength`, `otp_locked`, `otp_invalid`).
- `GET /api/v1/dev/last_otp/{phone}` is **live on `api.balinyaar.ir`** — anyone who knows a registered phone
  can read its login code. Recorded in [DEPLOY.md](../../DEPLOY.md); the fix is the environment switch.
- **A signed-in user visiting `/fa/login` sees the phone form again.** `/login` is in `PUBLIC_PATHS`, so the
  middleware skips the gate and never redirects an authenticated visitor away (contrast `/welcome`, which
  does redirect — `middleware.ts:40-42`).
- **A `?next=` pointing at `/partner/...` never survives login.** `appRoleForPath` returns `null` for the
  partner tree (`routing.ts:60`), so the deep link is always discarded. Related: REQ-038 — `/me` carries no
  signal that the caller administers a partner center.
- **`useLogout` always signs out every device.** It posts `{}`, and an absent `refreshToken` means
  "everywhere" (`LogoutCommand.Handler.cs:25-28`). Signing out on a phone kills the desktop session too;
  no UI offers the choice.
- `resolveRoleDestination` checks admin before customer/nurse (`routing.ts:34`), so a user who holds both an
  admin sub-role and `customer` can never reach the family app from login.
- REQ-039 open: the OTP SMS template is not WebOTP-conformant, so `useWebOtp` ships but never fires.
- No seeded account is role-less, so the `/fa/select-role` screen has **no natural path to it** in the demo
  world — it can only be tested by navigating directly.
