Files
baya-monorepo/dev/contracts/domains/identity-auth.md
T
hamid 3a51305343 backend phase 2: identity — phone-OTP auth, sessions & roles (REST)
- six REST endpoints (auth/request_otp, verify_otp, refresh, logout, me,
  me/select_role) wrapping the existing JWE/TOTP/RBAC engine
- usr.UserSessions with refresh-token rotation + stolen-token (replay)
  detection → revoke-all + 401; logout rotates the security stamp
- users extended: gender, national_id (enc, NULL until KYC),
  shahkar_verified_at (auto-reset on phone change), phone_hash UNIQUE,
  is_active, deleted_at + soft-delete filter; phone/email/national_id
  encrypted at rest via IFieldEncryptor value converter
- user_roles grant/revoke audit trail + global revoked filter; 7 roles
  seeded; admin sub-roles never self-assignable (403)
- ISmsSender seam (mock logs the OTP code) replaces the TODO log lines
- OperationResult/BaseController learned enveloped 401/403
- auth knobs as platform_configs rows (resend/attempts/session TTL)
- migration IdentitySessionsAndUserExtensions applied to the dev DB
- 24 new tests incl. Baya.Test.Api (WebApplicationFactory over SQLite);
  47 total green, zero new build warnings; swagger snapshot + contract
  (identity-auth.md), handoff, report, mocks-registry updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:34:11 +03:30

131 lines
6.8 KiB
Markdown

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