Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

n8n-2229-implement-oauth-for-shopify-nodes #3389

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions packages/core/src/NodeExecuteFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ async function parseRequestObject(requestObject: IDataObject) {
// If we have body and possibly form
if (requestObject.form !== undefined) {
// merge both objects when exist.
// @ts-ignore
requestObject.body = Object.assign(requestObject.body, requestObject.form);
}
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
Expand Down Expand Up @@ -953,6 +954,13 @@ export async function requestOAuth2(
// @ts-ignore
newRequestOptions?.headers?.Authorization.split(' ')[1];
}

if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
Object.assign(newRequestOptions.headers, {
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
});
}

if (isN8nRequest) {
return this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => {
if (error.response?.status === 401) {
Expand All @@ -970,10 +978,24 @@ export async function requestOAuth2(
Authorization: '',
};
}
const newToken = await token.refresh(tokenRefreshOptions);

let newToken;

Logger.debug(
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
);
// if it's OAuth2 with client credentials grant type, get a new token
// instead of refreshing it.
if (OAuth2GrantType.clientCredentials === credentials.grantType) {
newToken = await token.client.credentials.getToken();
} else {
newToken = await token.refresh(tokenRefreshOptions);
}

Logger.debug(
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
);

credentials.oauthTokenData = newToken.data;
// Find the credentials
if (!node.credentials || !node.credentials[credentialsType]) {
Expand All @@ -988,11 +1010,19 @@ export async function requestOAuth2(
credentials,
);
const refreshedRequestOption = newToken.sign(requestOptions as clientOAuth2.RequestObject);

if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
Object.assign(newRequestOptions.headers, {
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
});
}

return this.helpers.httpRequest(refreshedRequestOption);
}
throw error;
});
}

return this.helpers.request!(newRequestOptions).catch(async (error: IResponseError) => {
const statusCodeReturned =
oAuth2Options?.tokenExpiredStatusCode === undefined
Expand Down Expand Up @@ -1057,9 +1087,13 @@ export async function requestOAuth2(

// Make the request again with the new token
const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject);
if (isN8nRequest) {
return this.helpers.httpRequest(newRequestOptions);

if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
Object.assign(newRequestOptions.headers, {
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
});
}

return this.helpers.request!(newRequestOptions);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';

export class ShopifyAccessTokenApi implements ICredentialType {
name = 'shopifyAccessTokenApi';
displayName = 'Shopify Access Token API';
documentationUrl = 'shopify';
properties: INodeProperties[] = [
{
displayName: 'Shop Subdomain',
name: 'shopSubdomain',
required: true,
type: 'string',
default: '',
description: 'Only the subdomain without .myshopify.com',
},
{
displayName: 'Access Token',
name: 'accessToken',
required: true,
type: 'string',
default: '',
},
{
displayName: 'APP Secret Key',
name: 'appSecretKey',
required: true,
type: 'string',
default: '',
description: 'Secret key needed to verify the webhook when using Shopify Trigger node',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
'X-Shopify-Access-Token': '={{$credentials?.accessToken}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: '=https://{{$credentials?.shopSubdomain}}.myshopify.com/admin/api/2019-10',
url: '/products.json',
},
};
}
86 changes: 86 additions & 0 deletions packages/nodes-base/credentials/ShopifyOAuth2Api.credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
ICredentialType,
INodeProperties,
} from 'n8n-workflow';

export class ShopifyOAuth2Api implements ICredentialType {
name = 'shopifyOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'Shopify OAuth2 API';
documentationUrl = 'shopify';
properties: INodeProperties[] = [
{
displayName: 'Shop Subdomain',
name: 'shopSubdomain',
required: true,
type: 'string',
default: '',
description: 'Only the subdomain without .myshopify.com',
},
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Client ID',
name: 'clientId',
type: 'string',
default: '',
required: true,
hint: 'Be aware that Shopify refers to the Client ID as API Key',
},
{
displayName: 'Client Secret',
name: 'clientSecret',
type: 'string',
typeOptions: {
password: true,
},
default: '',
required: true,
hint: 'Be aware that Shopify refers to the Client Secret as API Secret Key',
},
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: '=https://{{$self["shopSubdomain"]}}.myshopify.com/admin/oauth/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: '=https://{{$self["shopSubdomain"]}}.myshopify.com/admin/oauth/access_token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: 'write_orders read_orders write_products read_products',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: 'access_mode=value',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'body',
},
];
}
4 changes: 4 additions & 0 deletions packages/nodes-base/nodes/HttpRequest/HttpRequest.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,10 @@ export class HttpRequest implements INodeType {
boxOAuth2Api: {
includeCredentialsOnRefreshOnBody: true,
},
shopifyOAuth2Api: {
tokenType: 'Bearer',
keyToIncludeInAccessTokenHeader: 'X-Shopify-Access-Token',
},
};

const additionalOAuth2Options = oAuth2Options[nodeCredentialType];
Expand Down
36 changes: 29 additions & 7 deletions packages/nodes-base/nodes/Shopify/GenericFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,49 @@ import {
} from 'n8n-core';

import {
IDataObject, NodeApiError, NodeOperationError,
IDataObject, IOAuth2Options, NodeApiError,
} from 'n8n-workflow';

import {
snakeCase,
} from 'change-case';

export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = await this.getCredentials('shopifyApi');
const headerWithAuthentication = Object.assign({},
{ Authorization: `Basic ${Buffer.from(`${credentials.apiKey}:${credentials.password}`).toString(BINARY_ENCODING)}` });

const authenticationMethod = this.getNodeParameter('authentication', 0, 'oAuth2') as string;

let credentials;
let credentialType = 'shopifyOAuth2Api';

if (authenticationMethod === 'apiKey') {
credentials = await this.getCredentials('shopifyApi');
credentialType = 'shopifyApi';

} else if (authenticationMethod === 'accessToken') {
credentials = await this.getCredentials('shopifyAccessTokenApi');
credentialType = 'shopifyAccessTokenApi';

} else {
credentials = await this.getCredentials('shopifyOAuth2Api');
}

const options: OptionsWithUri = {
headers: headerWithAuthentication,
method,
qs: query,
uri: uri || `https://${credentials.shopSubdomain}.myshopify.com/admin/api/2019-10${resource}`,
body,
json: true,
};

const oAuth2Options: IOAuth2Options = {
tokenType: 'Bearer',
keyToIncludeInAccessTokenHeader: 'X-Shopify-Access-Token',
};

if (authenticationMethod === 'apiKey') {
Object.assign(options, { auth: { username: credentials.apiKey, password: credentials.password } });
}

if (Object.keys(option).length !== 0) {
Object.assign(options, option);
}
Expand All @@ -41,14 +63,14 @@ export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions
if (Object.keys(query).length === 0) {
delete options.qs;
}

try {
return await this.helpers.request!(options);
return await this.helpers.requestWithAuthentication.call(this, credentialType, options, { oauth2: oAuth2Options });
} catch (error) {
throw new NodeApiError(this.getNode(), error);
}
}


export async function shopifyApiRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any

const returnData: IDataObject[] = [];
Expand Down
49 changes: 49 additions & 0 deletions packages/nodes-base/nodes/Shopify/Shopify.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,58 @@ export class Shopify implements INodeType {
{
name: 'shopifyApi',
required: true,
displayOptions: {
show: {
authentication: [
'apiKey',
],
},
},
},
{
name: 'shopifyAccessTokenApi',
required: true,
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'shopifyOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
{
name: 'API Key',
value: 'apiKey',
},
],
default: 'apiKey',
},
{
displayName: 'Resource',
name: 'resource',
Expand Down
Loading