@lucent-commerce/shopify-customer-auth
v0.2.0
Published
Framework-agnostic OAuth/PKCE client for Shopify's Customer Account API, with an optional React hook.
Readme
@lucent-commerce/shopify-customer-auth
Framework-agnostic OAuth/PKCE client for Shopify's Customer Account API, with an optional React hook.
Description
Shopify's Customer Account API lets you authenticate customers and query their account data (orders, profile, store credit, etc.) through a GraphQL API — but only after completing an OAuth 2.0 Authorization Code + PKCE handshake with Shopify's authorization server. That handshake involves several fiddly, easy-to-get-wrong steps: discovering the shop's OAuth endpoints, generating and storing a PKCE verifier/state/nonce, building the correct authorize URL, and — after the browser redirects back — validating state, exchanging the code for tokens, verifying the id_token's nonce, and persisting everything safely.
This package implements that entire flow once, correctly, behind a small API: construct a client with your shop domain and OAuth client ID, call login() to redirect the customer to Shopify, and call handleRedirectCallback() when they come back — it detects whether a callback is present, completes the token exchange, and tells you the result. It also knows about a related but separate concern: a Customer Account API token does not log the customer into your storefront (no supported bridge exists between the two Shopify auth systems), so an opt-in createShopifyLoginSession flag can drive that second, Shopify-standard hop for you when you need both.
The core client has zero framework dependencies and works anywhere fetch/crypto.subtle/localStorage are available — plain scripts, any JS framework, or server-rendered apps (storage and navigation are pluggable and SSR-safe). A useCustomerAccountAuth React hook is available as an optional subpath for drop-in use in React apps, without ever pulling React into the core bundle.
Features
- Full PKCE authorization code flow (discovery, authorize redirect, token exchange, id_token validation)
- Framework-agnostic core — works in plain JS, any framework, or server-rendered apps
- Optional
./reactentry point with auseCustomerAccountAuthhook —reactis an optional peer dependency, never bundled into the core - Optional storefront-login chaining, for apps that also need the customer logged into the Shopify storefront (not just holding an API token)
- Fully typed (TypeScript), pluggable storage and navigation for SSR/testing
Install
npm install @lucent-commerce/shopify-customer-authThe ./react entry point additionally requires react (>=16.8):
npm install reactPlain JS/non-React consumers do not need to install react at all.
Prerequisites
You need a Customer Account API OAuth client ID before you can use this package. Steps to get one, grouped by where they happen in Shopify Admin:
A. Enable new customer accounts (one-time account setting)
- Go to Settings → Customer accounts and make sure new customer accounts (managed accounts) is enabled — the Customer Account API requires new accounts, not classic accounts.
B. Set up the Headless sales channel
- Add the Headless sales channel if you don't already have it: Settings → Apps and sales channels → Shopify App Store, search for "Headless", and add it to your store.
- Open the Headless channel and create a storefront (or select an existing one) representing this app/site.
C. Get the Client ID and register your redirect URI(s)
- In that storefront's settings, find the Customer Account API
credentials section (next to the Storefront API credentials). Shopify
generates and displays the Client ID there — this is the
clientIdyou pass toCustomerAccountAuthClient. - In the same section, under Application setup, add your callback
URI(s) (i.e.
redirect_uri). This must exactly match what this client computes — by default that'slocation.origin + location.pathnameof whichever page callslogin(). Add every URI you'll actually redirect back to (e.g. both a local dev URL and your production URL).
Shopify's admin menu labels/paths shift between releases — if a step above doesn't match what you see, search Shopify Admin for "Headless" or "Customer Account API" to find the current location.
Once you have the client ID and have registered your redirect URI(s), pass them to the client:
new CustomerAccountAuthClient({
shopDomain: 'my-shop.myshopify.com',
clientId: 'THE_CLIENT_ID_FROM_STEP_4',
});SPA routing pitfall: if you use client-side routing, a trailing-slash mismatch between the page that calls
login()and the page Shopify redirects back to will cause the token exchange to fail. For SPAs, pass an explicit, fixedredirectUri(e.g. a dedicated/auth/callbackroute) rather than relying on the default.
Quick start — plain JS
import { CustomerAccountAuthClient } from '@lucent-commerce/shopify-customer-auth';
const client = new CustomerAccountAuthClient({
shopDomain: 'my-shop.myshopify.com',
clientId: 'YOUR_OAUTH_CLIENT_ID',
});
// Run this unconditionally on page load — it's a no-op if there's no callback to handle.
const result = await client.handleRedirectCallback();
if (result.status === 'error') {
console.error('Login failed:', result.error);
}
document.querySelector('#login-button')?.addEventListener('click', () => {
client.login();
});
const accessToken = await client.getAccessToken(); // transparently refreshes if expired
if (accessToken) {
console.log('Access token:', accessToken);
}Quick start — React
import { useCustomerAccountAuth } from '@lucent-commerce/shopify-customer-auth/react';
function LoginButton() {
const { isAuthenticated, accessToken, isProcessingRedirect, error, login, logout } =
useCustomerAccountAuth({
shopDomain: 'my-shop.myshopify.com',
clientId: 'YOUR_OAUTH_CLIENT_ID',
});
if (isProcessingRedirect) return <p>Signing you in…</p>;
if (error) return <p>Login failed: {error.message}</p>;
return isAuthenticated ? (
<button onClick={logout}>Log out ({accessToken?.slice(0, 8)}…)</button>
) : (
<button onClick={() => login()}>Log in</button>
);
}Storefront-login chaining
A Customer Account API access token authenticates calls to the Customer
Account GraphQL API — it does not establish a Shopify storefront session
cookie. These are two separate Shopify authorization systems with no
supported bridge between them. If your app also needs the customer logged
into the storefront (Liquid customer object, checkout, /account pages),
opt in:
await client.login({ createShopifyLoginSession: true });When the OAuth callback completes successfully, the client will additionally
navigate to Shopify's /customer_authentication/login?return_to=.... Since
both flows share the same underlying Shopify accounts identity, this second
hop typically completes via SSO without prompting for credentials again — but
it is still a second full-page navigation. Defaults to false.
Automatic token refresh
getAccessToken() and getTokens() transparently keep your session alive — you never need to check expiry or call a refresh method yourself:
const accessToken = await client.getAccessToken();
// Still valid → returned immediately, no network call.
// Expired, refresh token available → refreshed automatically, new token returned.
// Expired, no refresh token (or refresh fails) → null.How it behaves:
- No unnecessary calls. A token is treated as valid until ~60 seconds before its real expiry (a small leeway to avoid edge-of-expiry races) — while valid, there's zero network activity.
- Concurrency-safe. If multiple calls need a refresh at the same time, only one request is made; every caller gets the same result. This isn't just an efficiency optimization — Shopify rotates the refresh token on every use, so two unguarded concurrent refreshes would race on the same stored value and one would fail.
- Consistent state after failure. A definitively dead refresh token (Shopify returns
invalid_grant) clears the entire stored session, so every subsequent call cleanly resolves tonullwith no further network calls — the app gets an unambiguous "log in again" signal. A transient failure (network error, 5xx) leaves the stored session untouched, so the next call simply retries. getAccessToken()/getTokens()never throw — a failed refresh just resolves tonull, matching their existing "no session" behavior. Callclient.refreshTokens()directly if you need to distinguish why it failed (see the errors table below).- The React hook is unaffected by any of this internally —
useCustomerAccountAuth()'s returnedaccessTokenreflects a refreshed token automatically; no hook API changed.
API reference
Core (@lucent-commerce/shopify-customer-auth)
| Export | Description |
|---|---|
| new CustomerAccountAuthClient(options) | Constructs a client. See CustomerAccountAuthClientOptions. |
| client.discoverEndpoints() | Fetches and caches /.well-known/openid-configuration. |
| client.getCustomerAccountApiEndpoint() | Resolves the versioned Customer Account GraphQL endpoint via /.well-known/customer-account-api. When you call it yourself, set the Authorization header to the raw access token with no Bearer prefix — the Customer Account API expects the token as-is (it already carries a shcat_ prefix as issued by Shopify), unlike most OAuth APIs. Sending Bearer <token> fails with "Invalid token, missing prefix shcat_.". |
| client.login(options?) | Redirects the browser to Shopify's authorize endpoint. See LoginOptions. |
| client.handleRedirectCallback(options?) | Detects and completes an OAuth callback. Safe to call unconditionally. See HandleRedirectCallbackOptions/HandleRedirectCallbackResult. |
| client.hasSession() | Synchronous, zero-network: true if a non-expired access token is currently stored. Does not account for a possible refresh — see Automatic token refresh. |
| client.getAccessToken() | Promise<string \| null>. Returns the current access token, transparently refreshing it first if expired/near-expiry and a refresh token is available. null if there's no usable session. |
| client.getIdTokenPayload() | Decoded id_token claims from whatever is currently in storage, or null. Does not itself trigger a refresh — call getAccessToken()/getTokens() first if you need this to reflect a freshly-refreshed session. |
| client.getTokens() | Promise<TokenSet \| null>. Same transparent-refresh behavior as getAccessToken(), returning the full token set. |
| client.refreshTokens() | Promise<TokenSet>. Forces a refresh using the stored refresh token. Concurrent calls share one in-flight request. Throws (doesn't swallow) on failure — see Automatic token refresh. |
| client.clearSession() | Clears all stored tokens. |
| client.getRedirectUri() | The effective redirect_uri this client uses — register this exact value in Shopify Admin. |
| createLocalStorageAdapter() / createSessionStorageAdapter() | Default browser StorageAdapter factories. |
| DEFAULT_STORAGE_KEY_PREFIX | 'shopify_customer_account_auth' |
CustomerAccountAuthClientOptions: shopDomain (required), clientId (required), scope (default 'openid email customer-account-api:full'), redirectUri, storageKeyPrefix, storage, sessionStorage, navigate.
LoginOptions (passed to client.login(options?)):
| Option | Type | Default | Description |
|---|---|---|---|
| createShopifyLoginSession | boolean | false | If true, once handleRedirectCallback() completes successfully, the client additionally navigates to Shopify's /customer_authentication/login?return_to=... to establish a storefront session cookie (see Storefront-login chaining above). |
| extraAuthParams | Record<string, string> | undefined | Extra query params merged into the authorize URL. Reserved param names (client_id, scope, response_type, redirect_uri, state, nonce, code_challenge, code_challenge_method) are ignored if present here. |
./react
| Export | Description |
|---|---|
| useCustomerAccountAuth(options) | React hook. options extends CustomerAccountAuthClientOptions plus autoHandleRedirect (default true). Returns { client, isAuthenticated, accessToken, idTokenPayload, isProcessingRedirect, error, login, logout, handleRedirectCallback }. |
Errors
All errors extend CustomerAccountAuthError.
| Class | When |
|---|---|
| EnvironmentError | A browser-only capability (e.g. deriving redirectUri) was used outside a browser. |
| DiscoveryError | The /.well-known/... discovery fetch failed. |
| StateMismatchError | Returned state didn't match — possible CSRF or a stale flow. |
| MissingPkceVerifierError | No PKCE verifier was found in storage when completing the callback. |
| TokenExchangeError | The code-for-token exchange request failed (carries httpStatus). |
| NonceMismatchError | The id_token's nonce claim didn't match. |
| OAuthCallbackError | Shopify redirected back with ?error=... (carries oauthError/description). |
| MissingRefreshTokenError | refreshTokens() was called with no refresh token in storage. |
| TokenRefreshError | The refresh request failed for a reason other than a confirmed dead refresh token (network error, 5xx, unparseable error body — carries httpStatus). Treated as transient; the stored session is left untouched. |
| RefreshTokenExpiredError | Shopify confirmed the refresh token is dead (invalid_grant). The stored session is cleared before this is thrown — the customer must log in again. Carries oauthError/description. |
handleRedirectCallback() never throws — failures surface as { status: 'error', error }. getAccessToken()/getTokens() never throw either — a failed refresh resolves to null. refreshTokens() does throw, for callers who need the specific reason.
Storage & security
- This is a public OAuth client — no client secret is ever used or stored.
- Persistent storage (default:
localStorage) holdsaccess_token,id_token,refresh_token,token_expiry. - Transient storage (default:
sessionStorage) holds the in-flight PKCE verifier/state/nonce and the storefront-login-pending flag; PKCE keys are cleared on a successful exchange. - All keys are namespaced under
storageKeyPrefix(defaultshopify_customer_account_auth) to avoid collisions and to support multiple client instances (e.g. multi-shop) on the same origin. - Supply custom
storage/sessionStorageadapters (implementing{ getItem, setItem, removeItem }) for non-browser storage or testing.
TypeScript
All types are exported from both entry points. Example: import type { TokenSet, HandleRedirectCallbackResult } from '@lucent-commerce/shopify-customer-auth';.
Browser / runtime requirements
Requires fetch, crypto.subtle, localStorage/sessionStorage, and window.location/history. In SSR frameworks (Next.js, Remix, etc.), the client is safe to construct during server rendering (storage/navigation access is lazy), but only call login()/handleRedirectCallback() client-side.
Upgrading from 0.1.x
client.getAccessToken() and client.getTokens() are now async (Promise<string | null> / Promise<TokenSet | null> instead of returning synchronously) — this is the one breaking change in 0.2.0, required so they can transparently refresh an expired token before returning it. Add await at every call site:
- const token = client.getAccessToken();
+ const token = await client.getAccessToken();Everything else is unchanged: hasSession() stays synchronous, and the useCustomerAccountAuth() React hook's public API (accessToken, isAuthenticated, login, logout, etc.) is identical — hook consumers need no changes at all.
Development
npm install
npm run typecheck
npm run build
npm testSee docs/MANUAL_VERIFICATION.md for the manual, real-Shopify-store verification steps that can't be automated.
License
MIT
