Files
baya-monorepo/archive/docs/flows/auth-login-otp.md
T
2026-08-02 20:01:31 +03:30

149 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 24 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.