# 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=` | | 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.