Authenticate requests (Shopify Hydrogen)
Authenticate requests (Shopify Hydrogen)
When initializing your Commerce engine, you must specify an accessToken
.
This access token is either a search token or an API key.
For most implementations, we recommend search token authentication. This article explains why and covers both authentication methods.
Search token authentication
To generate search tokens, you have two choices:
-
If you’ve installed the Coveo app for Shopify, it includes an App Proxy that generates search tokens for unauthenticated users. This is the easiest method for generating search tokens for your Shopify Hydrogen app, but it provides limited flexibility.
-
If you don’t use the Coveo app for Shopify, or need to generate search tokens for authenticated users and perhaps support additional features, create your search tokens using the Coveo Search API.
For instance, with search tokens, you can implement availability filtering, which lets you filter the products that users can access based on their roles or regions. You can also use search tokens to filter content using dictionary fields or multi-value fields.
To generate tokens yourself by making Coveo Search API requests, you first need to create a secure API key that will be stored in your back-end. Use the Authenticated search API key template to generate an API key with the required permissions. We recommend storing it as an Hydrogen environment variable.
Do not expose this API key in your client-side code, as it can be used to impersonate any user.
Create the token route
We recommend creating a route in your back-end that generates a search token, either using the app proxy or the Coveo Search API. The following example shows both ways of generating tokens. You can find the full sample in the Barca Sports Hydrogen repository.
// app/routes/token.tsx
import {engineConfig} from '~/lib/coveo.engine';
import {getOrganizationEndpoint} from '@coveo/headless-react/ssr-commerce';
import type {AppLoadContext, LoaderFunctionArgs} from '@remix-run/node';
import {isTokenExpired, decodeBase64Url} from '~/lib/token-utils.server';
import {accessTokenCookie} from '~/lib/cookies.server';
declare global {
interface Env {
COVEO_API_KEY: string;
}
}
interface ParsedToken {
exp: number;
}
export const loader = async ({request, context}: LoaderFunctionArgs) => {
const accessTokenCookieValue = await accessTokenCookie.parse(
request.headers.get('Cookie'),
);
if (accessTokenCookie && !isTokenExpired(accessTokenCookieValue)) {
return new Response(JSON.stringify({token: accessTokenCookieValue}), {
headers: {'Content-Type': 'application/json'},
});
}
const newToken = await fetchTokenFromSAPI(context);
const parsedToken = JSON.parse(
decodeBase64Url(newToken.split('.')[1]),
) as ParsedToken;
const maxAge = parsedToken.exp * 1000 - Date.now();
return new Response(JSON.stringify({token: newToken}), {
headers: {
'Content-Type': 'application/json',
'Set-Cookie': await accessTokenCookie.serialize(newToken, {
maxAge: Math.floor(maxAge / 1000),
}),
},
});
};
async function fetchTokenFromSAPI(context: AppLoadContext): Promise<string> {
const organizationEndpoint = getOrganizationEndpoint(
engineConfig.configuration.organizationId,
);
const response = await fetch(`${organizationEndpoint}/rest/search/v2/token`, {
method: 'POST',
body: JSON.stringify({
userIds: [
{
name: 'anonymous',
type: 'User',
provider: 'Email Security Provider',
infos: {},
authCookie: '',
},
],
}),
headers: {
Authorization: `Bearer ${context.env.COVEO_API_KEY}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch access token from Coveo Search API');
}
const responseData = (await response.json()) as {token: string};
return responseData.token;
}
async function fetchTokenFromAppProxy(): Promise<string> {
const marketId = // ...
const response = await fetch(`https://barca-sports.myshopify.com/apps/coveo?marketId=${marketId}`);
if (!response.ok) {
throw new Error('Failed to fetch token from app proxy');
}
const data = (await response.json()) as {accessToken: string};
return data.accessToken;
}
In a server-side rendering (SSR ) scenario, we recommend storing the search token in a cookie to minimize the number of network requests. Cookie utilities will be defined below. | |
As its name suggests, the isTokenExpired utility function checks if the token is expired.
If not, return the token stored in the cookie.
Otherwise, generate a new token. |
|
We’ll make sure that the cookie max age is set to the same value as the token expiration time. | |
This example focuses on demonstrating the Coveo search token authentication flow in an SSR scenario.
To keep the example simple, it generates only anonymous search tokens.
If you use search token authentication in a real-world scenario, you’ll likely want to generate tokens for authenticated users. The specific implementation details for this use case will vary based on the requirements of your application and the way it handles user authentication. For the list of possible request body properties, see Use search token authentication: Request body properties. |
|
The Coveo API key with the privilege to create search tokens, stored as an Hydrogen environment variable. | |
The marketId is a unique identifier for the market in which the user is located.
In a real application, you would likely retrieve the marketId dynamically based on the user’s location or preferences.
Details will vary depending on your setup. |
|
The response from the app proxy contains the generated search token.
It also contains the trackingId value associated with the target market, as per the Coveo app for Shopify indexing flow.
You can use it to set the appropriate context.analytics.trackingId in the Headless engine. |
Implement a fetcher function
Because you’ll need to call the token route from multiple places, we recommend creating a fetcher function to manage token requests and responses. You can find an example in the Barca Sports Hydrogen repository.
// app/lib/fetch-token.ts
type TokenResponse = {
token: string;
};
export const fetchToken = async (request?: Request) => {
const baseUrl = request && request.url ? new URL(request.url).origin : '';
const headersToRelay = extractCookiesFromRequest(request);
const sapiResponse = await fetch(`${baseUrl}/token`, { headers: headersToRelay });
if (!sapiResponse.ok) {
throw new Error(`Failed to fetch token: ${sapiResponse.status} ${sapiResponse.statusText}`);
}
return ((await sapiResponse.json()) as TokenResponse).token;
};
const extractCookiesFromRequest = (request?: Request) => {
const headers = new Headers();
const cookieHeader = request && request.headers && request.headers.get('Cookie');
if (cookieHeader) {
headers.set('Cookie', cookieHeader);
}
return headers;
}
In a Hydrogen app hosted on Oxygen, routes are protected and require certain cookie headers. This helper function receives the incoming request and extracts necessary authentication headers to forward them when accessing the protected token route. |
Use search tokens in your app
Set the token in the accessToken
property of the CommerceEngine
configuration object, and also validate that it’s still valid before using it to fetch the static state.
You can find full examples in the Barca Sports Hydrogen repository:
// lib/coveo.engine.ts
import {fetchToken} from './fetch-token';
// ...
const getSearchToken = async () => {
return typeof window !== 'undefined'
? await fetchToken()
: '';
};
export const engineConfig: CommerceEngineDefinitionOptions = {
configuration: {
organizationId: 'barcagroupproductionkwvdy6lp',
accessToken: getSearchToken(),
// ...
}
// ...
};
// ...
Headless requires an accessToken to be set in the configuration.
We can’t simply call fetchToken in all cases, because when the file first loads in the backend (and typeof window == undefined ), the /token route might not be ready.
As a backup, we can pass an empty, invalid value.
We will use an updateTokenIfNeeded function to update invalid or outdated tokens before interacting with Coveo APIs. |
The following sample shows how to use an updateTokenIfNeeded
function (defined in the next section) to update invalid or outdated tokens before fetching the static state and interacting with Coveo APIs.
// lib/coveo.engine.server.ts
import {updateTokenIfNeeded} from '~/lib/token-utils.server';
// ...
export async function fetchStaticState({
k,
parameters,
url,
context,
request,
}: {
k:
| 'listingEngineDefinition'
| 'searchEngineDefinition'
| 'standaloneEngineDefinition';
parameters: CommerceSearchParameters;
url: string;
context: AppLoadContext;
request: Request;
}) {
// ...
await updateTokenIfNeeded(k, request)
return engineDefinition[k].fetchStaticState({
// ...
});
}
export async function fetchRecommendationStaticState({
k,
context,
request,
productId,
}: {
context: AppLoadContext;
request: Request;
k: (
| 'homepageRecommendations'
| 'cartRecommendations'
| 'pdpRecommendationsLowerCarousel'
| 'pdpRecommendationsUpperCarousel'
)[];
productId?: string;
}) {
// ...
await updateTokenIfNeeded('recommendationEngineDefinition', request);
return engineDefinition.recommendationEngineDefinition.fetchStaticState({
// ...
});
}
// ...
The updateTokenIfNeeded function checks if the token is valid and updates it if necessary, before calling the fetchStaticState method. |
Token utilities
This section provides implementations of the updateTokenIfNeeded
function and several related cookie utility functions.
// lib/token-utils.server.ts
import {engineDefinition} from './coveo.engine';
import {fetchToken} from '~/lib/fetch-token';
import {accessTokenCookie} from './cookies.server';
export async function updateTokenIfNeeded(
engineType:
| 'listingEngineDefinition'
| 'searchEngineDefinition'
| 'standaloneEngineDefinition'
| 'recommendationEngineDefinition',
request: Request,
) {
if (isTokenExpired(engineDefinition[engineType].getAccessToken())) {
const accessTokenCookieValue = await accessTokenCookie.parse(
request.headers.get('Cookie'),
);
const accessToken =
accessTokenCookieValue && !isTokenExpired(accessTokenCookieValue)
? accessTokenCookieValue
: await fetchToken(request);
engineDefinition[engineType].setAccessToken(accessToken);
}
}
export function decodeBase64Url(base64Url: string): string {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return atob(base64);
}
export function isTokenExpired(token: string): boolean {
if (isApiKey(token)) {
return false;
}
try {
const [, payload] = token.split('.');
const decodedPayload = JSON.parse(decodeBase64Url(payload)) as {
exp: number;
};
return decodedPayload.exp * 1000 < Date.now();
} catch {
return true;
}
}
function isApiKey(token: string) {
return /^xx[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(
token,
);
}
A helper function to parse access tokens, defined in its own file below. | |
Check whether the token set in the engine configuration is expired. If not, no need to update it. | |
Check whether the token stored in the cookie is valid. If so, use it. Otherwise, fetch a new token. | |
Set the new token in the engine configuration. | |
Treat invalid tokens as expired. |
The accessTokenCookie
must be defined in a separate file using the createCookie
method, as shown in the following example.
See Remix Cookies.
// lib/cookies.server.ts
import {createCookie} from '@shopify/remix-oxygen';
export const accessTokenCookie = createCookie('coveo_accessToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
API key authentication
API key authentication only works for public content. If you’re using the Coveo app for Shopify, we recommend using the app proxy to generate search tokens for unauthenticated users instead. It’s more secure and simpler than API key authentication, because the app proxy manages the token privileges and lifecycle for you.
If you choose API key authentication, generate an API key using the Anonymous search API key template. This API key will be shared between all users of your storefront and can be publicly exposed in the client-side code.
Set that API key in the accessToken
property of the CommerceEngine
configuration object.
// lib/commerce-engine-config.ts
// ...
export default {
configuration: {
organizationId: 'barcagroupproductionkwvdy6lp',
accessToken: 'xx697404a7-6cfd-48c6-93d1-30d73d17e07a',
// ...
},
//...
} satisfies CommerceEngineDefinitionOptions;