Appearance
Authentication System
Upvendo uses a custom JWT-based authentication system (not Laravel Sanctum/Passport). Four tokenable models share the same JWT infrastructure: Users (backoffice), Customers (online ordering), Devices (kiosk/KDS), and FieldSessions (the Field Ops app). (Verified: the match on model_type in JwtService::getModelFromToken(), app/Services/JwtService.php.)
Key Files
| File | Purpose |
|---|---|
app/Http/Middleware/JwtAuthenticate.php | Core JWT validation middleware |
app/Http/Middleware/Authenticate.php | Laravel auth middleware override |
app/Http/Middleware/TokenType.php | Validates user type matches route |
app/Http/Middleware/OptionalJwtAuth.php | Optional auth (for guest + auth routes) |
app/Http/Middleware/CheckPermission.php | RBAC permission checking |
app/Http/Middleware/SetTenantDatabase.php | Multi-tenant database routing |
app/Http/Middleware/CapacitorApiKey.php | API key auth for Capacitor apps |
app/Http/Middleware/AdminVendorOverride.php | ?vendor_id merchant override for internal admins / the merchant's reseller |
app/Http/Middleware/EnsureLocationBelongsToMerchant.php | {locationId} cross-tenant ownership guard |
app/Http/Middleware/EnsureGlobalFieldOpsAuthority.php | Global field-ops surface guard |
app/Http/Middleware/VerifyTurnstile.php | Cloudflare Turnstile bot check on two guest routes (see below) |
bootstrap/app.php | Where every middleware alias is registered |
app/Services/JwtService.php | JWT token creation and validation |
app/Services/AuthService.php | BackOffice auth business logic |
app/Services/AuthCustomerService.php | Customer auth business logic |
app/Services/AuthDeviceService.php | Device auth business logic |
app/Services/PasskeyService.php | WebAuthn passkey management |
JWT Token Structure
Tokens are generated by JwtService and contain:
Header: { alg: HS256, typ: JWT }
Payload: {
iss: <app_url>, // config('app.url')
iat: <timestamp>, // Issued at
sub: <model_id>, // MongoDB ObjectId as string
model_type: <class>, // FQCN: App\RawModels\User | Device | Customer | FieldSession
exp: <timestamp> // Expiration (absent for device tokens)
}There is no type claim. The audience of a token is carried by model_type (the fully qualified model class), and that is what JwtService::getModelFromToken() matches on to pick the repository; a token missing model_type is rejected outright as "Invalid token structure". The per-audience extra claims are:
| Model | Extra claims added by JwtService |
|---|---|
User | user_type (always the literal string "user"), email, username |
Device | device_type -- a DeviceTypes value, e.g. "Kiosk" or "Kitchen Display" (not "kds") |
Customer | email, phone |
FieldSession | aud = "upvendo-field", rep_id |
Callers then merge custom claims on top. tenant_database is one of those -- it is not added by JwtService itself: AuthService adds tenant_database + remember_me (+ reseller_id when present) and AuthDeviceService adds tenant_database, device_id, device_type and token_version.
(Verified: app/Services/JwtService.php -- generateToken() builds the payload and getModelFromToken() resolves on model_type; custom claims in app/Services/AuthService.php (generateUserToken) and app/Services/AuthDeviceService.php (activate).)
Token Lifetimes
- User tokens:
config('jwt.ttl')minutes (JWT_TTL, default 10080 = 7 days), orconfig('jwt.remember_ttl')minutes (JWT_REMEMBER_TTL, default 43200 = 30 days) when the login carriedremember_me. Computed inAuthService::generateUserToken()(app/Services/AuthService.php:447-448);JwtService::generateToken()falls back to 7 days when no expiry is passed. Setting either env var does change token lifetime — the defaults are byte-identical to the previously hardcoded 7/30 days, so observed lifetimes are unchanged, but the values are now genuinely configurable. - Device tokens: No expiration (
expclaim is absent) -- devices stay authenticated until explicitly logged out - Customer tokens: 7 days (
AuthCustomerServicepasses no explicit expiry, so theJwtServicedefault applies) - Field Ops tokens: 30 minutes by default (
FIELD_OPS_FIELD_JWT_TTL_MIN), refreshed viaPOST /field/auth/refresh
(Verified: app/Services/JwtService.php generateToken(); app/Services/AuthService.phpgenerateUserToken(); app/Services/AuthCustomerService.php; app/Services/FieldOps/FieldAuthService.php with config('field_ops.field_jwt_ttl_min', 30).)
Token Validation Flow
Request -> JwtAuthenticate middleware
1. Extract Bearer token from Authorization header
2. Validate token signature and expiration via JwtService::validateToken()
3. Resolve model from token via JwtService::getModelFromToken()
- Returns User, Device, or Customer model based on token type
4. Set user resolver on Request object
5. Set tenant database from the token's tenant_database claim if present
6. Set config('auth.current_reseller_id') from the reseller_id claim if present
7. Optionally check user type against route requirementsFailure modes: missing bearer token aborts 401 "Unauthenticated"; an unreadable or expired token aborts 401 "Invalid token"; a valid token whose subject no longer resolves returns 401 with the TokenAuthenticationException message.
(Verified: app/Http/Middleware/JwtAuthenticate.php handle().)
Authentication Flows
1. Email/Password Login (BackOffice)
Route: POST /api/loginController: AuthController::login()Service: AuthService::loginWithDeviceCheck()
Flow:
- Client sends
{ email, password } LoginRequestvalidates input- The account's email must be verified.
AuthService::loginWithDeviceCheck()short-circuits before the credential/device logic when! $user->isEmailVerified(), returning{ status: 'verification_required', email, message }— no token and norequires_challenge, even for perfectly correct credentials (AuthService.php:701-707) AuthService::loginWithDeviceCheck()verifies credentials- If the user has a passkey registered, the response includes
requires_challenge: true- Client must complete a second authentication step (OTP or Passkey)
- On success, returns JWT token and user data
Response (direct login):
jsonc
{
"token": "eyJ...",
"user": { /* user object */ }
}Response (requires second factor):
json
{
"requires_challenge": true,
"challenge_methods": ["otp", "passkey"],
"email": "user@example.com"
}Response (email not yet verified — returned instead of a token, whatever the credentials):
json
{
"status": "verification_required",
"email": "user@example.com",
"message": "Please verify your email address before logging in."
}2. OTP Authentication (BackOffice Two-Factor)
Routes:
POST /api/back-office/request-otp-- Send OTPPOST /api/back-office/verify-otp-- Verify OTP and get token
Flow:
- After initial login returns
requires_challenge: true - Client calls
request-otpwith{ email } - Server generates 6-digit OTP, stores it with TTL, sends via email
- Job dispatched:
SendBackofficeOTPMail - Client submits
{ email, otp_code }toverify-otp - Server validates OTP code and expiration
- Returns JWT token on success
3. Passkey Authentication (WebAuthn)
Routes:
POST /api/back-office/passkeys-- Get authentication challengePOST /api/back-office/authenticate-passkey-- Verify passkey response
Setup Routes (authenticated):
GET /api/back-office/passkeys/setup-- Get registration optionsPOST /api/back-office/passkeys/setup-- Register passkeyGET /api/back-office/passkeys-- List registered passkeysDELETE /api/back-office/passkeys-- Delete passkey
Flow (authentication):
- Client requests challenge:
POST /passkeyswith{ email } - Server generates WebAuthn challenge via
PasskeyService - Client uses browser WebAuthn API to sign challenge
- Client sends signed response to
authenticate-passkey - Server verifies signature against stored public key
- Returns JWT token on success
Flow (registration):
- Authenticated user requests setup options:
GET /passkeys/setup - Server returns WebAuthn registration options
- Client creates credential via browser API
- Client sends credential to
POST /passkeys/setup - Server stores public key for future authentication
4. Customer OTP Login
Routes:
POST /api/customer/send-otp-- Send OTP to phone/emailPOST /api/customer/login-- Verify OTP and login
Controller: AuthCustomerControllerService: AuthCustomerService
Flow:
- Customer provides phone or email
- OTP sent via SMS (
SendSMSjob) or email (SendVerificationCodeMailjob) - Customer submits OTP code
- Server validates and returns a JWT token whose
model_typeisApp\RawModels\Customer - If customer doesn't exist, account is created automatically
The whole
/customerguest prefix is rate-limitedthrottle:20,1(20 req/min per IP). Both routes are unauthenticated and abuse-prone —send-otpcreates a Customer and sends an SMS/email per call, andloginhas no per-attempt counter, so a 6-digit OTP would otherwise be grindable for its full 15-minute validity. 20/min leaves room for a venue behind one NAT while capping the brute-force window at roughly 300 attempts (0.03% of the keyspace).
send-otpadditionally carries a Cloudflare Turnstile check —verify.turnstile:storefront_secret(routes/api/guest.php:110-111). A per-IP throttle cannot see a flood spread across many IPs, and this is the call that spends real money (SMS) and grants the loyalty sign-up bonus. It is inert whileTURNSTILE_STOREFRONT_SECRET_KEYis unset — the middleware returns$next($request)before looking at the token — so with an empty secret a storefront that sends no token still logs the customer in. Whether the secret is set on any given environment is not knowable from this repository;.env.exampleis not a deployment.POST /api/customer/loginandPOST /api/customer/login/googlecarry no Turnstile check at all. Full behaviour, including the fail-open rule, is in Cloudflare Turnstile below.
4a. Customer Google Sign-In
Route: POST /api/customer/login/google (guest, shares the /customer throttle:20,1 bucket — routes/api/guest.php:113; no Turnstile check) Controller: AuthCustomerController::loginWithGoogleService: AuthCustomerService::loginWithGoogle
Shipped 2026-07-26; the storefront login dialog now offers it alongside OTP.
| Field | Rules |
|---|---|
credential | required string — a Google ID token |
location_id | nullable string, must exist in locations |
The ID token is verified against config('services.google.*'); the resulting session is an ordinary customer JWT, identical to the OTP path.
5. Device Activation (Kiosk / KDS)
Route: POST /api/device-auth/activateController: AuthDeviceController::activate()Service: AuthDeviceService::activate()
Flow:
- Merchant creates device in BackOffice, receives activation code
- Activation code can be sent via email:
POST /back-office/devices/{id}/send-activation-code - Physical device enters the activation code
POST /device-auth/activatewith{ activation_code }(optionallydevice_info,network_info)- Server looks the device up by activation code alone, bumps
token_version, and marks itis_activated - An active subscription is required -- otherwise the call fails with 400 "Device does not have an active subscription." Two device kinds are exempt: any device flagged
is_test, and any POS device ("POS devices are FREE for now -- wire billing here later",AuthDeviceService.php:150-159). Kiosk, Kitchen Display and Printer are all billed here. - Returns JWT token without expiration (persistent auth)
- Token payload carries
device_typeset verbatim to the device'sDeviceTypesvalue --"Kiosk","Kitchen Display","POS"or"Printer"(AuthDeviceService.php:217,'device_type' => $device->getType()) -- plusdevice_id,tenant_databaseandtoken_version. There is notypeclaim.
The device token persists until the device is explicitly logged out or deactivated.
Scope. The endpoint itself is device-type-agnostic: it matches on activation_code with no type filter, and DeviceTypes also defines POS and Printer. TokenType -- the middleware behind every type: gate -- implements the cases kds, kiosk, pos, backoffice, field and customer. A POS-typed device token passes type:pos (TokenType.php:53-56), which is what the whole /pos route group is gated on (routes/api/pos.php:17). Printer devices never use type: at all -- they authenticate through the separate printer.auth middleware (AuthenticatePrinterToken) on routes/api/print.php. The device subscription check inside TokenType still only runs for Kiosk and Kitchen Display (TokenType.php:83).
(Verified: app/Services/AuthDeviceService.php activate() -- lookup by activation_code, is_activated write, subscription check and custom claims; app/Enums/DeviceTypes.php for the four enum cases; app/Http/Middleware/TokenType.php isAuthorized() for the six implemented cases and checkDeviceSubscription() for the Kiosk/KitchenDisplay-only subscription gate.)
6. Email Verification (BackOffice)
Registration no longer issues a token. AuthService::register() returns { status: 'verification_required', email, message } and dispatches the verification mail (AuthService.php:201-210).
| Route | Body | Purpose |
|---|---|---|
POST /api/back-office/verify-email | { token } | Consume the emailed verification token |
POST /api/back-office/resend-verification | { email } | Re-send the verification mail |
Both are guest routes (routes/api/guest.php:29-30). The back-office page is /verify-email (blank layout, unauthenticatedOnly); LoginForm and RegisterForm both redirect there on a verification_required response.
The link token arrives as
?verificationToken=, never?token=. A bare?token=query parameter is claimed byApp.vueas a JWT access token and will break session bootstrap. Keep the distinct name when building or debugging verification links.
7. Google Sign-In (BackOffice)
Route: POST /api/login/google (throttle 5/min, routes/api/guest.php:11)
Takes { credential } — the JWT issued by Google Identity Services. The button is rendered only when VITE_GOOGLE_CLIENT_ID is set in the back-office build, so it is absent from any build without that variable. The response funnels through the same verification_required handling as flow 1.
Cloudflare Turnstile (bot check on guest routes)
VerifyTurnstile (app/Http/Middleware/VerifyTurnstile.php, alias verify.turnstile, bootstrap/app.php:53) verifies a Cloudflare Turnstile token. It guards exactly two routes:
| Route | Middleware string | Secret it verifies against | Env var |
|---|---|---|---|
POST /api/back-office/register | verify.turnstile (default arg) | services.turnstile.secret | TURNSTILE_SECRET_KEY |
POST /api/customer/send-otp | verify.turnstile:storefront_secret | services.turnstile.storefront_secret | TURNSTILE_STOREFRONT_SECRET_KEY |
(Verified: routes/api/guest.php:25-26 and routes/api/guest.php:110-111; the handle(..., string $configKey = 'secret') parameter and config("services.turnstile.{$configKey}") lookup at VerifyTurnstile.php:26-28; config/services.php:365-374; .env.example:292-298.)
The two surfaces run separate Cloudflare widgets on purpose: widget mode (Managed / Non-Interactive / Invisible) and analytics are per-widget dashboard settings, and a visible challenge that is free on a merchant signup form costs conversions on the ordering path.
What the client must send
A body field named cf-turnstile-response, carrying the token Cloudflare's widget produced. It is read with $request->input('cf-turnstile-response') (VerifyTurnstile.php:29), so it is a JSON/form body (or query-string) field — not a header. There is no X-Turnstile-Token header and no alternative field name.
Two clients send it today, one per guarded route:
- Back-office register form —
upvendo-backoffice/src/views/authentication/RegisterForm.vue:175posts'cf-turnstile-response': this.turnstileToken, with the widget rendered only whenVITE_TURNSTILE_SITE_KEYis set in that build (RegisterForm.vue:20,83). - Storefront login dialog —
zestidoo-online-ordering/src/components/LoginDialog.vue:338appends the token to the OTP payload (OtpPayloadcarries an optional'cf-turnstile-response',src/utils/interfaces.ts:23). Its widget uses its own Cloudflare widget and its ownVITE_TURNSTILE_SITE_KEY, renderedappearance: 'interaction-only'so it stays invisible unless Cloudflare wants an interaction (LoginDialog.vue:166-179).
The backend never reads
services.turnstile.site_key/TURNSTILE_SITE_KEY— nothing inapp/references it. The site key a browser needs is a frontend build variable (VITE_TURNSTILE_SITE_KEY); the config entry exists only for reference (config/services.php:368).
Failure responses
Both failure modes are HTTP 403 with the same envelope shape:
json
{ "error": { "code": "CAPTCHA_REQUIRED", "message": "Verification required" } }| Condition | Code | Message |
|---|---|---|
Secret configured, cf-turnstile-response empty or absent | CAPTCHA_REQUIRED | Verification required |
| Cloudflare's siteverify replies non-2xx | CAPTCHA_INVALID | Verification failed |
Cloudflare's siteverify replies success != true | CAPTCHA_INVALID | Verification failed |
Cloudflare unreachable (ConnectionException, 5s timeout) | CAPTCHA_INVALID | Verification failed |
Verification is a server-to-server POST to https://challenges.cloudflare.com/turnstile/v0/siteverify with secret, response and remoteip ($request->ip()), 5-second timeout. A connection failure is logged as Turnstile siteverify unreachable and then rejected, so a Cloudflare outage blocks the guarded route rather than waving traffic through.
(Verified: VerifyTurnstile.php:36-43 for CAPTCHA_REQUIRED, :45-52 for the siteverify call, :53-63 for the connection-failure branch, :66-73 for the invalid branch; matching assertions in tests/Unit/Http/Middleware/VerifyTurnstileTest.php.)
It fails OPEN when its secret is unconfigured
This is the detail that decides how a misconfigured environment behaves, so state it exactly:
- Secret configured → fail closed. Every request without a valid token gets 403.
- Secret empty or unset → fail open.
if ($secret === '') { return $next($request); }(VerifyTurnstile.php:31-34). The request is passed through unverified. A missingTURNSTILE_STOREFRONT_SECRET_KEYdoes not lock customers out — it silently disables the protection on/api/customer/send-otp.
The check is per-key, not global: an unset storefront_secret passes /customer/send-otp through even while TURNSTILE_SECRET_KEY is set and /back-office/register is fully enforced (VerifyTurnstileTest.php:198-217).
That is deliberate — it let the middleware ship attached to the live /customer/send-otp route without 403-ing every OTP request on merge, to be switched on later by setting the env var. The practical consequence: whether either route is actually protected depends on a deployed environment variable, which is not in the repo. Never infer "Turnstile is on" from the route definition; check the environment. Per the commit body of e3727c428, TURNSTILE_SECRET_KEY is already configured in production (so back-office registration is enforced) while TURNSTILE_STOREFRONT_SECRET_KEY is intentionally left unset at merge time.
Client/server sequencing: both guarded routes now have a client
Both routes that carry verify.turnstile have a shipped client that sends the token, so neither is in the "server ahead of client" state any more:
- zestidoo-online-ordering — the login dialog renders a Turnstile widget and appends
cf-turnstile-responseto the/customer/send-otppayload (LoginDialog.vue:166-179,:338). The token is conditional on the build:initTurnstile()returns early whenVITE_TURNSTILE_SITE_KEYis unset (LoginDialog.vue:179), and the widget'sexpired-callback/error-callbackclear the token, so a request can still go out without one. It is also single-use — the dialog resets the widget after each send so a resend gets a fresh token. - upvendo-kiosk — no Turnstile references at all, and it never calls
/customer/send-otp. Its customer path isPOST /api/kiosk/customer/identify(src/api/services/customer-services.ts:6), a device-authenticated kiosk route (routes/api.php:207) with no Turnstile middleware. The OTP input on the kiosk login screen is the 12-character device activation code (src/pages/login.vue:29,60), not a customer OTP.
The sequencing rule still holds, but the failure it guards against has changed shape. A secret may only be enabled on an environment whose deployed storefront build both includes the widget and has VITE_TURNSTILE_SITE_KEY set — a build without the site key sends no token, and the middleware fails closed once its secret exists. So a CAPTCHA_REQUIRED flood no longer means "no client exists"; it means the deployed build is stale or its site key is missing. Unsetting the secret restores sign-in either way.
Middleware Stack
Route Middleware Aliases
Production registers 28 aliases (bootstrap/app.php:31-58). This is the complete set, in registration order.
| Alias | Middleware Class | Description |
|---|---|---|
auth.optional | OptionalJwtAuth | JWT validation if a token is present |
type:{type} | TokenType | Verify token audience -- kds, kiosk, pos, backoffice, field, customer |
tenant:{context} | SetTenantDatabase | Set tenant DB connection |
super-admin | SuperAdmin | Require super admin role. Registered but applied to no route on production |
global-admin | GlobalAdministrator | Require global-administrator access (admin dashboards, async monitoring, guided-setup management) |
check-user-activity | CheckUserActivity | Track user last activity |
auth | JwtAuthenticate | JWT token validation (required) |
auth.sanctum | Authenticate | Sanctum-based auth, kept as an option; used by the /reseller commission routes |
permission:{perm} | CheckPermission | RBAC permission check |
capacitor.auth | CapacitorApiKey | Validate Capacitor API key header |
pos.staff | PosStaffToken | POS staff PIN-session token (header X-Pos-Staff-Token) |
printer.auth | AuthenticatePrinterToken | Thermal-printer poll token; binds the tenant from the device |
verify.uber-eats-webhook | VerifyUberEatsWebhook | Uber Eats webhook signature |
verify.shopify-webhook | VerifyShopifyWebhook | Shopify webhook signature |
verify.deliveroo-webhook | VerifyDeliverooWebhook | Deliveroo webhook signature |
verify.square-webhook | VerifySquareWebhook | Square webhook signature |
verify.shopcaisse-webhook | VerifyShopCaisseWebhook | ShopCaisse webhook signature |
verify.mpluskassa-webhook | VerifyMplusKassaWebhook | MplusKassa webhook signature |
verify.crm-webhook | VerifyCrmWebhook | CRM intake shared bearer token |
verify.lightspeed-k-series-webhook | VerifyLightspeedKSeriesWebhook | Lightspeed K-Series webhook signature |
verify.viva-webhook | VerifyVivaWebhookIp | Viva Wallet webhook IP allowlist |
verify.admin-switch-ip | VerifyAdminSwitchIp | IP gate on the device admin-switch endpoints |
verify.turnstile | VerifyTurnstile | Cloudflare Turnstile check. Takes an optional config-key argument selecting which widget secret to verify against: bare on /back-office/register (services.turnstile.secret), verify.turnstile:storefront_secret on /customer/send-otp. Fails open when that secret is unset |
admin-vendor-override | AdminVendorOverride | Let an authorised internal admin / the merchant's own reseller act on a merchant via ?vendor_id |
location-owner | EnsureLocationBelongsToMerchant | {locationId} cross-tenant guard (see below) |
first-party-pos | EnsureFirstPartyPos | First-party POS/KDS gate -- 403 unless the tenant merchant has pos_provider === 'upvendo' |
field-ops-authority | EnsureGlobalFieldOpsAuthority | Global field-ops surface guard on /back-office/field-ops/* |
e2e.auth | E2EAuthMiddleware | E2E test token gate; 403s in production |
guest is not in this list -- it is Laravel's stock alias for RedirectIfAuthenticated, which is a no-op for unauthenticated API callers. Wrapping a route in guest therefore grants no protection at all; that is why the dev/test routes in routes/api/guest.php carry an explicit e2e.auth gate on top of it.
(Verified: bootstrap/app.php -- the single $middleware->alias([...]) call; the guest no-op behaviour is spelled out in the C01 comment in routes/api/guest.php.)
location-owner -- the {locationId} cross-tenant guard
Location and ThirdPartyIntegration live on the global mongodb connection, so a route keyed only by {locationId} would otherwise resolve any tenant's location regardless of the caller's bound tenant DB. EnsureLocationBelongsToMerchant closes that IDOR.
- Resolves
{locationId}(the route-parameter name is configurable as a middleware argument, defaulting tolocationId). A missing or non-string parameter is a pass-through, so the alias is harmless on merchant-level routes. - Returns 404 "Location not found" if no location resolves.
- Returns 403 "This location does not belong to the current merchant." if the location's merchant differs from the merchant bound to the current tenant DB.
- Reads the location
withTrashed: true. It is purely an ownership gate, so a soft-deleted location still resolves for its legitimate owner rather than newly 404-ing; existence semantics stay the controller's concern. A trashed location still carries itsvendor_id, so the comparison is unaffected. - Fails closed: a null current merchant (no tenant bound) can never equal a real location owner.
- Must be ordered after
admin-vendor-override. That ordering is what allows an internal admin who legitimately overrode to merchant B (bound DB == B, location owner == B) while still blocking an ordinary user of merchant A from reaching a B-owned location.
Applied on the {locationId}-keyed integration prefixes: Uber Eats, Deliveroo, Shopify, Kassanet and the location-scoped ShopCaisse group.
(Verified: app/Http/Middleware/EnsureLocationBelongsToMerchant.php -- handle() and its class docblock; prefix middleware arrays in routes/api/backoffice/uber-eats.php, deliveroo.php, shopify.php, kassanet.php and shopcaisse.php.)
Common Middleware Combinations
BackOffice routes:
auth -> type:backoffice -> tenant:backoffice -> check-user-activity -> permission:{specific}Kiosk routes:
auth -> capacitor.auth -> type:kiosk -> tenant:kioskKDS routes:
auth -> type:kds -> tenant:kdsCustomer routes:
auth -> type:customerOnline ordering (guest with optional auth):
auth.optional -> type:customer -> tenant:online-orderingField Ops app:
auth -> type:fieldIntegration routes (on top of the backoffice stack):
admin-vendor-override -> location-owner -> permission:MANAGE_INTEGRATION_SETTINGSlocation-owner only appears on prefixes keyed by {locationId}, and always after admin-vendor-override.
(Verified: routes/api.php for the backoffice / kiosk / KDS / customer / online-ordering groups; routes/api/field.php for ['auth', 'type:field']; the integration prefix groups under routes/api/backoffice/.)
Permission System
Permissions are checked by CheckPermission middleware which delegates to PermissionService.
How Permission Checks Work
- Middleware receives required permission string (e.g.,
permission:VIEW_ITEMS) - Multiple permissions can be OR'd:
permission:EDIT_USERS|EDIT_USER_LOCATION_ACCESS PermissionService::extractMerchantAndLocationIds()gets context from the request -- route paramsvendorId/locationIdfirst, then the body fieldsvendor_id/location_idPermissionService::hasPermission()checks if user's role grants the permission- Returns 401 "Unauthenticated" if no user is resolved, or 403 "Unauthorized: Insufficient permissions" if no matching permission is found
The method is named extractMerchantAndLocationIds, not extractVendorAndLocationIds -- the Merchant naming is the current one, even though the request fields it reads are still called vendorId / vendor_id.
(Verified: app/Services/PermissionService.php -- extractMerchantAndLocationIds(); called at app/Http/Middleware/CheckPermission.php in handle(), which aborts 401/403.)
Permission Scoping
Permissions are scoped to vendor + location:
- Global permissions: Apply across all locations for a vendor
- Location-specific permissions: Apply only to specific locations assigned to user
Key Permission Constants
Permissions are defined in App\Constants\Permissions and follow the pattern: VIEW_*, CREATE_*, EDIT_*, DELETE_*, EXPORT_*
Examples: VIEW_ITEMS, CREATE_ITEMS, EDIT_ITEMS, DELETE_ITEMS, EXPORT_ITEMS
Multi-Tenant Database Routing
The SetTenantDatabase middleware configures the MongoDB connection for the current tenant:
- Middleware sets
config('database.connections.tenant.database')to the tenant DB name, then purges and reconnects thetenantconnection - It also tags
tenantonto the log Context (per request / per queued job) and drops any cached MongoDB session bound to the old client - All subsequent queries on the
tenantconnection use the correct database
Context types determine how the tenant is resolved:
tenant:online-ordering-- from the{slug}route parameter (cached lookup)tenant:customer-- from the{locationId}route parametertenant:backoffice,tenant:kiosk,tenant:kds-- all take the default branch: read the bearer token, and use itstenant_databaseclaim; if that claim is absent, fall back to deriving the database from the already-authenticated model. Missing token returns 401, an unreadable token returns 401.
Because the default branch ignores the context argument, tenant:kiosk, tenant:kds and tenant:backoffice behave identically -- the context name documents intent, it does not restrict the token audience. Audience restriction is the job of type:.
(Verified: app/Http/Middleware/SetTenantDatabase.php handle() -- the three-way branch; app/Traits/TenancyTrait.php setTenantDatabase() for the purge/reconnect, log Context tag and session reset.)
Token Validation Endpoint
Route: GET /api/validate-token
Used by Cloudflare Workers and other services to verify token validity without full auth context.
Returns:
json
{
"valid": true,
"user_id": "...",
"user_type": "App\\RawModels\\User",
"merchant_id": "...",
"has_global_access": false,
"issued_at": 1711500000,
"expires_at": 1711586400,
"valid_until": "2025-03-28 00:00:00",
"expires_in_seconds": 86400
}user_type is the tokenable's fully qualified class name. merchant_id is null for tokens with no merchant scope (device / customer). For a token with no exp claim -- every device token -- the last three fields come back as null, "never" and null respectively. A missing bearer token or an invalid/expired one returns 401 with { "valid": false, "message": ... }.
(Verified: app/Http/Controllers/Api/AuthController.php validateToken().)
Debugging Authentication Issues
Common Problems
401 "Unauthenticated"
- Token missing from Authorization header
- Token expired (check
expclaim) - Token signature invalid (wrong JWT secret between environments)
403 "Unauthorized: Invalid user type"
- Token type doesn't match route requirement
- Example: customer token used on backoffice route
403 "Unauthorized: Insufficient permissions"
- User's role doesn't include the required permission
- Check
PermissionService::hasPermission()logic - Verify user's assigned roles and location access
Tenant database not set
- Token missing
tenant_databaseclaim - Usually happens with malformed or very old tokens
- Token missing
Quick Checks
- Decode JWT at jwt.io to inspect claims
- Verify
JWT_SECRETenvironment variable matches between services - Check
app/Services/JwtService.phpfor token generation logic - For device tokens, confirm no
expfield (they should be permanent)