Skip to content

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_credentialsservice-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;
Auth model — two systems (verified)

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.

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.

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.