# auth — phone OTP, sessions, `/me`, role selection

> Client seam `client/src/services/auth/` · `USE_AUTH_MOCK = false` (**real**) · 7 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).

The only way into the platform. Phone + OTP, no passwords. The transport rules — bearer header, cookie
storage, silent refresh, rotation, reuse detection — are in
[../api-contract.md](../api-contract.md#auth); this file is the endpoints and the payload semantics.

## Endpoints

| Method | Path | Auth | Rate limit | Verdict |
| --- | --- | --- | --- | --- |
| POST | `/api/v1/auth/request_otp` | **anonymous** | `otp` 5/min | wired |
| POST | `/api/v1/auth/verify_otp` | **anonymous** | `otp` 5/min | wired |
| POST | `/api/v1/auth/refresh` | **anonymous** | `auth` 10/min | wired · called by both `services/auth` **and** the fetch layer |
| POST | `/api/v1/auth/logout` | `[Authorize]` | — | wired |
| GET | `/api/v1/me` | `[Authorize]` | — | wired |
| POST | `/api/v1/me/select_role` | `[Authorize]` | — | wired |
| GET | `/api/v1/dev/last_otp/{phone}` | **anonymous** | — | **Development only** — see below |

No phantoms.

`AUTH_API_BASE` is `/api/v1`; the routes above are exactly what the client sends.

## `dev/last_otp` — live on the deployment

`DevController` is registered unconditionally, and the OTP-capture bridge behind it is wired only when
`IsDevelopment()` **and** the SMS provider is capture-safe (`mock` or `telegram`). The
`balinyaar.ir` deployment runs `ASPNETCORE_ENVIRONMENT=Development` with `Seams:Sms:Provider = telegram`,
so **both conditions hold and the endpoint is reachable on `api.balinyaar.ir`.** Anyone who knows a
registered phone number can read its login code. Recorded in [DEPLOY.md](../../../DEPLOY.md) as the
deployment's largest exposure; the fix is the environment switch, not a code change.

Selecting a real gateway (`kavenegar`, …) disables the bridge — the OTP must never be logged or captured
once real SMS ships.

## Shape rules the JSON does not express

- **`RequestOtpResult` carries the code length and expiry** (REQ-002, delivered) so the client sizes the
  input and runs the countdown from server truth rather than a hardcoded constant.
- **`verify_otp` failures carry a machine-readable `code`** on the envelope (REQ-003, delivered) —
  e.g. `otp_locked` — so the client branches on the state instead of matching a message string. This is
  the `code` field described in [../api-contract.md](../api-contract.md#the-envelope).
- **`refresh` returns a new pair and retires the old refresh token.** Presenting a retired token is
  treated as theft: the session is killed, not merely refused. A 401 from `/auth/refresh` is therefore
  terminal and the client must not retry it.
- **`/me` is the only source of identity.** The JWE is opaque; the client reads role, gender and
  profile-completeness from `/me`, never from a decoded claim.
- **Multi-role users**: `/me` reports the roles the caller holds. `POST /me/select_role` commits to one.
  REQ-004 was **resolved as a client concern** — the client owns the disambiguation and the "resolved vs.
  pending" role hydration; no backend change was needed. See
  [docs/rules/client/auth.md](../../rules/client/auth.md).
- **`logout` returns an empty envelope** — no `data`. The client awaits the revocation and must not
  `unwrap()` it.
- **Phone numbers are encrypted at rest.** Login looks the user up by a deterministic HMAC hash
  (`users.PhoneHash`, derived from `Seams:FieldEncryption:HashKey`), never by comparing the encrypted
  column. This is why that key is immutable — see [../config-matrix.md](../config-matrix.md).

## Enums

| Vocabulary | Values |
| --- | --- |
| `PublicRole` | `customer` `nurse` |
| `AdminRole` | `admin` `support` `finance` `moderation` `super_admin` |
| `Gender` | `male` `female` — **load-bearing**, drives same-gender caregiver matching |
| `NurseVerificationStatus` *(as surfaced on `/me`)* | `not_started` `in_progress` `pending_review` `verified` `rejected` |

> The `/me` verification summary uses a **different vocabulary** from the verification domain's own
> aggregate status (`not_started` `pending` `in_review` `approved` `rejected` `suspended`). They are two
> read models over the same source of truth, not a drift — but do not treat the strings as
> interchangeable. See [verification.md](verification.md).

## Open REQs

| REQ | Status | Effect |
| --- | --- | --- |
| REQ-038 | open | `/me` carries no signal that the caller administers a partner center, so partner auto-routing cannot be driven off it. See [partner-center.md](partner-center.md) |
| REQ-039 | open | The OTP SMS template is not WebOTP-conformant, so the browser's one-tap autofill never fires. The client's WebOTP hook ships anyway and degrades silently |
