13 KiB
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 Integration: docs/integration/domains/auth.md · 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 → /{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 → PhoneStep. ?role=nurse seeds the intent; the switch link toggles copy only — one login tree, not two |
| A2 / B2 code | /fa/login |
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 — renders AuthSplash while /me is in flight so the wrong shell never flashes |
| First-use role pick | /fa/select-role |
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 — not restated here. Client seam
services/auth/apis/index.ts:10, USE_AUTH_MOCK = false
(constants.ts:6) — 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 |
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 |
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
→ lib/api/client.ts:34 → AuthController / MeController →
Baya.Application/Features/Identity/**.
select_roledoes not rotate anything server-side.SelectRoleCommandHandlergrants the role and returnsMeResult— no token, no security-stamp bump (verified: the 200 body carries noaccessToken). The rotation is a client-side follow-up:useSelectRole.tsfires a secondPOST /auth/refreshon 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 |
| 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 |
?next= is not an open redirect |
same-origin relative and role-owned, else fall through | routing.ts:49-82; 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 |
How to test
- Log in as
09120000010(سارا محمدی, customer) — see testing-setup.md. Boot the API withSeams__Sms__Provider=mockor step 3 will 500. - Open
http://localhost:3000/fa/nurse/visitswhile signed out. Expect: a 307 to/fa/login?next=/nurse/visits. - Enter the phone, submit. Expect: the six-box code screen with «۰۲:۰۰» counting down.
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.- Repeat step 2–4 as
09120000001(زهرا عزیزی, nurse) with?role=nurse. Expect: you land on/fa/nurse/visits— the same deep link now survives. - 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). - Role picker: no seeded account is role-less, so
/fa/select-rolecannot be reached naturally. Visit it directly while signed in. Expect: two cards, «خانواده» pre-selected; «ادامه» callsselect_role, then a second/auth/refreshfrom 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"}on09120000010→ 200). - Admins:
09120000020logs in fine and/mereturns 200, but every admin endpoint then 403s — the console renders onUSE_ADMIN_MOCKdata. See testing-setup.md. 09120000030(partner owner) has/meroles["customer"]only — you land on the family home and must navigate to/fa/partnerby 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_otp500s on a fresh clone: the committedSeams:Sms:Provideristelegramwith nothing on:5010. Login is unreachable in the browser until you boot withSeams__Sms__Provider=mock.- Reuse detection cannot tell theft from two tabs. The single-flight guard in
lib/api/refresh.ts:17is 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-rotatedRT2was already dead. useSelectRole's rotation bypasses the single-flight guard. It callsauthApi.refreshdirectly, notattemptTokenRefresh, so its/auth/refreshis not coalesced with a concurrent 401-retry fromclientFetch. 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.
RefreshTokenCommandHandlerrevokes sessions but never rotates the security stamp (contrastLogoutCommand.Handler.cs:42, which does). Verified: after reuse-detection revoked all sessions,GET /mewith the pre-existing access token still returned 200. "Logged out everywhere" is up to 60 minutes late (IdentitySettings:ExpirationMinutes: "60"inappsettings.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 viaclientFetch's 401 retry; the navigation gate does not. - A 429 on
verify_otpis shown to the user as "wrong code."OtpStep.tsx:134-137rendersotp_invalidfor any verify error, and a rate-limitedverify_otpreturns an empty body so there is nocodeto branch on.PhoneStep.tsx:50handles 429 correctly —OtpStepdoes not. - The client ignores the server's OTP metadata.
request_otpreturnscodeLengthandexpiresInSeconds(REQ-002, delivered) butRequestOtpResult(services/auth/types.ts:37-40) declares neither, andOTP_CODE_LENGTH = 6is 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-28claim 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 onapi.balinyaar.ir— anyone who knows a registered phone can read its login code. Recorded in DEPLOY.md; the fix is the environment switch.- A signed-in user visiting
/fa/loginsees the phone form again./loginis inPUBLIC_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.appRoleForPathreturnsnullfor the partner tree (routing.ts:60), so the deep link is always discarded. Related: REQ-038 —/mecarries no signal that the caller administers a partner center. useLogoutalways signs out every device. It posts{}, and an absentrefreshTokenmeans "everywhere" (LogoutCommand.Handler.cs:25-28). Signing out on a phone kills the desktop session too; no UI offers the choice.resolveRoleDestinationchecks admin before customer/nurse (routing.ts:34), so a user who holds both an admin sub-role andcustomercan never reach the family app from login.- REQ-039 open: the OTP SMS template is not WebOTP-conformant, so
useWebOtpships but never fires. - No seeded account is role-less, so the
/fa/select-rolescreen has no natural path to it in the demo world — it can only be tested by navigating directly.