# 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).

---

## 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).
