# Authentication & Authorization

Deep reference for how login, registration, OTP verification, and RBAC actually work across all four roles. Endpoint-by-endpoint request/response detail (exact fields, validation rules, captured examples) lives in each role's own file under `### Auth` / `### Profile` — this file explains the *mechanics* those endpoints implement, so you understand the flow rather than just the individual calls.

## JWT

- Package: `tymon/jwt-auth` v2.2, guard `api` (`config/auth.php`), driver `jwt`.
- Algorithm: **HS256** (`config/jwt.php`, no `JWT_ALGO` override present in `.env`).
- TTL: **86400 minutes (60 days)** — `config/jwt.php`'s `env('JWT_TTL', 86400)`, and no `JWT_TTL` key is set in `.env`/`.env.example`, so this default is what's actually in effect. This is unusually long for an access-token TTL (the package's own doc comment describes a 60-minute default) — worth confirming with the team whether this is intentional before relying on it in a mobile client's token-refresh logic.
- Refresh TTL: **20160 minutes (2 weeks)** — `JWT_REFRESH_TTL`, also unoverridden.
- `lock_subject: true`, blacklist enabled.
- The token is returned as `data.token` — **not** `access_token` — from every login/verify/register/refresh response, via `App\Http\Resources\Shared\LoginResource`. There is no `expires_in`, `token_type`, or `expires_at` field anywhere in the response; if a client needs to know when to refresh, it must track the TTL out-of-band (86400 min from issuance, per the config above) rather than reading it from the payload.
- `client.auth.refresh` / equivalents issue a new token for the currently authenticated user; body is empty (`{}`).

### `LoginResource` shape (identical across all 4 roles)

```json
{
  "id": 12,
  "name": "Jane Doe",
  "email": "jane@example.com",
  "phone": "1234567890",
  "phone_code": "20",
  "user_type": "individual",
  "user_type_label": "Individual",
  "status": "active",
  "status_label": "Active",
  "is_active": true,
  "is_verified": true,
  "is_banned": false,
  "avatar": null,
  "settings": { "locale": "ar", "allow_notifications": true },
  "last_login_at": "2026-08-20T10:00:00.000000Z",
  "created_at": "2026-08-20T09:00:00.000000Z",

  "token": "eyJ...",

  "roles": [ { "id": 1, "name": "Admin" } ],
  "permissions": ["users.index", "users.view", "..."],

  "completed_orders_total": 4,
  "cancelled_orders_total": 1
}
```
- `roles`/`permissions` only appear for `super_admin`/`admin` user types, and only when the `roles` relation was eager-loaded by the service (it is, on admin login).
- `completed_orders_total`/`cancelled_orders_total` only appear for `user_type === individual`.
- `token` only appears when a token was actually issued for this response (present on login/verify-that-auto-logs-in/refresh; **absent** on Company/Technician register-verify, which deliberately does not auto-login — see below).
- `verification_token` (a separate field, not `token`) appears instead on responses mid-OTP-flow (register/forgot-password) that hand back a token identifying that OTP session rather than an auth token.

## OTP Codes Are Always `123456` in This Environment

`generate_unique_code()` (`app/Helpers/helpers.php`) is what every OTP-sending call in the app uses (register, login-verify, forgot-password, change-email, change-phone — all four roles). Its actual body is:
```php
function generate_unique_code($length, $model, $col = 'code', $type = 'numbers', $letter_type = 'all'): string
{
    return '123456';
    // ...the rest of the function (real randomization) is unreachable dead code.
}
```
So there is no need to intercept an email/SMS to get a real code while exercising any verification flow against this environment — `123456` always works, live-verified against `client.auth.forgot`/`verify-reset-code`/`reset-password` while producing this documentation. This is presumably a development-only stand-in; don't assume it holds in staging/production without checking this function there first.

## Per-Role Auth Flow

All four roles share the same OTP-based register/verify skeleton (`VerificationFacade::send/verify/resend`), driven by whichever of `email`/`phone` the request's `login` field resolves to (`detectLoginField()` in `app/Helpers/helpers.php`). They differ in what happens after verification and what login requires.

### Client (Individual) — `Shared\AuthController` → `Shared\Auth\AuthService`

1. **Register** — request cached under `pending_registration:{verification_token}` (not written to `users` yet); OTP sent; response carries `verification_token`, no `token`.
2. **Verify** (`code` + `verification_token`) — creates the `User` row (`status: active`, `user_type: individual`) plus `individualProfile`, and **auto-logs-in** — response includes `token`.
3. **Login** — `auth('api')->attempt()` on whichever field (email/phone) `login` resolves to; rejects (422) if `status !== active`.
4. **Forgot/Reset password** — `forgot-password` sends an OTP to the account's contact method; `verify-reset-code` marks that code confirmed without changing anything; `reset-password` then only needs the already-verified token + new password.
5. **Logout** — optionally deletes the caller's device row (if `device_id` sent), then `auth('api')->logout()` (blacklists the current token).

Individuals go **active immediately on verify** — no admin approval step.

### Company — `Company\AuthController` → `Company\Auth\CompanyAuthService`

Same OTP skeleton, but:
- **Login** requires `user_type === company` (else 403 `company_access_denied`), **and** requires `status === active` — a company stays `pending` after OTP verification and **cannot log in** until an admin calls `POST /admin/companies/{id}/activate` (422 `company_account_pending` on the attempt). Code comment cites this as "BRD Gap G-05, strict reading" — i.e. an explicit, deliberate product decision, not a bug.
- **Register** takes `legal_name`, `email`, `phone`, `password`, plus optional `phone_code`, `vat_number`, `commercial_registration_no`, `account_owner_phone`, and device fields — cached under `pending_company_registration:{token}`. (`vat_number` is the input field name; it's persisted and returned back as `tax_number` on the company profile — see `CompanyProfileResource` in company.md.)
- **Verify** creates the `User` (`status: pending`, `user_type: company`) + `companyProfile`, but **does not auto-login** — no `token` in the response, just the `company_account_pending` message. The client app must show a "pending review" state, not attempt to use a token that was never issued.
- See [WORKFLOWS.md](WORKFLOWS.md#company-onboarding) for the full register → verify → admin-approve → login sequence.

### Technician — `Technician\AuthController` → `Technician\Auth\TechnicianAuthService`

Same shape as Company:
- **Login** requires `user_type === technician` (else 403) and `status === active`.
- **Register** is the largest payload of the four: personal fields, `category_id`, `region_ids`/coverage areas, `years_experience`, `daily_order_capacity` (default 5), plus four file uploads (`personal_photo`, `national_id_front`, `national_id_back`, `cv_file`) — cached under `pending_technician_registration:{token}`.
- **Verify** creates the `User` (`status: pending`) + `technicianProfile` (`employment_type: freelance`, `availability_status: available`, `avg_rating: 0.0`, `completed_orders_count: 0`) and syncs coverage areas — **no auto-login**, same as Company. `employment_type` matters beyond labeling: `Wallet::isFreelanceTechnician()` gates withdrawal eligibility on it (`in_house` technician wallets are locked at zero), so self-registered technicians are always created as withdrawal-eligible `freelance` — only an admin manually setting a technician to `in_house` (via `PUT /admin/technicians/{id}`) locks their wallet.
- Approval: `POST /admin/users/{id}/activate` (generic `UserController`, not a technician-specific endpoint — companies get a dedicated `CompanyController::activate` because of the extra commercial-register step; technicians don't need one, so they go through the generic user endpoint).
- See [WORKFLOWS.md](WORKFLOWS.md#technician-onboarding).

### Admin — `Admin\AuthController` → `Admin\Auth\AdminAuthService`

**Only `login`/`logout` exist** — no register/verify/forgot-password/reset-password. Admin accounts are provisioned by seeder or by another admin (`POST /admin/users` with `user_type: admin`), never self-registered.

- Login is **double rate-limited**: the `admin.login_throttle` route middleware (`AdminLoginThrottle`) allows **5 attempts / 60s per IP** (429 on exceed), and `AdminAuthService::login()` independently applies its own **5 attempts / 60s keyed by the lowercased/trimmed `login` string** (also 429). Both must pass.
- Requires `user_type` to be `super_admin` or `admin` (else 403 `access_denied`).
- Requires `status === active`; otherwise 403 with a status-specific message (`account_suspended` / `account_rejected` / `account_pending` / `account_not_active`).
- On success: rate limiter cleared, `last_login_at` updated, `roles.permissions:id,name,group` eager-loaded, `LoginResource` returned with `token` and message `auth.login_success`.

## RBAC (Admin only)

Client/Company/Technician endpoints only gate on account status (`ActiveMiddleware` — must be `active`). **Admin** endpoints additionally run `rbac.auto` → `App\Http\Middleware\RbacAutoCheck`.

### Hard admin/non-admin boundary (always enforced) vs. fine-grained permissions (bypassed in `local`/`testing`)

`RbacAutoCheck::handle()` does two separate things, and only the second one is skipped outside a real permission-checking environment:

```php
if ($user && ! UserType::tryFrom($user->user_type)?->isAdmin()) {
    throw new HttpException(403, __('You do not have permission to access this resource.'));
}

# TODO: Enable fine-grained RBAC permission checking when permissions are fully seeded
if (app()->environment('local', 'testing')) {
    return $next($request);
}
```
1. **Always enforced, in every environment**: a valid client/company/technician token is hard-rejected (403) the moment it hits any admin route — this check runs *before* the local/testing bypass, so it can't be skipped by environment. Only `super_admin`/`admin` user types can pass this line at all.
2. **Bypassed in `local`/`testing`**: the fine-grained per-resource permission check (the `{resource}.{action}` slug resolution described below) is skipped entirely in those two environments — any authenticated admin-type user passes every admin endpoint regardless of role/permissions. **All the captured example responses in this documentation were taken against a `local`-environment server, so none of them exercise a real fine-grained-permission 403** — the 403 examples shown for admin endpoints elsewhere in this doc set illustrate the *shape* the response would take, not a live-captured denial. In `staging`/`production`, step 2 is fully enforced — see the gap below before assuming any of this "just works" there.

### Permission derivation (`App\Infrastructure\Auth\PermissionResolver::fromRouteName()`)

For a route name like `admin.orders.update`:
1. Split on `.` → `['admin', 'orders', 'update']`.
2. Strip known prefixes wherever they occur: `admin`, `client`, `shared`, `api`, `v1`, `v2` → `['orders', 'update']`.
3. Need ≥ 2 remaining segments, or the route is treated as unresolvable/public (passes through with no check).
4. `resource = parts[0]`, `action = end(parts)`.
5. Action is remapped: `index→index`, `show→view`, `store→create`, `create→create`, `update→update`, `destroy→delete`, `edit→update`; anything else (e.g. `approve`, `reject`, `sign`, `toggle-status`) passes through unchanged.
6. Result: `"{resource}.{mappedAction}"`.

Every admin endpoint's **Required permission** line in [admin.md](admin.md) was computed with this exact algorithm — e.g. `admin.internal-notes.store` → `internal-notes.create`, `admin.contracts.renewal` → `contracts.renewal`, `admin.addresses.bulk.destroy` → `addresses.delete`.

Permission check (`RbacEngine::userCan()`): the authenticated admin's roles (from `user_roles` pivot, or legacy `role_id` fallback) are expanded up the parent chain (`RoleHierarchy::expandWithParents()`), then every `Permission` attached to any of those roles is checked for an exact match against the resolved slug, **or** a wildcard `"{resource}.*"`. No `super_admin` bypass exists in code — a super_admin only passes because of whatever's actually attached to their role.

On failure: `HttpException(403, "You do not have permission: {permission}")`, with response header `X-Missing-Permission: {permission}`.

### ⚠️ Known gap: seeded permissions don't cover most admin resources, and no role has any attached

Two separate problems, found by reading the actual seeders (`database/seeders/`), not assumed:

1. **`PermissionSeeder`** only creates `Permission` rows (CRUD × resource) for: `countries, governorates, regions, shipping-zones, categories, faqs, static-pages, sliders, users, supervisors, roles, settings, feature-flags, audit-logs, contact-messages, notifications, dashboard-home, conversations, internal-notes`, plus one non-CRUD `reports.operational`. There is **no seeded permission at all** for `branches`, `technicians`, `contracts`, `invoices`, `addresses`, `orders`, `wallets`, `withdraw-requests`, `companies`, or `permissions` itself — the resources that arguably matter most operationally.
2. **`RolePermissionSeeder` and `UserRoleSeeder` are both empty stubs** (`public function run(): void { // }`) and are **not even called** from `DatabaseSeeder::run()`. The only seeder that actually populates `permission_role` is `ReportingPermissionSeeder`, which grants `reports.operational` to `super_admin`, `admin`, `dispatcher`, `support` (explicitly excluding `finance`).

**Practical consequence:** on a fresh `php artisan db:seed` deployed to `staging`/`production` (where the local/testing bypass doesn't apply), **no admin account — including `super_admin` — can perform any RBAC-gated action except the operational report**, until `permission_role` and `user_roles` rows are populated by hand or a custom seeder. This is very likely a "seeders are incomplete" gap rather than intended behavior, but it's what the shipped code does — confirm with whoever owns the RBAC rollout before assuming any staging/production admin flow works out of the box.

Seeded roles (`RoleSeeder`): `super_admin`, `admin`, `support`, `finance`, `dispatcher` (hierarchical via `parent_id`, translatable `name`).

## Other Auth-Adjacent Middleware

| Middleware | Applies to | Behavior |
|---|---|---|
| `active` (`ActiveMiddleware`) | All authenticated client/company/technician/admin routes | 403 if `status !== active`; message varies by actual status (`suspended`/`rejected`/`pending`/other) |
| `admin.login_throttle` (`AdminLoginThrottle`) | `admin.auth.login` only | 5 attempts / 60s per **IP** → 429 |
| `idempotency` (`IdempotencyMiddleware`) | Cart checkout (`client.cart.checkout`, `company.cart.checkout`) | Requires `Idempotency-Key` header on POST/PUT; replays a cached response for a repeated key within 24h (`X-Idempotent-Replay: true`), or 409 `IDEMPOTENCY_CONFLICT` if the same key is still mid-flight |
| `geo.context` (`GeoContextMiddleware`) | Client public-content and authenticated routes | Resolves country/currency context in priority order: request header → user profile → IP geolocation → config default (`geo.default_country`/`geo.default_currency`); doesn't block the request, just populates context used by downstream pricing/geo logic |
