Auth & Sessions

Auth & Sessions

There are three distinct auth contexts: Shopify OAuth (install), embedded merchant sessions, and the admin/operator PIN.

Shopify OAuth (install)

  • InstallGET /api/auth/install?shop=… builds the Shopify install URL with a signed state, sets shopify_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 in platform_connections (TOKEN_ENCRYPTION_KEY). upsertStore creates/updates the stores row 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: embedded header (set when host is present); layout.tsx injects the App Bridge script and shopify-api-key meta 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 /reconnect verify the current App Bridge session before carrying shop and host forward. Verified session identity replaces stale URL, cookie, or sessionStorage values.
  • Server pages use requireInstalledMerchantPageAccess() to resolve the authorized shopDomain from 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 with aud/dest/exp checks) or the signed merchant_session cookie. shop/host query 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 patches window.fetch), and /api/auth/session exchanges it for the cookie where cookies are available.

Storefront (public) requests

  • Widget endpoints under /api/storefront/* are public (CORS-enabled) and are scoped by shopDomain + a server-created tryon_sessions row. Abuse is bounded by enforceAuditWindowLimit (IP fingerprint) and per-shopper rate limits.

Admin / operator

  • /admin is gated by a PIN session (requireAdminSession, /api/admin/login, ADMIN_AUTH_EMAIL). All /api/admin/* handlers call requireAdminSession() and return 401 on Unauthorized.

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 null and log shopify.token.needs_reconnect instead.
  • 401 retry-once with a force-refreshed token, centralized in shopifyGraphql (an optional storeId is threaded through the billing read paths — reconcile / subscribe / callback / cron) — logs shopify.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.