# 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/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the > machine schema: [`../openapi/`](../openapi/README.md) (`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:** ```json { "phone": "09121112233" } ``` Accepts `+98…`/`0098…`/Persian digits; normalized server-side to `09xxxxxxxxx`. - **Success `200` payload (`data`):** ```json { "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:** ```json { "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:** ```json { "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) ```json { "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:** ```json { "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).