PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / trunk
Booking for Appointments and Events Calendar – Amelia vtrunk
2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / docs / provider-mobile-auth.md
ameliabooking / docs Last commit date
provider-mobile-auth.md 6 days ago
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