feat(Shopify Node): Add OAuth support (#3389)

*  wip

*  Add includeAccessTokenInHeader option to OAuth2

* 🔨 fixed build error, fixed trigger node when using token auth

* 🔨 fixed trigger when using oauth2

* 🔨 changed default auth method to access token

*  Improvements

*  Improvements

*  Improvements

*  Rename includeAccessTokenInHeader to keyToIncludeInAccessTokenHeader

*  Assign values to only header property

* 🔥 Remove unreachable code

*  Add keyToIncludeInAccessTokenHeader when isN8nRequest

*  Add CC grant type when isN8nRequest

Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <janober@users.noreply.github.com>
This commit is contained in:
Michael Kret 2022-07-15 11:36:01 +03:00 committed by GitHub
parent 74064325c8
commit 945e25a77c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 333 additions and 12 deletions

View file

@ -277,6 +277,7 @@ async function parseRequestObject(requestObject: IDataObject) {
// If we have body and possibly form // If we have body and possibly form
if (requestObject.form !== undefined) { if (requestObject.form !== undefined) {
// merge both objects when exist. // merge both objects when exist.
// @ts-ignore
requestObject.body = Object.assign(requestObject.body, requestObject.form); requestObject.body = Object.assign(requestObject.body, requestObject.form);
} }
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[]; axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
@ -953,6 +954,13 @@ export async function requestOAuth2(
// @ts-ignore // @ts-ignore
newRequestOptions?.headers?.Authorization.split(' ')[1]; newRequestOptions?.headers?.Authorization.split(' ')[1];
} }
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
Object.assign(newRequestOptions.headers, {
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
});
}
if (isN8nRequest) { if (isN8nRequest) {
return this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { return this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
@ -970,10 +978,24 @@ export async function requestOAuth2(
Authorization: '', Authorization: '',
}; };
} }
const newToken = await token.refresh(tokenRefreshOptions);
let newToken;
Logger.debug( Logger.debug(
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`, `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; credentials.oauthTokenData = newToken.data;
// Find the credentials // Find the credentials
if (!node.credentials || !node.credentials[credentialsType]) { if (!node.credentials || !node.credentials[credentialsType]) {
@ -988,11 +1010,19 @@ export async function requestOAuth2(
credentials, credentials,
); );
const refreshedRequestOption = newToken.sign(requestOptions as clientOAuth2.RequestObject); const refreshedRequestOption = newToken.sign(requestOptions as clientOAuth2.RequestObject);
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
Object.assign(newRequestOptions.headers, {
[oAuth2Options.keyToIncludeInAccessTokenHeader]: token.accessToken,
});
}
return this.helpers.httpRequest(refreshedRequestOption); return this.helpers.httpRequest(refreshedRequestOption);
} }
throw error; throw error;
}); });
} }
return this.helpers.request!(newRequestOptions).catch(async (error: IResponseError) => { return this.helpers.request!(newRequestOptions).catch(async (error: IResponseError) => {
const statusCodeReturned = const statusCodeReturned =
oAuth2Options?.tokenExpiredStatusCode === undefined oAuth2Options?.tokenExpiredStatusCode === undefined
@ -1057,9 +1087,13 @@ export async function requestOAuth2(
// Make the request again with the new token // Make the request again with the new token
const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject); 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); return this.helpers.request!(newRequestOptions);
} }

View file

@ -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',
},
};
}

View file

@ -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',
},
];
}

View file

@ -1216,6 +1216,10 @@ export class HttpRequest implements INodeType {
boxOAuth2Api: { boxOAuth2Api: {
includeCredentialsOnRefreshOnBody: true, includeCredentialsOnRefreshOnBody: true,
}, },
shopifyOAuth2Api: {
tokenType: 'Bearer',
keyToIncludeInAccessTokenHeader: 'X-Shopify-Access-Token',
},
}; };
const additionalOAuth2Options = oAuth2Options[nodeCredentialType]; const additionalOAuth2Options = oAuth2Options[nodeCredentialType];

View file

@ -11,7 +11,7 @@ import {
} from 'n8n-core'; } from 'n8n-core';
import { import {
IDataObject, NodeApiError, NodeOperationError, IDataObject, IOAuth2Options, NodeApiError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
@ -19,12 +19,25 @@ import {
} from 'change-case'; } 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 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({}, const authenticationMethod = this.getNodeParameter('authentication', 0, 'oAuth2') as string;
{ Authorization: `Basic ${Buffer.from(`${credentials.apiKey}:${credentials.password}`).toString(BINARY_ENCODING)}` });
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 = { const options: OptionsWithUri = {
headers: headerWithAuthentication,
method, method,
qs: query, qs: query,
uri: uri || `https://${credentials.shopSubdomain}.myshopify.com/admin/api/2019-10${resource}`, uri: uri || `https://${credentials.shopSubdomain}.myshopify.com/admin/api/2019-10${resource}`,
@ -32,6 +45,15 @@ export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions
json: true, 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) { if (Object.keys(option).length !== 0) {
Object.assign(options, option); Object.assign(options, option);
} }
@ -41,14 +63,14 @@ export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions
if (Object.keys(query).length === 0) { if (Object.keys(query).length === 0) {
delete options.qs; delete options.qs;
} }
try { try {
return await this.helpers.request!(options); return await this.helpers.requestWithAuthentication.call(this, credentialType, options, { oauth2: oAuth2Options });
} catch (error) { } catch (error) {
throw new NodeApiError(this.getNode(), 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 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[] = []; const returnData: IDataObject[] = [];

View file

@ -57,9 +57,58 @@ export class Shopify implements INodeType {
{ {
name: 'shopifyApi', name: 'shopifyApi',
required: true, required: true,
displayOptions: {
show: {
authentication: [
'apiKey',
],
},
},
},
{
name: 'shopifyAccessTokenApi',
required: true,
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'shopifyOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
}, },
], ],
properties: [ 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', displayName: 'Resource',
name: 'resource', name: 'resource',

View file

@ -36,6 +36,35 @@ export class ShopifyTrigger implements INodeType {
{ {
name: 'shopifyApi', name: 'shopifyApi',
required: true, required: true,
displayOptions: {
show: {
authentication: [
'apiKey',
],
},
},
},
{
name: 'shopifyAccessTokenApi',
required: true,
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'shopifyOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
}, },
], ],
webhooks: [ webhooks: [
@ -47,6 +76,26 @@ export class ShopifyTrigger implements INodeType {
}, },
], ],
properties: [ 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: 'Topic', displayName: 'Topic',
name: 'topic', name: 'topic',
@ -356,14 +405,33 @@ export class ShopifyTrigger implements INodeType {
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> { async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const headerData = this.getHeaderData() as IDataObject; const headerData = this.getHeaderData() as IDataObject;
const req = this.getRequestObject(); const req = this.getRequestObject();
const credentials = await this.getCredentials('shopifyApi'); const authentication = this.getNodeParameter('authentication') as string;
let secret = '';
console.log('llego request');
if (authentication === 'apiKey') {
const credentials = await this.getCredentials('shopifyApi');
secret = credentials.sharedSecret as string;
}
if (authentication === 'accessToken') {
const credentials = await this.getCredentials('shopifyAccessTokenApi');
secret = credentials.appSecretKey as string;
}
if (authentication === 'oAuth2') {
const credentials = await this.getCredentials('shopifyOAuth2Api');
secret = credentials.clientSecret as string;
}
const topic = this.getNodeParameter('topic') as string; const topic = this.getNodeParameter('topic') as string;
if (headerData['x-shopify-topic'] !== undefined if (headerData['x-shopify-topic'] !== undefined
&& headerData['x-shopify-hmac-sha256'] !== undefined && headerData['x-shopify-hmac-sha256'] !== undefined
&& headerData['x-shopify-shop-domain'] !== undefined && headerData['x-shopify-shop-domain'] !== undefined
&& headerData['x-shopify-api-version'] !== undefined) { && headerData['x-shopify-api-version'] !== undefined) {
// @ts-ignore // @ts-ignore
const computedSignature = createHmac('sha256', credentials.sharedSecret as string).update(req.rawBody).digest('base64'); const computedSignature = createHmac('sha256', secret).update(req.rawBody).digest('base64');
if (headerData['x-shopify-hmac-sha256'] !== computedSignature) { if (headerData['x-shopify-hmac-sha256'] !== computedSignature) {
return {}; return {};
} }

View file

@ -260,6 +260,8 @@
"dist/credentials/ServiceNowBasicApi.credentials.js", "dist/credentials/ServiceNowBasicApi.credentials.js",
"dist/credentials/Sftp.credentials.js", "dist/credentials/Sftp.credentials.js",
"dist/credentials/ShopifyApi.credentials.js", "dist/credentials/ShopifyApi.credentials.js",
"dist/credentials/ShopifyAccessTokenApi.credentials.js",
"dist/credentials/ShopifyOAuth2Api.credentials.js",
"dist/credentials/Signl4Api.credentials.js", "dist/credentials/Signl4Api.credentials.js",
"dist/credentials/SlackApi.credentials.js", "dist/credentials/SlackApi.credentials.js",
"dist/credentials/SlackOAuth2Api.credentials.js", "dist/credentials/SlackOAuth2Api.credentials.js",

View file

@ -38,12 +38,17 @@ export interface IBinaryData {
id?: string; id?: string;
} }
// All properties in this interface except for
// "includeCredentialsOnRefreshOnBody" will get
// removed once we add the OAuth2 hooks to the
// credentials file.
export interface IOAuth2Options { export interface IOAuth2Options {
includeCredentialsOnRefreshOnBody?: boolean; includeCredentialsOnRefreshOnBody?: boolean;
property?: string; property?: string;
tokenType?: string; tokenType?: string;
keepBearer?: boolean; keepBearer?: boolean;
tokenExpiredStatusCode?: number; tokenExpiredStatusCode?: number;
keyToIncludeInAccessTokenHeader?: string;
} }
export interface IConnection { export interface IConnection {