Files
baya-monorepo/docs/flows/auth-login-otp.md
T
2026-08-02 17:18:36 +03:30

13 KiB
Raw Blame History

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-37rewrite to /fa/welcome, never a redirect; URL stays /
A1 / B1 phone /fa/login login/page.tsxLoginScreenLoginFlowPhoneStep. ?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 SignOutRowuseLogout

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 091200000100912*****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-62lib/api/client.ts:34AuthController / MeControllerBaya.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 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 5PlatformConfigConfig.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

  1. Log in as 09120000010 (سارا محمدی, customer) — see 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 09120000010200).
  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.
  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 RT1401 "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; 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.