Files
baya-monorepo/archive/build-chain/contracts/domains/identity-auth.md
T
2026-08-02 18:48:32 +03:30

7.3 KiB

Contract — Identity & Auth (backend phase b2)

One-line: phone-OTP login, revocable refresh-token sessions with rotation + reuse detection, the current-user profile (/me) and public role selection. Assumes ../conventions/api-conventions.md + ../conventions/money-and-types.md. Source of truth for the machine schema: ../openapi/ (swagger.v1.json, refreshed for b2).

Status: live as of backend-phase-2 · Frontend consumer: frontend-phase-f1-b2

Exact paths (snake_case transformer output — differs from early sketches that showed otp/request / me/role): auth/request_otp, auth/verify_otp, auth/refresh, auth/logout, me, me/select_role. JSON bodies are camelCase (confirmed against the live envelope).

Enums used

  • role (self-selectable): customer | nurse — a user may hold both. Admin sub-roles (admin, support, finance, moderation, super_admin) exist but are internal-only; sending one to me/select_role returns 403.
  • gender: male | female — load-bearing for same-gender matching; null until the profile flow (b3) sets it; never defaulted.
  • nurseVerificationStatus: not_started until the b6 verification pipeline exists.

Endpoints

POST api/v1/auth/request_otp

  • Purpose: send a one-time login code to an Iranian mobile; silently creates an inactive-until-verified account for a new phone.
  • Auth: none · Rate-limited: yes (otp per-IP policy → 429) · Idempotency key: no
  • Request body:
    { "phone": "09121112233" }
    
    Accepts +98…/0098…/Persian digits; normalized server-side to 09xxxxxxxxx.
  • Success 200 payload (data):
    { "otpSent": true, "resendAvailableInSeconds": 120 }
    
    Inside the per-phone resend window the same shape returns with otpSent: false and the remaining seconds. The shape never reveals whether the phone already had an account (no enumeration).
  • Failure cases: 400 invalid phone; 429 over the per-IP OTP limit.
  • Notes: in the mock environment the code is written to the server log (ISmsSender mock); the OTP itself expires on the TOTP provider's window (~3 min).

POST api/v1/auth/verify_otp

  • Purpose: verify the code, activate the account, mint the token pair + a revocable session.
  • Auth: none · Rate-limited: yes (otp policy) · Idempotency key: no
  • Request body:
    { "phone": "09121112233", "code": "466036", "deviceInfo": "iPhone 15 / app 1.0 (optional)" }
    
  • Success 200 payload (data): AuthTokensResult (below). isNewUser: true on the first successful verify; roles is empty for a fresh user — route them to role selection.
  • Failure cases: 400 wrong/expired code (same safe message whether the phone exists or the code is wrong — no enumeration); 400 "too many failed attempts" after auth_otp_max_attempts wrong codes (request a new OTP to reset); 429 over limit.

POST api/v1/auth/refresh

  • Purpose: rotate the refresh token: the presented session is revoked, a new pair is issued.
  • Auth: none required (RequireTokenWithoutAuthorization — the refresh token is the credential) · Rate-limited: yes (auth policy) · Idempotency key: no
  • Request body:
    { "refreshToken": "<64-hex refresh token>", "deviceInfo": "optional" }
    
  • Success 200 payload (data): AuthTokensResult (isNewUser always false).
  • Failure cases: 401 unknown token; 401 expired session; 401 reuse-detection — a token that hashes to an already-revoked session is treated as stolen: all of that user's sessions are revoked (logout-everywhere) and the client must sign in again.

POST api/v1/auth/logout

  • Purpose: revoke the session server-side and kill outstanding access tokens.
  • Auth: authenticated (Bearer) · Rate-limited: no
  • Request body: (send {} at minimum)
    { "refreshToken": "optional — revoke just this session", "everywhere": false }
    
    With everywhere: true or no refreshToken, every active session is revoked.
  • Success 200: empty envelope (no data).
  • Failure cases: 401 unauthenticated.
  • Notes: the security stamp rotates on every logout, so all of the user's outstanding access tokens fail immediately (other devices recover by refreshing their still-valid refresh tokens).

GET api/v1/me

  • Purpose: the signed-in user's identity, roles and onboarding state (drives the role router).
  • Auth: authenticated · Rate-limited: no
  • Success 200 payload (data): MeResult (below).
  • Failure cases: 401 missing/expired/stamp-invalidated token.

POST api/v1/me/select_role

  • Purpose: self-assign a public actor role. Idempotent; customer and nurse can coexist.
  • Auth: authenticated · Rate-limited: no
  • Request body:
    { "role": "customer" }
    
  • Success 200 payload (data): the updated MeResult.
  • Failure cases: 403 for any non-public role (super_admin, support, …); 400 empty role; 401 unauthenticated.
  • Notes: role claims live inside the (JWE) access token — after selecting a role, refresh the token pair so subsequent role-gated calls carry the new claim. /me reads roles from the DB and reflects the change immediately.

Shared shapes

  • AuthTokensResult:

    field type notes
    accessToken string JWE bearer token (send as Authorization: Bearer …)
    refreshToken string 64-hex opaque token; store securely, only its hash exists server-side
    accessExpiresAt ISO-8601 absolute access-token expiry
    refreshExpiresAt ISO-8601 session expiry (auth_session_ttl_days, default 30d)
    isNewUser bool true only on the first successful verify for the phone
    roles string[] active roles; empty ⇒ send the user to role selection
  • MeResult:

    field type notes
    id int user id
    phone string always masked (0912*****33) — full phone is never returned
    firstName / lastName string | null null until profile (b3)
    gender male/female | null null until profile (b3); never defaulted
    isActive bool phone-verified account
    roles string[] active roles (revoked grants excluded)
    hasCustomerProfile / hasNurseProfile bool false until b3 populates the profile tables
    nurseVerificationStatus string not_started until b6
  • RequestOtpResult: otpSent (bool) + resendAvailableInSeconds (int).

Changelog

  • b2 — initial contract (phone-OTP auth, sessions, /me, role selection).

Refinement phase 3 additions (REQ-002/003)

  • RequestOtpResult gains codeLength (6) and expiresInSeconds (60) so the OTP box count + expiry hint are contract-driven.
  • verify_otp failures now carry a stable machine code on the envelope: otp_invalid (wrong or expired — collapsed for anti-enumeration) and otp_locked with data: { retryAfterSeconds } on lockout. The coded-error envelope is { isSuccess: false, statusCode: 400, message, code, data? } (the optional code is omitted from every other response).