Auth & Sessions
There are three distinct auth contexts: Shopify OAuth (install), embedded merchant sessions, and the admin/operator PIN.
Shopify OAuth (install)
- Install —
GET /api/auth/install?shop=…builds the Shopify install URL with a signedstate, setsshopify_oauth_state+ return-host cookies, redirects to Shopify. - Callback —
/api/auth/callback(and/auth/callback) validates HMAC + state, exchanges the code for an access token, and stores it encrypted inplatform_connections(TOKEN_ENCRYPTION_KEY).upsertStorecreates/updates thestoresrow and sets trial dates on first install.
Embedded merchant access
- Embedded pages render inside Shopify admin via App Bridge. The shell is detected with the
x-tryvio-shell: embeddedheader (set whenhostis present);layout.tsxinjects the App Bridge script andshopify-api-keymeta only then. - Tenant identity for pages follows this trust order: a server-verified App Bridge ID
token, the current middleware-provided embedded
shop/host, then a signed merchant cookie for non-embedded requests. A configured/default shop is never used as an embedded merchant fallback. (Pages render no tenant data server-side — all data flows through the API routes.) - Navigation and
/reconnectverify the current App Bridge session before carryingshopandhostforward. Verified session identity replaces stale URL, cookie, or sessionStorage values. - Server pages use
requireInstalledMerchantPageAccess()to resolve the authorizedshopDomainfrom the embedded session/cookies. - Shopify-API-backed route handlers use
resolveAuthorizedShopForShopifyRequest(request). Since #174 it grants merchant actor status only on cryptographic proof: a verified App Bridge session token (Authorization: Bearer, HMAC-verified against the app secret withaud/dest/expchecks) or the signedmerchant_sessioncookie.shop/hostquery params and the referer are attacker-controlled and are only used to cross-check the proven identity — an “embedded-looking” request without a token or cookie gets a 401. Embedded fetches carry the token automatically (app-bridge.js patcheswindow.fetch), and/api/auth/sessionexchanges it for the cookie where cookies are available.
Storefront (public) requests
- Widget endpoints under
/api/storefront/*are public (CORS-enabled) and are scoped byshopDomain+ a server-createdtryon_sessionsrow. Abuse is bounded byenforceAuditWindowLimit(IP fingerprint) and per-shopper rate limits.
Admin / operator
/adminis gated by a PIN session (requireAdminSession,/api/admin/login,ADMIN_AUTH_EMAIL). All/api/admin/*handlers callrequireAdminSession()and return 401 onUnauthorized.
Token encryption
Access tokens are encrypted at rest using TOKEN_ENCRYPTION_KEY. Never log raw tokens.
Shopify offline-token refresh
Tryvio uses Shopify expiring offline access tokens (mandatory for public apps; Shopify
deadline 2027). Token exchange requests
requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token with
expiring=1, returning a ~60-minute access token plus a rotating refresh token (refresh
token valid ~90 days; the old one is invalidated on every refresh).
Old behavior (fixed 2026-06-16, RC#1). getShopifyAccessToken only refreshed reactively,
within 5 minutes of expiry on a live call, with no concurrency control. Concurrent callers
(e.g. dashboard metrics + billing reconcile firing at once, or reviewer plan-churn) could both
refresh in parallel using the same rotating refresh token — the second got invalid_grant
(“Invalid API key or access token”), or a stale/expired token could be handed back. Background
paths (webhooks/cron) had no session to recover from at all, surfacing as
cron.billing_cycle.no_token.
Current behavior, following Shopify’s documented guidance:
- Single-flight refresh per store (in-memory) — concurrent callers share one in-flight refresh instead of each burning the rotating refresh token.
- Proactive refresh ~5 minutes before expiry, not reactively on a 401.
- Cross-instance rotation tolerance — if a refresh fails, re-read the connection; if
another serverless instance already stored a newer token, use it
(
shopify.token.recovered_after_race). - Never return a known-expired token — return
nulland logshopify.token.needs_reconnectinstead. - 401 retry-once with a force-refreshed token, centralized in
shopifyGraphql(an optionalstoreIdis threaded through the billing read paths — reconcile / subscribe / callback / cron) — logsshopify.graphql.auth_retry. - Pure decision logic lives in
lib/server/shopify-token.ts(decideTokenAction,isShopifyAuthFailure), unit-tested independently of the network calls.
When the token is unrecoverable, billing-facing callers surface a graceful
“reconnect” path rather than a generic error — see
Billing Pipeline → Shopify access token refresh.
The /reconnect route re-mints the token via token exchange.