Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Commit

Permalink
Function to authenticate flow requests (#987)
Browse files Browse the repository at this point in the history
* [WIP] - Function to authenticate flow requests

* Use the correct hmac header

* Adding documentation and tests

* Ignore changeset files in link checks

* Applying comments from review

---------

Co-authored-by: Paulo Margarido <[email protected]>
  • Loading branch information
byrichardpowell and paulomarg authored Feb 6, 2024
1 parent 707e5cd commit 9c41d91
Show file tree
Hide file tree
Showing 13 changed files with 331 additions and 18 deletions.
7 changes: 7 additions & 0 deletions .changeset/loud-camels-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@shopify/shopify-api": minor
---

Added support for validating Flow extension requests, using `shopify.authenticate.flow`.

Please see [the `flow` object documentation](./docs/reference/flow/README.md) for more information.
1 change: 1 addition & 0 deletions .github/workflows/markdown_link_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ jobs:
- uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
config-file: '.github/workflows/markdown_link_checker_config.json'
folder-path: 'packages'
9 changes: 9 additions & 0 deletions packages/shopify-api/docs/reference/flow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# shopify.flow

This object contains functions used to authenticate Flow extension requests coming from Shopify.

| Property | Description |
| ------------------------- | ------------------------------------------------------------------- |
| [validate](./validate.md) | Verify whether a request is a valid Shopify Flow extension request. |

[Back to shopifyApi](../shopifyApi.md)
66 changes: 66 additions & 0 deletions packages/shopify-api/docs/reference/flow/validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# shopify.flow.validate

Takes in a raw request and the raw body for that request, and validates that it's a legitimate Shopify Flow extension request.

Refer to [the Flow documentation](https://shopify.dev/docs/apps/flow/actions/endpoints#custom-validation) for more information on how this validation works.

## Example

```ts
app.post('/flow', express.text({type: '*/*'}), async (req, res) => {
const result = await shopify.flow.validate({
rawBody: req.body, // is a string
rawRequest: req,
rawResponse: res,
});

if (!result.valid) {
console.log(`Received invalid Flow extension request: ${result.reason}`);
res.send(400);
}

res.send(200);
});
```

## Parameters

Receives an object containing:

### rawBody

`string` | :exclamation: required

The raw body of the request received by the app.

### rawRequest

`AdapterRequest` | :exclamation: required

The HTTP Request object used by your runtime.

### rawResponse

`AdapterResponse` | :exclamation: required for Node.js

The HTTP Response object used by your runtime. Required for Node.js.

## Return

Returns an object containing:

### valid

`boolean`

Whether the request is a valid Flow extension request from Shopify.

### If valid is `false`:

#### reason

`FlowValidationErrorReason`

The reason why the check was considered invalid.

[Back to shopify.flow](./README.md)
1 change: 1 addition & 0 deletions packages/shopify-api/docs/reference/shopifyApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ This function returns an object containing the following properties:
| [session](./session/README.md) | Object containing functions to manage Shopify sessions. |
| [webhooks](./webhooks/README.md) | Object containing functions to configure and handle Shopify webhooks. |
| [billing](./billing/README.md) | Object containing functions to enable apps to bill merchants. |
| [flow](./flow/README.md) | Object containing functions to authenticate Flow extension requests. |
| [utils](./utils/README.md) | Object containing general functions to help build apps. |
| [rest](../guides/rest-resources.md) | Object containing OO representations of the Admin REST API. See the [API reference documentation](https://shopify.dev/docs/api/admin-rest) for details. |

Expand Down
119 changes: 119 additions & 0 deletions packages/shopify-api/lib/flow/__tests__/flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {shopifyApi} from '../..';
import {ShopifyHeader} from '../../types';
import {
createSHA256HMAC,
HashFormat,
type NormalizedRequest,
} from '../../../runtime';
import {testConfig} from '../../__tests__/test-config';
import {FlowValidationErrorReason} from '../types';

describe('flow', () => {
describe('validate', () => {
describe('failure cases', () => {
it('fails if the HMAC header is missing', async () => {
// GIVEN
const shopify = shopifyApi(testConfig());

const payload = {field: 'value'};
const req: NormalizedRequest = {
method: 'GET',
url: 'https://my-app.my-domain.io',
headers: {},
};

// WHEN
const result = await shopify.flow.validate({
rawBody: JSON.stringify(payload),
rawRequest: req,
});

// THEN
expect(result).toMatchObject({
valid: false,
reason: FlowValidationErrorReason.MissingHmac,
});
});

it('fails if the HMAC header is invalid', async () => {
// GIVEN
const shopify = shopifyApi(testConfig());

const payload = {field: 'value'};
const req: NormalizedRequest = {
method: 'GET',
url: 'https://my-app.my-domain.io',
headers: {[ShopifyHeader.Hmac]: 'invalid'},
};

// WHEN
const result = await shopify.flow.validate({
rawBody: JSON.stringify(payload),
rawRequest: req,
});

// THEN
expect(result).toMatchObject({
valid: false,
reason: FlowValidationErrorReason.InvalidHmac,
});
});

it('fails if the body is empty', async () => {
// GIVEN
const shopify = shopifyApi(testConfig());

const req: NormalizedRequest = {
method: 'GET',
url: 'https://my-app.my-domain.io',
headers: {
[ShopifyHeader.Hmac]: await createSHA256HMAC(
shopify.config.apiSecretKey,
'',
HashFormat.Base64,
),
},
};

// WHEN
const result = await shopify.flow.validate({
rawBody: '',
rawRequest: req,
});

// THEN
expect(result).toMatchObject({
valid: false,
reason: FlowValidationErrorReason.MissingBody,
});
});
});

it('succeeds if the body and HMAC header are correct', async () => {
// GIVEN
const shopify = shopifyApi(testConfig());

const payload = {field: 'value'};
const req: NormalizedRequest = {
method: 'GET',
url: 'https://my-app.my-domain.io',
headers: {
[ShopifyHeader.Hmac]: await createSHA256HMAC(
shopify.config.apiSecretKey,
JSON.stringify(payload),
HashFormat.Base64,
),
},
};

// WHEN
const result = await shopify.flow.validate({
rawBody: JSON.stringify(payload),
rawRequest: req,
});

// THEN
expect(result).toMatchObject({valid: true});
});
});
});
11 changes: 11 additions & 0 deletions packages/shopify-api/lib/flow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {ConfigInterface} from '../base-types';

import {validateFactory} from './validate';

export function shopifyFlow(config: ConfigInterface) {
return {
validate: validateFactory(config),
};
}

export type ShopifyFlow = ReturnType<typeof shopifyFlow>;
32 changes: 32 additions & 0 deletions packages/shopify-api/lib/flow/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {AdapterArgs} from '../../runtime/types';

export interface FlowValidateParams extends AdapterArgs {
/**
* The raw body of the request.
*/
rawBody: string;
}

export enum FlowValidationErrorReason {
MissingBody = 'missing_body',
MissingHmac = 'missing_hmac',
InvalidHmac = 'invalid_hmac',
}

export interface FlowValidationInvalid {
/**
* Whether the request is a valid Flow request from Shopify.
*/
valid: false;
/**
* The reason why the request is not valid.
*/
reason: FlowValidationErrorReason;
}

export interface FlowValidationValid {
/**
* Whether the request is a valid Flow request from Shopify.
*/
valid: true;
}
60 changes: 60 additions & 0 deletions packages/shopify-api/lib/flow/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {abstractConvertRequest, getHeader} from '../../runtime/http';
import {HashFormat} from '../../runtime/crypto/types';
import {ConfigInterface} from '../base-types';
import {logger} from '../logger';
import {ShopifyHeader} from '../types';
import {validateHmacString} from '../utils/hmac-validator';

import {
FlowValidateParams,
FlowValidationInvalid,
FlowValidationValid,
FlowValidationErrorReason,
} from './types';

export function validateFactory(config: ConfigInterface) {
return async function validate({
rawBody,
...adapterArgs
}: FlowValidateParams): Promise<FlowValidationInvalid | FlowValidationValid> {
const request = await abstractConvertRequest(adapterArgs);

if (!rawBody.length) {
return fail(FlowValidationErrorReason.MissingBody, config);
}

const hmac = getHeader(request.headers, ShopifyHeader.Hmac);

if (!hmac) {
return fail(FlowValidationErrorReason.MissingHmac, config);
}

if (await validateHmacString(config, rawBody, hmac, HashFormat.Base64)) {
return succeed(config);
}

return fail(FlowValidationErrorReason.InvalidHmac, config);
};
}

async function fail(
reason: FlowValidationErrorReason,
config: ConfigInterface,
): Promise<FlowValidationInvalid> {
const log = logger(config);
await log.debug('Flow request is not valid', {reason});

return {
valid: false,
reason,
};
}

async function succeed(config: ConfigInterface): Promise<FlowValidationValid> {
const log = logger(config);
await log.debug('Flow request is valid');

return {
valid: true,
};
}
4 changes: 4 additions & 0 deletions packages/shopify-api/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {shopifyBilling, ShopifyBilling} from './billing';
import {logger, ShopifyLogger} from './logger';
import {SHOPIFY_API_LIBRARY_VERSION} from './version';
import {restClientClass} from './clients/admin/rest/client';
import {ShopifyFlow, shopifyFlow} from './flow';

export * from './error';
export * from './session/classes';
Expand All @@ -26,6 +27,7 @@ export * from './billing/types';
export * from './clients/types';
export * from './session/types';
export * from './webhooks/types';
export * from './flow/types';

export interface Shopify<
Params extends ConfigParams = ConfigParams,
Expand All @@ -41,6 +43,7 @@ export interface Shopify<
billing: ShopifyBilling;
logger: ShopifyLogger;
rest: Resources;
flow: ShopifyFlow;
}

export function shopifyApi<
Expand All @@ -67,6 +70,7 @@ export function shopifyApi<
utils: shopifyUtils(validatedConfig),
webhooks: shopifyWebhooks(validatedConfig),
billing: shopifyBilling(validatedConfig),
flow: shopifyFlow(validatedConfig),
logger: logger(validatedConfig),
rest: {} as Resources,
};
Expand Down
11 changes: 11 additions & 0 deletions packages/shopify-api/lib/utils/hmac-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ export function validateHmac(config: ConfigInterface) {
};
}

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);
}
Expand Down
Loading

0 comments on commit 9c41d91

Please sign in to comment.