Skip to content

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

FilePurpose
app/Http/Middleware/JwtAuthenticate.phpCore JWT validation middleware
app/Http/Middleware/Authenticate.phpLaravel auth middleware override
app/Http/Middleware/TokenType.phpValidates user type matches route
app/Http/Middleware/OptionalJwtAuth.phpOptional auth (for guest + auth routes)
app/Http/Middleware/CheckPermission.phpRBAC permission checking
app/Http/Middleware/SetTenantDatabase.phpMulti-tenant database routing
app/Http/Middleware/CapacitorApiKey.phpAPI 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.phpGlobal field-ops surface guard
app/Http/Middleware/VerifyTurnstile.phpCloudflare Turnstile bot check on two guest routes (see below)
bootstrap/app.phpWhere every middleware alias is registered
app/Services/JwtService.phpJWT token creation and validation
app/Services/AuthService.phpBackOffice auth business logic
app/Services/AuthCustomerService.phpCustomer auth business logic
app/Services/AuthDeviceService.phpDevice auth business logic
app/Services/PasskeyService.phpWebAuthn 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:

ModelExtra claims added by JwtService
Useruser_type (always the literal string "user"), email, username
Devicedevice_type -- a DeviceTypes value, e.g. "Kiosk" or "Kitchen Display" (not "kds")
Customeremail, phone
FieldSessionaud = "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), or config('jwt.remember_ttl') minutes (JWT_REMEMBER_TTL, default 43200 = 30 days) when the login carried remember_me. Computed in AuthService::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 (exp claim is absent) -- devices stay authenticated until explicitly logged out
  • Customer tokens: 7 days (AuthCustomerService passes no explicit expiry, so the JwtService default applies)
  • Field Ops tokens: 30 minutes by default (FIELD_OPS_FIELD_JWT_TTL_MIN), refreshed via POST /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 requirements

Failure 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:

  1. Client sends { email, password }
  2. LoginRequest validates input
  3. 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 no requires_challenge, even for perfectly correct credentials (AuthService.php:701-707)
  4. AuthService::loginWithDeviceCheck() verifies credentials
  5. If the user has a passkey registered, the response includes requires_challenge: true
    • Client must complete a second authentication step (OTP or Passkey)
  6. 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 OTP
  • POST /api/back-office/verify-otp -- Verify OTP and get token

Flow:

  1. After initial login returns requires_challenge: true
  2. Client calls request-otp with { email }
  3. Server generates 6-digit OTP, stores it with TTL, sends via email
  4. Job dispatched: SendBackofficeOTPMail
  5. Client submits { email, otp_code } to verify-otp
  6. Server validates OTP code and expiration
  7. Returns JWT token on success

3. Passkey Authentication (WebAuthn)

Routes:

  • POST /api/back-office/passkeys -- Get authentication challenge
  • POST /api/back-office/authenticate-passkey -- Verify passkey response

Setup Routes (authenticated):

  • GET /api/back-office/passkeys/setup -- Get registration options
  • POST /api/back-office/passkeys/setup -- Register passkey
  • GET /api/back-office/passkeys -- List registered passkeys
  • DELETE /api/back-office/passkeys -- Delete passkey

Flow (authentication):

  1. Client requests challenge: POST /passkeys with { email }
  2. Server generates WebAuthn challenge via PasskeyService
  3. Client uses browser WebAuthn API to sign challenge
  4. Client sends signed response to authenticate-passkey
  5. Server verifies signature against stored public key
  6. Returns JWT token on success

Flow (registration):

  1. Authenticated user requests setup options: GET /passkeys/setup
  2. Server returns WebAuthn registration options
  3. Client creates credential via browser API
  4. Client sends credential to POST /passkeys/setup
  5. Server stores public key for future authentication

4. Customer OTP Login

Routes:

  • POST /api/customer/send-otp -- Send OTP to phone/email
  • POST /api/customer/login -- Verify OTP and login

Controller: AuthCustomerControllerService: AuthCustomerService

Flow:

  1. Customer provides phone or email
  2. OTP sent via SMS (SendSMS job) or email (SendVerificationCodeMail job)
  3. Customer submits OTP code
  4. Server validates and returns a JWT token whose model_type is App\RawModels\Customer
  5. If customer doesn't exist, account is created automatically

The whole /customer guest prefix is rate-limited throttle:20,1 (20 req/min per IP). Both routes are unauthenticated and abuse-prone — send-otp creates a Customer and sends an SMS/email per call, and login has 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-otp additionally carries a Cloudflare Turnstile checkverify.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 while TURNSTILE_STOREFRONT_SECRET_KEY is 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.example is not a deployment. POST /api/customer/login and POST /api/customer/login/google carry 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.

FieldRules
credentialrequired string — a Google ID token
location_idnullable 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:

  1. Merchant creates device in BackOffice, receives activation code
  2. Activation code can be sent via email: POST /back-office/devices/{id}/send-activation-code
  3. Physical device enters the activation code
  4. POST /device-auth/activate with { activation_code } (optionally device_info, network_info)
  5. Server looks the device up by activation code alone, bumps token_version, and marks it is_activated
  6. 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.
  7. Returns JWT token without expiration (persistent auth)
  8. Token payload carries device_type set verbatim to the device's DeviceTypes value -- "Kiosk", "Kitchen Display", "POS" or "Printer" (AuthDeviceService.php:217, 'device_type' => $device->getType()) -- plus device_id, tenant_database and token_version. There is no type claim.

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).

RouteBodyPurpose
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 by App.vue as 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:

RouteMiddleware stringSecret it verifies againstEnv var
POST /api/back-office/registerverify.turnstile (default arg)services.turnstile.secretTURNSTILE_SECRET_KEY
POST /api/customer/send-otpverify.turnstile:storefront_secretservices.turnstile.storefront_secretTURNSTILE_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 formupvendo-backoffice/src/views/authentication/RegisterForm.vue:175 posts 'cf-turnstile-response': this.turnstileToken, with the widget rendered only when VITE_TURNSTILE_SITE_KEY is set in that build (RegisterForm.vue:20,83).
  • Storefront login dialogzestidoo-online-ordering/src/components/LoginDialog.vue:338 appends the token to the OTP payload (OtpPayload carries an optional 'cf-turnstile-response', src/utils/interfaces.ts:23). Its widget uses its own Cloudflare widget and its own VITE_TURNSTILE_SITE_KEY, rendered appearance: '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 in app/ 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" } }
ConditionCodeMessage
Secret configured, cf-turnstile-response empty or absentCAPTCHA_REQUIREDVerification required
Cloudflare's siteverify replies non-2xxCAPTCHA_INVALIDVerification failed
Cloudflare's siteverify replies success != trueCAPTCHA_INVALIDVerification failed
Cloudflare unreachable (ConnectionException, 5s timeout)CAPTCHA_INVALIDVerification 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 missing TURNSTILE_STOREFRONT_SECRET_KEY does 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-response to the /customer/send-otp payload (LoginDialog.vue:166-179, :338). The token is conditional on the build: initTurnstile() returns early when VITE_TURNSTILE_SITE_KEY is unset (LoginDialog.vue:179), and the widget's expired-callback / error-callback clear 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 is POST /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.

AliasMiddleware ClassDescription
auth.optionalOptionalJwtAuthJWT validation if a token is present
type:{type}TokenTypeVerify token audience -- kds, kiosk, pos, backoffice, field, customer
tenant:{context}SetTenantDatabaseSet tenant DB connection
super-adminSuperAdminRequire super admin role. Registered but applied to no route on production
global-adminGlobalAdministratorRequire global-administrator access (admin dashboards, async monitoring, guided-setup management)
check-user-activityCheckUserActivityTrack user last activity
authJwtAuthenticateJWT token validation (required)
auth.sanctumAuthenticateSanctum-based auth, kept as an option; used by the /reseller commission routes
permission:{perm}CheckPermissionRBAC permission check
capacitor.authCapacitorApiKeyValidate Capacitor API key header
pos.staffPosStaffTokenPOS staff PIN-session token (header X-Pos-Staff-Token)
printer.authAuthenticatePrinterTokenThermal-printer poll token; binds the tenant from the device
verify.uber-eats-webhookVerifyUberEatsWebhookUber Eats webhook signature
verify.shopify-webhookVerifyShopifyWebhookShopify webhook signature
verify.deliveroo-webhookVerifyDeliverooWebhookDeliveroo webhook signature
verify.square-webhookVerifySquareWebhookSquare webhook signature
verify.shopcaisse-webhookVerifyShopCaisseWebhookShopCaisse webhook signature
verify.mpluskassa-webhookVerifyMplusKassaWebhookMplusKassa webhook signature
verify.crm-webhookVerifyCrmWebhookCRM intake shared bearer token
verify.lightspeed-k-series-webhookVerifyLightspeedKSeriesWebhookLightspeed K-Series webhook signature
verify.viva-webhookVerifyVivaWebhookIpViva Wallet webhook IP allowlist
verify.admin-switch-ipVerifyAdminSwitchIpIP gate on the device admin-switch endpoints
verify.turnstileVerifyTurnstileCloudflare 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-overrideAdminVendorOverrideLet an authorised internal admin / the merchant's own reseller act on a merchant via ?vendor_id
location-ownerEnsureLocationBelongsToMerchant{locationId} cross-tenant guard (see below)
first-party-posEnsureFirstPartyPosFirst-party POS/KDS gate -- 403 unless the tenant merchant has pos_provider === 'upvendo'
field-ops-authorityEnsureGlobalFieldOpsAuthorityGlobal field-ops surface guard on /back-office/field-ops/*
e2e.authE2EAuthMiddlewareE2E 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 to locationId). 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 its vendor_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:kiosk

KDS routes:

auth -> type:kds -> tenant:kds

Customer routes:

auth -> type:customer

Online ordering (guest with optional auth):

auth.optional -> type:customer -> tenant:online-ordering

Field Ops app:

auth -> type:field

Integration routes (on top of the backoffice stack):

admin-vendor-override -> location-owner -> permission:MANAGE_INTEGRATION_SETTINGS

location-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

  1. Middleware receives required permission string (e.g., permission:VIEW_ITEMS)
  2. Multiple permissions can be OR'd: permission:EDIT_USERS|EDIT_USER_LOCATION_ACCESS
  3. PermissionService::extractMerchantAndLocationIds() gets context from the request -- route params vendorId / locationId first, then the body fields vendor_id / location_id
  4. PermissionService::hasPermission() checks if user's role grants the permission
  5. 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:

  1. Middleware sets config('database.connections.tenant.database') to the tenant DB name, then purges and reconnects the tenant connection
  2. It also tags tenant onto the log Context (per request / per queued job) and drops any cached MongoDB session bound to the old client
  3. All subsequent queries on the tenant connection 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 parameter
  • tenant:backoffice, tenant:kiosk, tenant:kds -- all take the default branch: read the bearer token, and use its tenant_database claim; 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

  1. 401 "Unauthenticated"

    • Token missing from Authorization header
    • Token expired (check exp claim)
    • Token signature invalid (wrong JWT secret between environments)
  2. 403 "Unauthorized: Invalid user type"

    • Token type doesn't match route requirement
    • Example: customer token used on backoffice route
  3. 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
  4. Tenant database not set

    • Token missing tenant_database claim
    • Usually happens with malformed or very old tokens

Quick Checks

  • Decode JWT at jwt.io to inspect claims
  • Verify JWT_SECRET environment variable matches between services
  • Check app/Services/JwtService.php for token generation logic
  • For device tokens, confirm no exp field (they should be permanent)