provider-mobile-auth.md
255 lines
| 1 | # Provider / Employee Mobile App Authentication |
| 2 | |
| 3 | How a mobile app authenticates an **employee (provider)** with email + password and |
| 4 | makes authenticated, per-provider–scoped requests against the Amelia backend. |
| 5 | |
| 6 | > **No code change is required.** This flow already exists and is enabled by default. |
| 7 | > It deliberately does **not** use the external `/api/v1/*` API-key surface — that key |
| 8 | > represents the site admin and is unscoped (it would grant cross-provider/admin access). |
| 9 | > The provider Cabinet flow below is scoped to the authenticated provider by construction. |
| 10 | |
| 11 | ## Why not the API-key API? |
| 12 | |
| 13 | The `/api/v1/*` routes (`src/Infrastructure/API/ApiRoutes/**`) are guarded by |
| 14 | `Api::callMainFunction` (`src/Infrastructure/API/Api.php`), which checks the static |
| 15 | `Amelia` API key. A valid key only causes the per-request CSRF nonce to be skipped |
| 16 | (`Controller.php` — `$validApiCall`); it does **not** scope the caller to a user. A |
| 17 | provider authenticated this way would see every provider's data. Wrong model for a |
| 18 | mobile app, and it would mean shipping a shared admin secret inside the app. |
| 19 | |
| 20 | The **Cabinet** flow below issues a per-provider JWT instead. |
| 21 | |
| 22 | ## 1. Log in |
| 23 | |
| 24 | ``` |
| 25 | POST {site}/wp-admin/admin-ajax.php?action=wpamelia_api&call=/users/authenticate |
| 26 | Content-Type: application/json |
| 27 | |
| 28 | { |
| 29 | "email": "employee@example.com", |
| 30 | "password": "••••••••", |
| 31 | "cabinetType": "provider" |
| 32 | } |
| 33 | ``` |
| 34 | |
| 35 | - **No `Amelia` API key required** — `/users/authenticate` is a Cabinet (Starter-tier) |
| 36 | route, not part of the key-gated API set. |
| 37 | - **No CSRF nonce required** — `LoginCabinetCommand` is explicitly nonce-exempt |
| 38 | (`src/Application/Commands/Command.php`, `validateNonce`). |
| 39 | - `cabinetType: "provider"` is what makes this **employees only** — see §3. |
| 40 | |
| 41 | ### Success response |
| 42 | |
| 43 | ```json |
| 44 | { |
| 45 | "message": "Successfully", |
| 46 | "data": { |
| 47 | "user": { "...": "full provider object", "type": "provider" }, |
| 48 | "is_wp_user": false, |
| 49 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." |
| 50 | } |
| 51 | } |
| 52 | ``` |
| 53 | |
| 54 | - `data.token` is a JWT signed with the **provider cabinet's `headerJwtSecret`** |
| 55 | (`UserApplicationService::getAuthenticatedUserResponse`). Store it securely on device |
| 56 | (Keychain / Keystore). |
| 57 | - Token lifetime = `providerCabinet.tokenValidTime` (Amelia → Settings → Roles). Re-run |
| 58 | the login call to obtain a fresh token; there is no separate refresh endpoint for the |
| 59 | header flow. |
| 60 | |
| 61 | ### Failure |
| 62 | |
| 63 | `data.invalid_credentials: true` → wrong email/password, or the account is not allowed |
| 64 | into the provider cabinet (see §3). |
| 65 | |
| 66 | ## 2. Make authenticated requests |
| 67 | |
| 68 | Send the token as a Bearer header on every subsequent call: |
| 69 | |
| 70 | ``` |
| 71 | GET {site}/wp-admin/admin-ajax.php?action=wpamelia_api&call=/appointments |
| 72 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... |
| 73 | ``` |
| 74 | |
| 75 | - The header is read in `Command::setToken` and gated by the `enabledHttpAuthorization` |
| 76 | role setting, which **defaults to `true`** (`ActivationSettingsHook.php`). Confirm it |
| 77 | is on at Amelia → Settings → Roles if requests come back unauthorized. |
| 78 | - The backend resolves the token (`UserApplicationService::getAuthenticatedUser`, |
| 79 | validated against the same `headerJwtSecret`) and scopes the request to that provider |
| 80 | via `authorization($token, 'provider')`. The employee only sees their own |
| 81 | appointments / schedule. |
| 82 | |
| 83 | Two non-obvious rules a mobile client must follow: |
| 84 | |
| 85 | - **Always send `?source=cabinet-provider`** (a query param, even on POSTs). This is what |
| 86 | flips the handler into cabinet mode (`isCabinetPage = getPage() === 'cabinet'`) and |
| 87 | engages per-provider scoping. Without it the handler ignores the token and the request |
| 88 | is treated as an unscoped dashboard call. The web Employee Panel sends exactly this |
| 89 | (see `v3/src/views/public/Cabinet/**`, `source: 'cabinet-' + cabinetType`). |
| 90 | - **Auth failures are NOT HTTP 401.** An expired/invalid token yields `RESULT_ERROR` with |
| 91 | `data.reauthorize = true`, which `Controller::__invoke` maps to HTTP **409/500**. Key |
| 92 | your re-login off the `data.reauthorize` body flag, not the status code (the web panel |
| 93 | does: `error.response.data.data.reauthorize → logout`). |
| 94 | |
| 95 | ## 2a. Cabinet data endpoints (use these, not `/api/v1/*`) |
| 96 | |
| 97 | The `/api/v1/*` routes require the admin API key and won't accept the JWT. Use the cabinet |
| 98 | (no-prefix) routes instead. All take `Authorization: Bearer <token>` + `?source=cabinet-provider`. |
| 99 | |
| 100 | | Operation | Method & path | Body | Scoping | |
| 101 | | ------------------------ | -------------------------------------- | ------------ | ------- | |
| 102 | | List appointments | `GET /appointments` | — | server forces the provider's own id; **do not send `providerId`** | |
| 103 | | Appointment detail | `GET /appointments/{id}` | — | provider-checked | |
| 104 | | Update appt. status | `POST /appointments/status/{id}` | `{ status }` | provider-checked; returns new status in `data.status` | |
| 105 | | List events | `GET /events` | — | provider-scoped | |
| 106 | | Event detail | `GET /events/{id}` | — | provider-scoped | |
| 107 | |
| 108 | ### Pagination (for infinite scroll / load-on-scroll) |
| 109 | |
| 110 | Both list endpoints paginate server-side, but with **different** param names, page-size |
| 111 | sources, and total fields: |
| 112 | |
| 113 | | | `GET /appointments` | `GET /events` | |
| 114 | | --- | --- | --- | |
| 115 | | Page param | `page` (1-based) | `page` (1-based) | |
| 116 | | Page size | server-fixed (`general.itemsPerPage`); **client sends no limit** | `limit` — **client sends it** | |
| 117 | | Items in response | `data.appointments` — object **grouped by date** (`{ "YYYY-MM-DD": { date, appointments[] } }`) | `data.events` — **flat array** | |
| 118 | | Total for stop-detection | `data.total` (≡ `data.filteredCount`) | `data.count` | |
| 119 | | Unfiltered total | `data.totalCount` | `data.countTotal` | |
| 120 | | Free-text search | `search` (matches customer name/email/phone + service name) | `search` (matches event name) | |
| 121 | |
| 122 | Notes: |
| 123 | - **You must send `page`** to engage the `LIMIT`. With no `page`, the query returns |
| 124 | *everything* (no limit) and `total` stays `0` — so always paginate. |
| 125 | - `data.total` is a real non-null count on every success path when `page` is sent |
| 126 | (`GetAppointmentsCommandHandler` line 409 gate + line 467 assignment). The only response |
| 127 | without it is the auth-error path (`data.reauthorize`), which a scroll loop must treat as |
| 128 | re-auth, not "load more". Safe stop signal: **accumulated items ≥ `total`**. |
| 129 | - ⚠️ `total` is marked `// TODO: Redesign - remove total` in `GetAppointmentsCommandHandler`. |
| 130 | `data.filteredCount` carries the **identical value** (line 471) and has no removal TODO, |
| 131 | so the mobile client reads `data.total ?? data.filteredCount` (see `lib/api.ts`). If that |
| 132 | refactor lands, the app keeps working off `filteredCount`. There is no PHPUnit test pinning |
| 133 | this shape (it's built deep in the handler); the mobile **live contract test** is where the |
| 134 | field's presence + numeric-ness should be asserted. |
| 135 | - `getLimit()` computes offset as `(page - 1) * itemsPerPage` (`AbstractRepository::getLimit`). |
| 136 | - The web Employee Cabinet uses classic numbered pagination, not scroll; the mobile app |
| 137 | reconstructs the same totals into an infinite-scroll accumulator. |
| 138 | |
| 139 | **Not available over the provider JWT** (the handlers authorize via the WordPress session / |
| 140 | capabilities, with no token path): **delete appointment, update event status, delete event**. |
| 141 | Exposing them safely would require adding token-based provider-ownership checks to those |
| 142 | handlers (an IDOR risk if done naively) — out of scope for this auth model. Manage those |
| 143 | from the web dashboard, or treat as a separate, deliberate plugin change. |
| 144 | |
| 145 | ## 2b. Version negotiation (`/mobile/info`) |
| 146 | |
| 147 | Different sites run different plugin builds, so the app negotiates compatibility |
| 148 | **before** it relies on the `/mobile/v1/*` contract. The plugin exposes one tiny, |
| 149 | **unversioned, unauthenticated** handshake: |
| 150 | |
| 151 | ``` |
| 152 | GET {site}/wp-admin/admin-ajax.php?action=wpamelia_api&call=/mobile/info |
| 153 | → 200 { "message": "success", |
| 154 | "data": { "pluginVersion": "9.5", "mobileApi": { "min": 1, "max": 1 } } } |
| 155 | ``` |
| 156 | |
| 157 | - `mobileApi.{min,max}` = inclusive range of mobile-API **contract** versions this |
| 158 | build serves (an integer, independent of the plugin semver). Source of truth: |
| 159 | `GetMobileInfoController::MOBILE_API_MIN / MOBILE_API_MAX`. |
| 160 | - `pluginVersion` = `AMELIA_VERSION`, informational only. |
| 161 | - It is intentionally outside `/mobile/v1/` and requires no token — it is the thing |
| 162 | used to *detect* breaking changes, so it must never change shape or require auth. |
| 163 | `GetMobileInfoController` extends the base `Controller` (not `MobileV1Controller`) |
| 164 | and returns its payload directly, bypassing the command pipeline. |
| 165 | |
| 166 | The app declares the single contract version it was built for and compares: |
| 167 | |
| 168 | | Condition | Verdict | User action | |
| 169 | | --------- | ------- | ----------- | |
| 170 | | `appVersion > mobileApi.max` | plugin too old | **update the plugin** | |
| 171 | | `appVersion < mobileApi.min` | contract dropped (breaking change) | **update the app** | |
| 172 | | in range | compatible | — | |
| 173 | | route 404s (`{"message":"Not found."}`, no `data`) | predates `/mobile/info` | **update the plugin** | |
| 174 | | WP returns literal `0` / non-JSON | Amelia absent/inactive or wrong URL | connection error (not an update) | |
| 175 | | network failure | unknown | **fail open** — never blocks | |
| 176 | |
| 177 | These response shapes are verified live (handshake `200` JSON unauthenticated; |
| 178 | unknown route `404 {"message":"Not found."}` with no `data` via `AmeliaErrorHandler`; |
| 179 | no-Bearer `/mobile/v1/*` → `409 {reauthorize:true}`). |
| 180 | |
| 181 | > **Release invariant:** `/mobile/info` MUST ship in the same release as the |
| 182 | > `/mobile/v1/*` routes — otherwise the release that introduces mobile support |
| 183 | > fails its own compatibility check. |
| 184 | |
| 185 | > **Bump protocol:** raise `MOBILE_API_MAX` for a new additive contract version; |
| 186 | > raise `MOBILE_API_MIN` only when deliberately dropping support for an old app |
| 187 | > contract (this forces those app builds to update). |
| 188 | |
| 189 | ## 3. Employee-only enforcement |
| 190 | |
| 191 | The login rejects anyone whose Amelia type is not `provider` (admins are also allowed |
| 192 | through, as on web): |
| 193 | |
| 194 | ```php |
| 195 | // UserApplicationService::getAuthenticatedUserResponse |
| 196 | if ($user->getType() !== $cabinetType && $user->getType() !== AbstractUser::USER_ROLE_ADMIN) { |
| 197 | // -> invalid_credentials |
| 198 | } |
| 199 | ``` |
| 200 | |
| 201 | So with `cabinetType: "provider"`, a customer can never authenticate. Managers |
| 202 | (WP-account based) are handled through the WordPress-session path, not the email/password |
| 203 | path — a pure email/password mobile login returns either a `provider` or an `admin`. |
| 204 | |
| 205 | ## 4. Determining the role |
| 206 | |
| 207 | Read **`data.user.type`** from the login response. It is one of four string values |
| 208 | (`AbstractUser` constants): |
| 209 | |
| 210 | | `data.user.type` | Meaning | WordPress role | |
| 211 | | ---------------- | ------------------ | --------------------- | |
| 212 | | `admin` | Site administrator | `administrator` | |
| 213 | | `manager` | Amelia manager | `wpamelia-manager` | |
| 214 | | `provider` | Employee | `wpamelia-provider` | |
| 215 | | `customer` | Customer | `wpamelia-customer` | |
| 216 | |
| 217 | The mapping from WordPress role → Amelia role is in |
| 218 | `UserRoles::getUserAmeliaRole` (`src/Infrastructure/WP/UserRoles/UserRoles.php`), |
| 219 | resolved in priority order: `administrator` → `manager` → `provider` → `customer` |
| 220 | (a `super_admin` also resolves to `admin`). |
| 221 | |
| 222 | For the mobile employee app you will normally only ever see `provider` (regular |
| 223 | employee) or `admin` (an admin using the employee panel). Branch the UI on |
| 224 | `data.user.type` if admins should see additional capabilities. |
| 225 | |
| 226 | ## Source references |
| 227 | |
| 228 | | Concern | Location | |
| 229 | | ----------------------------- | -------- | |
| 230 | | Login route (no key/nonce) | `src/Infrastructure/Routes/Cabinet/Cabinet.php` → `/users/authenticate` | |
| 231 | | Nonce exemption | `src/Application/Commands/Command.php` → `validateNonce` | |
| 232 | | Token issued (in body) | `src/Application/Services/User/UserApplicationService.php` → `getAuthenticatedUserResponse` | |
| 233 | | Bearer header read | `src/Application/Commands/Command.php` → `setToken` (`enabledHttpAuthorization`) | |
| 234 | | Token validated + scoped | `src/Application/Services/User/UserApplicationService.php` → `getAuthenticatedUser` / `authorization` | |
| 235 | | Employee-only check | `getAuthenticatedUserResponse` (`type !== cabinetType`) | |
| 236 | | Role constants / WP mapping | `src/Domain/Entity/User/AbstractUser.php`, `src/Infrastructure/WP/UserRoles/UserRoles.php` | |
| 237 | | Cabinet data routes | `src/Infrastructure/Routes/Booking/**` (`/appointments`, `/events`, `/appointments/status/{id}`) | |
| 238 | | `source=cabinet` scoping | `src/Application/Commands/Booking/Appointment/GetAppointmentsCommandHandler.php` (`isCabinetPage`, provider id pinning) | |
| 239 | | `reauthorize` (not 401) | `src/Application/Controller/Controller.php` → `__invoke` (maps RESULT_ERROR to 409/500) | |
| 240 | | Version handshake | `src/Application/Controller/Mobile/GetMobileInfoController.php`, route in `src/Infrastructure/Routes/Mobile/MobileV1.php` | |
| 241 | | Routing 404 shape | `src/Infrastructure/Common/AmeliaErrorHandler.php` (`{message}` only, no `data`) | |
| 242 | | Reference client | `amelia-mobile-app` repo → `lib/api.ts` + `context/AuthContext.tsx` (+ `__tests__/api.test.ts`) | |
| 243 | |
| 244 | ## Regression guard |
| 245 | |
| 246 | - **Plugin:** `tests/phpunit/Application/Commands/LoginNonceExemptionTest.php` locks the |
| 247 | nonce exemption that lets the mobile app log in without a nonce/API key. Runs in CI via |
| 248 | the existing `PHPUnit` job in `.github/workflows/ci.yml`. |
| 249 | - **Plugin:** `tests/phpunit/Application/Controller/Mobile/GetMobileInfoControllerTest.php` |
| 250 | pins the `/mobile/info` handshake payload (success + integer `mobileApi.min/max`) the app |
| 251 | compares against. |
| 252 | - **Mobile app:** `__tests__/api.test.ts` (`npm test`) locks the security-critical client |
| 253 | invariants: never sends the admin `Amelia` key, always sends `source=cabinet-provider`, |
| 254 | never sends a client `providerId`, and re-logs-in on the `reauthorize` body flag. |
| 255 |