@shopify/shopify-api
Version:
Shopify API Library for Node - accelerate development with support for authentication, graphql proxy, webhooks
189 lines (161 loc) • 5.09 kB
text/typescript
import {logger} from '../logger';
import {ShopifyHeader} from '../types';
import {
AdapterArgs,
abstractConvertRequest,
getHeader,
} from '../../runtime/http';
import {ConfigInterface} from '../base-types';
import {createSHA256HMAC} from '../../runtime/crypto';
import {HashFormat} from '../../runtime/crypto/types';
import {AuthQuery} from '../auth/oauth/types';
import * as ShopifyErrors from '../error';
import {safeCompare} from '../auth/oauth/safe-compare';
import {WEBHOOK_HEADER_NAMES, WebhookTypeValue} from '../webhooks/types';
import ProcessedQuery from './processed-query';
import {
ValidationErrorReason,
ValidationInvalid,
HmacValidationType,
ValidationValid,
ValidationErrorReasonType,
} from './types';
const HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC = 90;
export type HMACSignator = 'admin' | 'appProxy';
export interface ValidateParams extends AdapterArgs {
/**
* The type of validation to perform, either 'flow' or 'webhook'.
*/
type: HmacValidationType;
/**
* The raw body of the request.
*/
rawBody: string;
/**
* The webhook type for header selection (optional, only for webhooks).
*/
webhookType?: WebhookTypeValue;
}
function stringifyQueryForAdmin(query: AuthQuery): string {
const processedQuery = new ProcessedQuery();
Object.keys(query)
.sort((val1, val2) => val1.localeCompare(val2))
.forEach((key: string) => processedQuery.put(key, query[key]));
return processedQuery.stringify(true);
}
function stringifyQueryForAppProxy(query: AuthQuery): string {
return Object.entries(query)
.sort(([val1], [val2]) => val1.localeCompare(val2))
.reduce((acc, [key, value]) => {
return `${acc}${key}=${Array.isArray(value) ? value.join(',') : value}`;
}, '');
}
export function generateLocalHmac(config: ConfigInterface) {
return async (
params: AuthQuery,
signator: HMACSignator = 'admin',
): Promise<string> => {
const {hmac, signature, ...query} = params;
const queryString =
signator === 'admin'
? stringifyQueryForAdmin(query)
: stringifyQueryForAppProxy(query);
return createSHA256HMAC(config.apiSecretKey, queryString, HashFormat.Hex);
};
}
export function validateHmac(config: ConfigInterface) {
return async (
query: AuthQuery,
{signator}: {signator: HMACSignator} = {signator: 'admin'},
): Promise<boolean> => {
if (signator === 'admin' && !query.hmac) {
throw new ShopifyErrors.InvalidHmacError(
'Query does not contain an HMAC value.',
);
}
if (signator === 'appProxy' && !query.signature) {
throw new ShopifyErrors.InvalidHmacError(
'Query does not contain a signature value.',
);
}
validateHmacTimestamp(query);
const hmac = signator === 'appProxy' ? query.signature : query.hmac;
const localHmac = await generateLocalHmac(config)(query, signator);
return safeCompare(hmac as string, localHmac);
};
}
export async function validateHmacString(
config: ConfigInterface,
data: string,
hmac: string,
format: HashFormat,
) {
const localHmac = await createSHA256HMAC(config.apiSecretKey, data, format);
return safeCompare(hmac, localHmac);
}
export function getCurrentTimeInSec() {
return Math.trunc(Date.now() / 1000);
}
export function validateHmacFromRequestFactory(config: ConfigInterface) {
return async function validateHmacFromRequest({
type,
rawBody,
webhookType,
...adapterArgs
}: ValidateParams): Promise<ValidationInvalid | ValidationValid> {
const request = await abstractConvertRequest(adapterArgs);
if (!rawBody.length) {
return fail(ValidationErrorReason.MissingBody, type, config);
}
// Use appropriate header based on webhook type
const hmacHeaderName = webhookType
? WEBHOOK_HEADER_NAMES[webhookType].hmac
: ShopifyHeader.Hmac;
const hmac = getHeader(request.headers, hmacHeaderName);
if (!hmac) {
return fail(ValidationErrorReason.MissingHmac, type, config);
}
const validHmac = await validateHmacString(
config,
rawBody,
hmac,
HashFormat.Base64,
);
if (!validHmac) {
return fail(ValidationErrorReason.InvalidHmac, type, config);
}
return succeed(type, config);
};
}
function validateHmacTimestamp(query: AuthQuery) {
if (
Math.abs(getCurrentTimeInSec() - Number(query.timestamp)) >
HMAC_TIMESTAMP_PERMITTED_CLOCK_TOLERANCE_SEC
) {
throw new ShopifyErrors.InvalidHmacError(
'HMAC timestamp is outside of the tolerance range',
);
}
}
async function fail(
reason: ValidationErrorReasonType,
type: HmacValidationType,
config: ConfigInterface,
): Promise<ValidationInvalid> {
const log = logger(config);
await log.debug(`${type} request is not valid`, {reason});
return {
valid: false,
reason,
};
}
async function succeed(
type: HmacValidationType,
config: ConfigInterface,
): Promise<ValidationValid> {
const log = logger(config);
await log.debug(`${type} request is valid`);
return {
valid: true,
};
}