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

Commit f66db62

Browse files
authored
Merge pull request #1250 from Shopify/liz/auth-fulfillment-service
[Feature] Add authentication for fulfillment service requests
2 parents e594c74 + a5a0736 commit f66db62

File tree

13 files changed

+253
-40
lines changed

13 files changed

+253
-40
lines changed

.changeset/fair-nails-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopify/shopify-api": minor
3+
---
4+
5+
Add function to authenticate fulfillment service requests
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# shopify.fulfillmentService
2+
3+
This object contains functions used to authenticate [Fulfillment Service](https://shopify.dev/docs/apps/fulfillment/fulfillment-service-apps/manage-fulfillments) requests coming from Shopify.
4+
5+
| Property | Description |
6+
| ------------------------- | ------------------------------------------------------------------- |
7+
| [validate](./validate.md) | Verify whether a request is a valid fulfillment service request. |
8+
9+
[Back to shopifyApi](../shopifyApi.md)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# shopify.fulfillmentService.validate
2+
3+
Takes in a raw request and the raw body for that request, and validates that it's a legitimate fulfillment service request.
4+
5+
Refer to [the fulfillment service documentation](https://shopify.dev/docs/apps/fulfillment/fulfillment-service-apps/manage-fulfillments#step-3-act-on-fulfillment-requests) for more information on how this validation works.
6+
7+
## Example
8+
9+
```ts
10+
app.post('/fulfillment_order_notification', express.text({type: '*/*'}), async (req, res) => {
11+
const result = await shopify.fulfillmentService.validate({
12+
rawBody: req.body, // is a string
13+
rawRequest: req,
14+
rawResponse: res,
15+
});
16+
17+
if (!result.valid) {
18+
console.log(`Received invalid fulfillment service request: ${result.reason}`);
19+
res.send(400);
20+
}
21+
22+
res.send(200);
23+
});
24+
```
25+
26+
## Parameters
27+
28+
Receives an object containing:
29+
30+
### rawBody
31+
32+
`string` | :exclamation: required
33+
34+
The raw body of the request received by the app.
35+
36+
### rawRequest
37+
38+
`AdapterRequest` | :exclamation: required
39+
40+
The HTTP Request object used by your runtime.
41+
42+
### rawResponse
43+
44+
`AdapterResponse` | :exclamation: required for Node.js
45+
46+
The HTTP Response object used by your runtime. Required for Node.js.
47+
48+
## Return
49+
50+
Returns an object containing:
51+
52+
### valid
53+
54+
`boolean`
55+
56+
Whether the request is a valid fulfillment service request from Shopify.
57+
58+
### If valid is `false`:
59+
60+
#### reason
61+
62+
`ValidationErrorReason`
63+
64+
The reason why the check was considered invalid.
65+
66+
[Back to shopify.fulfillmentService](./README.md)

packages/shopify-api/docs/reference/shopifyApi.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ This function returns an object containing the following properties:
172172
| [session](./session/README.md) | Object containing functions to manage Shopify sessions. |
173173
| [webhooks](./webhooks/README.md) | Object containing functions to configure and handle Shopify webhooks. |
174174
| [billing](./billing/README.md) | Object containing functions to enable apps to bill merchants. |
175-
| [flow](./flow/README.md) | Object containing functions to authenticate Flow extension requests. |
175+
| [flow](./flow/README.md) | Object containing functions to authenticate Flow extension requests.
176+
| [fulfillment service](./fulfillment-service/README.md) | Object containing functions to authenticate and create fulfillment service requests. |
176177
| [utils](./utils/README.md) | Object containing general functions to help build apps. |
177178
| [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. |
178179

packages/shopify-api/lib/error.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,4 @@ export class BillingError extends ShopifyError {
127127
this.errorData = errorData;
128128
}
129129
}
130-
131130
export class FeatureDeprecatedError extends ShopifyError {}

packages/shopify-api/lib/flow/__tests__/flow.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type NormalizedRequest,
77
} from '../../../runtime';
88
import {testConfig} from '../../__tests__/test-config';
9-
import {FlowValidationErrorReason} from '../types';
9+
import {ValidationErrorReason} from '../../utils/types';
1010

1111
describe('flow', () => {
1212
describe('validate', () => {
@@ -31,7 +31,7 @@ describe('flow', () => {
3131
// THEN
3232
expect(result).toMatchObject({
3333
valid: false,
34-
reason: FlowValidationErrorReason.MissingHmac,
34+
reason: ValidationErrorReason.MissingHmac,
3535
});
3636
});
3737

@@ -55,7 +55,7 @@ describe('flow', () => {
5555
// THEN
5656
expect(result).toMatchObject({
5757
valid: false,
58-
reason: FlowValidationErrorReason.InvalidHmac,
58+
reason: ValidationErrorReason.InvalidHmac,
5959
});
6060
});
6161

@@ -84,7 +84,7 @@ describe('flow', () => {
8484
// THEN
8585
expect(result).toMatchObject({
8686
valid: false,
87-
reason: FlowValidationErrorReason.MissingBody,
87+
reason: ValidationErrorReason.MissingBody,
8888
});
8989
});
9090
});

packages/shopify-api/lib/flow/types.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

packages/shopify-api/lib/flow/validate.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ import {
22
HmacValidationType,
33
ValidationInvalid,
44
ValidationValid,
5+
ValidateParams,
56
} from '../utils/types';
67
import {validateHmacFromRequestFactory} from '../utils/hmac-validator';
78
import {ConfigInterface} from '../base-types';
89

9-
import {FlowValidateParams} from './types';
10-
1110
export function validateFactory(config: ConfigInterface) {
1211
return async function validate({
1312
rawBody,
1413
...adapterArgs
15-
}: FlowValidateParams): Promise<ValidationInvalid | ValidationValid> {
14+
}: ValidateParams): Promise<ValidationInvalid | ValidationValid> {
1615
return validateHmacFromRequestFactory(config)({
1716
type: HmacValidationType.Flow,
1817
rawBody,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {shopifyApi} from '../..';
2+
import {ShopifyHeader} from '../../types';
3+
import {
4+
createSHA256HMAC,
5+
HashFormat,
6+
type NormalizedRequest,
7+
} from '../../../runtime';
8+
import {testConfig} from '../../__tests__/test-config';
9+
import {ValidationErrorReason} from '../../utils/types';
10+
11+
describe('fulfillment service', () => {
12+
describe('validate', () => {
13+
describe('failure cases', () => {
14+
it('fails if the HMAC header is missing', async () => {
15+
// GIVEN
16+
const shopify = shopifyApi(testConfig());
17+
18+
const payload = {field: 'value'};
19+
const req: NormalizedRequest = {
20+
method: 'GET',
21+
url: 'https://my-app.my-domain.io',
22+
headers: {},
23+
};
24+
25+
// WHEN
26+
const result = await shopify.fulfillmentService.validate({
27+
rawBody: JSON.stringify(payload),
28+
rawRequest: req,
29+
});
30+
31+
// THEN
32+
expect(result).toMatchObject({
33+
valid: false,
34+
reason: ValidationErrorReason.MissingHmac,
35+
});
36+
});
37+
38+
it('fails if the HMAC header is invalid', async () => {
39+
// GIVEN
40+
const shopify = shopifyApi(testConfig());
41+
42+
const payload = {field: 'value'};
43+
const req: NormalizedRequest = {
44+
method: 'GET',
45+
url: 'https://my-app.my-domain.io',
46+
headers: {[ShopifyHeader.Hmac]: 'invalid'},
47+
};
48+
49+
// WHEN
50+
const result = await shopify.fulfillmentService.validate({
51+
rawBody: JSON.stringify(payload),
52+
rawRequest: req,
53+
});
54+
55+
// THEN
56+
expect(result).toMatchObject({
57+
valid: false,
58+
reason: ValidationErrorReason.InvalidHmac,
59+
});
60+
});
61+
62+
it('fails if the body is empty', async () => {
63+
// GIVEN
64+
const shopify = shopifyApi(testConfig());
65+
66+
const req: NormalizedRequest = {
67+
method: 'GET',
68+
url: 'https://my-app.my-domain.io',
69+
headers: {
70+
[ShopifyHeader.Hmac]: await createSHA256HMAC(
71+
shopify.config.apiSecretKey,
72+
'',
73+
HashFormat.Base64,
74+
),
75+
},
76+
};
77+
78+
// WHEN
79+
const result = await shopify.fulfillmentService.validate({
80+
rawBody: '',
81+
rawRequest: req,
82+
});
83+
84+
// THEN
85+
expect(result).toMatchObject({
86+
valid: false,
87+
reason: ValidationErrorReason.MissingBody,
88+
});
89+
});
90+
});
91+
92+
it('succeeds if the body and HMAC header are correct', async () => {
93+
// GIVEN
94+
const shopify = shopifyApi(testConfig());
95+
96+
const payload = {field: 'value'};
97+
const req: NormalizedRequest = {
98+
method: 'GET',
99+
url: 'https://my-app.my-domain.io',
100+
headers: {
101+
[ShopifyHeader.Hmac]: await createSHA256HMAC(
102+
shopify.config.apiSecretKey,
103+
JSON.stringify(payload),
104+
HashFormat.Base64,
105+
),
106+
},
107+
};
108+
109+
// WHEN
110+
const result = await shopify.fulfillmentService.validate({
111+
rawBody: JSON.stringify(payload),
112+
rawRequest: req,
113+
});
114+
115+
// THEN
116+
expect(result).toMatchObject({valid: true});
117+
});
118+
});
119+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {ConfigInterface} from '../base-types';
2+
3+
import {validateFactory} from './validate';
4+
5+
export function fulfillmentService(config: ConfigInterface) {
6+
return {
7+
validate: validateFactory(config),
8+
};
9+
}
10+
11+
export type FulfillmentService = ReturnType<typeof fulfillmentService>;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {ConfigInterface} from '../base-types';
2+
import {validateHmacFromRequestFactory} from '../utils/hmac-validator';
3+
import {
4+
HmacValidationType,
5+
ValidateParams,
6+
ValidationInvalid,
7+
ValidationValid,
8+
} from '../utils/types';
9+
10+
export function validateFactory(config: ConfigInterface) {
11+
return async function validate({
12+
rawBody,
13+
...adapterArgs
14+
}: ValidateParams): Promise<ValidationInvalid | ValidationValid> {
15+
return validateHmacFromRequestFactory(config)({
16+
type: HmacValidationType.FulfillmentService,
17+
rawBody,
18+
...adapterArgs,
19+
});
20+
};
21+
}

packages/shopify-api/lib/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {logger, ShopifyLogger} from './logger';
1515
import {SHOPIFY_API_LIBRARY_VERSION} from './version';
1616
import {restClientClass} from './clients/admin/rest/client';
1717
import {ShopifyFlow, shopifyFlow} from './flow';
18+
import {FulfillmentService, fulfillmentService} from './fulfillment-service';
1819

1920
export * from './error';
2021
export * from './session/classes';
@@ -27,7 +28,7 @@ export * from './billing/types';
2728
export * from './clients/types';
2829
export * from './session/types';
2930
export * from './webhooks/types';
30-
export * from './flow/types';
31+
export * from './utils/types';
3132

3233
export interface Shopify<
3334
Params extends ConfigParams = ConfigParams,
@@ -44,6 +45,7 @@ export interface Shopify<
4445
logger: ShopifyLogger;
4546
rest: Resources;
4647
flow: ShopifyFlow;
48+
fulfillmentService: FulfillmentService;
4749
}
4850

4951
export function shopifyApi<
@@ -71,6 +73,7 @@ export function shopifyApi<
7173
webhooks: shopifyWebhooks(validatedConfig),
7274
billing: shopifyBilling(validatedConfig),
7375
flow: shopifyFlow(validatedConfig),
76+
fulfillmentService: fulfillmentService(validatedConfig),
7477
logger: logger(validatedConfig),
7578
rest: {} as Resources,
7679
};

packages/shopify-api/lib/utils/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import {AdapterArgs} from '../../runtime/types';
2+
13
export enum HmacValidationType {
24
Flow = 'flow',
35
Webhook = 'webhook',
6+
FulfillmentService = 'fulfillment_service',
7+
}
8+
9+
export interface ValidateParams extends AdapterArgs {
10+
/**
11+
* The raw body of the request.
12+
*/
13+
rawBody: string;
414
}
515

616
export const ValidationErrorReason = {

0 commit comments

Comments
 (0)