The auth model
Two auth systems gate the portal’s own APIs, and keeping them apart explains almost every 401 and 403 you’ll debug here:
- Sanctum personal-access tokens — every end-user persona. A Bearer token scoped to exactly
one ability (
access-<persona>-api). Lifetime 24 h (config/sanctum.php:55). - Passport OAuth2
client_credentials— service-to-service (portal ↔ middleware). Lifetime 30 min (app/Providers/AuthServiceProvider.php:29).
(A third system sits one hop further out — the middleware’s own Azure AD client_credentials
to the Logic Apps and Dynamics orgs, which throws its own 401s. That one is covered on the
CRM integration model page.) Everything below is verified against
the current backend, frontend, and middleware code.
flowchart TB subgraph SANCTUM["Sanctum PATs — end users · 24h · one ability each"] PER[5 personas + the Oman pre-reg identity]:::person --> ABIL[auth:sanctum + ability:access-*-api]:::be end subgraph PASSPORT["Passport client_credentials — services · 30 min"] BE[Backend]:::be -->|portals-api enforced| MW[crms-middleware]:::mw MW -->|middleware-webhook carried, NOT enforced| BE MW -->|crm-webhook enforced| CRM[CRM webhooks]:::ext end classDef person fill:#e7eef6,stroke:#2563A8,color:#0b1b2b; classDef be fill:#dff0f0,stroke:#0C7C84,color:#0b1b2b; classDef mw fill:#efe7f7,stroke:#7A4FB5,color:#0b1b2b; classDef ext fill:#f6e7dc,stroke:#C4622D,color:#0b1b2b;
End users → Sanctum ability tokens
Section titled “End users → Sanctum ability tokens”All five persona route groups gate on auth:sanctum + ability:access-<persona>-api (verified:
broker.php:79, shopper.php:94, sales-man.php:20, sales-manager.php:18, shopper-admin.php:11).
Each persona is a distinct Eloquent model/provider (config/auth.php:68-93).
| Persona | Login | Token minted at | Ability |
|---|---|---|---|
| Shopper | phone + SMS OTP | Api/Shopper/Auth/LoginController.php:68 |
access-shopper-api |
| Broker | email + password | Api/Auth/LoginController.php:67-70 |
access-broker-api |
| Sales-Manager | email + password (same controller, role branch) | Api/Auth/LoginController.php:72-75 |
access-sales-manager-api |
| Sales-Man | Microsoft/Azure SSO (Socialite azure) → ?token= redirect |
SalesMan/Auth/MicrosoftController.php:52-55 |
access-sales-man-api |
| Shopper-Admin | email + password | Api/ShopperAdmin/Auth/LoginController.php:46-49 |
access-shopper-analytics-api |
The token carries exactly one ability, so a token minted for one persona is rejected by every
other persona’s routes — this is what enforces persona isolation. The Sales-Man SSO handoff is the
one to remember: the Azure callback appends the freshly-minted Sanctum token to a redirect
({SALESMAN_FRONTEND_URL}/?token=…), and the SPA stores it (MicrosoftController.php:52-55).
The Oman pre-registration token is a sixth, separate identity
Section titled “The Oman pre-registration token is a sixth, separate identity”access-pre-registration-broker-api (AuthTokenAbilities.php:10) is a Sanctum PAT minted on the
OmanPreRegistrationBroker model — a separate Authenticatable, not a Broker
(app/Models/OmanPreRegistrationBroker.php:9,16) — and is deliberately excluded from
roleAbilities(). No login or role ever grants it, and the ability: guard makes it mutually
exclusive with access-broker-api. Full trace on the
Oman broker pre-registration page.
Service-to-service → Passport client_credentials (three legs)
Section titled “Service-to-service → Passport client_credentials (three legs)”The portal, middleware, and CRM authenticate to each other as OAuth2 clients. There are three legs, and — importantly — the scope is only enforced on two of them:
| Leg | Direction | Scope | Enforced? |
|---|---|---|---|
| 1 | middleware → backend (webhooks) | middleware-webhook |
No — the webhook routes apply the bare client guard (routes/api/crms-middleware.php:7), which accepts any valid client token. The scope is minted (CrmsMiddleware/AuthController.php:14-19) and carried, but not checked. |
| 2 | backend → middleware (portal API) | portals-api |
Yes — registered and gated middleware-side (crms-middleware AuthServiceProvider.php:31, routes/api.php:23). |
| 3 | middleware → CRM (webhooks) | crm-webhook |
Yes — registered and gated middleware-side (crms-middleware routes/api.php:265). |
user-provider:* — what it actually does (and doesn’t)
Section titled “user-provider:* — what it actually does (and doesn’t)”It’s tempting to read user-provider:brokers as “this is what makes Auth::user() a Broker.” Not
quite. UserProvider::handle() (app/Http/Middleware/UserProvider.php:17-32) swaps two runtime config
values for the request: the web (session) guard’s provider and the password-reset broker.
But the persona routes authenticate with auth:sanctum, and Sanctum resolves the caller from the
token’s polymorphic owner (personal_access_tokens.tokenable_type/id) — the exact model that minted
the token, independent of the web-guard provider. So for a Bearer request, Auth::user() comes
from the PAT morph, not from this middleware. user-provider:* really governs the
password-reset flow (which table /forgot-password targets) and any code touching the default web
session guard. The “it makes Auth::user() resolve the right model” mental model is approximately
right end-to-end, but the mechanism is the morph.
Doc drift to know about
Section titled “Doc drift to know about”Where the token lives in the browser
Section titled “Where the token lives in the browser”Each SPA stores its Bearer token in localStorage under an env-configured key name
(NX_*_ACCESS_TOKEN_KEY) and attaches it as Authorization: Bearer <token> via its network.ts
interceptor. The shipped default key names (not secrets):
| Env var | Default key | App |
|---|---|---|
NX_SHOPPER_ACCESS_TOKEN_KEY |
shopper_access_token |
Shopper |
NX_BROKER_ACCESS_TOKEN_KEY |
broker_access_token |
Broker |
NX_BROKER_REGISTRATION_ACCESS_TOKEN_KEY |
broker_registration_access_token |
Oman pre-registration (kept separate) |
NX_SALESMAN_ACCESS_TOKEN_KEY |
salesman_access_token |
Sales-Man |
NX_SHOPPER_ANALYTICS_ACCESS_TOKEN_KEY |
shopper_analytics_access_token |
Shopper-Analytics |
The broker app keeps the pre-registration token under its own key, mirroring the backend’s two
distinct identities. A wrong NX_* key between environments is a classic “logged in but every call
401s” cause.
Support: reading 401s and 403s
Section titled “Support: reading 401s and 403s”| Symptom | Most likely cause |
|---|---|
| 401 on a persona route | Missing/typo’d Bearer header; SPA read the wrong NX_*_ACCESS_TOKEN_KEY; expired token (Sanctum 24 h; Passport service tokens 30 min); or the token was revoked (logout deletes the current PAT, MicrosoftController.php:75). |
| 403 on a persona route | Ability mismatch — wrong persona’s token for the route (e.g. a broker token on a pre-reg route). The ability: guard (CheckForAnyAbility) rejects it; each token carries exactly one ability. |
| 401 service-to-service (portal ↔ middleware) | Bad/rotated CRMS_MIDDLEWARE_CLIENT_ID/SECRET, or the cached 30-min token expired and re-auth failed (cache key crms-middleware). A middleware-side 403 means the client lacks the portals-api scope. |
| Inbound webhook accepted despite a “wrong” scope | Expected — Leg 1’s webhook routes use the bare client guard, so a scope mismatch does not 403 there. |
