149 lines
13 KiB
Markdown
149 lines
13 KiB
Markdown
# 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.
|