From 945e25a77cf9ba33bc3e4b70053319ea86230cf7 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Fri, 15 Jul 2022 11:36:01 +0300 Subject: [PATCH] feat(Shopify Node): Add OAuth support (#3389) * :zap: wip * :zap: Add includeAccessTokenInHeader option to OAuth2 * :hammer: fixed build error, fixed trigger node when using token auth * :hammer: fixed trigger when using oauth2 * :hammer: changed default auth method to access token * :zap: Improvements * :zap: Improvements * :zap: Improvements * :zap: Rename includeAccessTokenInHeader to keyToIncludeInAccessTokenHeader * :zap: Assign values to only header property * :fire: Remove unreachable code * :zap: Add keyToIncludeInAccessTokenHeader when isN8nRequest * :zap: Add CC grant type when isN8nRequest Co-authored-by: Ricardo Espinoza Co-authored-by: ricardo Co-authored-by: Jan Oberhauser --- packages/core/src/NodeExecuteFunctions.ts | 40 ++++++++- .../ShopifyAccessTokenApi.credentials.ts | 51 +++++++++++ .../ShopifyOAuth2Api.credentials.ts | 86 +++++++++++++++++++ .../nodes/HttpRequest/HttpRequest.node.ts | 4 + .../nodes/Shopify/GenericFunctions.ts | 36 ++++++-- .../nodes-base/nodes/Shopify/Shopify.node.ts | 49 +++++++++++ .../nodes/Shopify/ShopifyTrigger.node.ts | 72 +++++++++++++++- packages/nodes-base/package.json | 2 + packages/workflow/src/Interfaces.ts | 5 ++ 9 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 packages/nodes-base/credentials/ShopifyAccessTokenApi.credentials.ts create mode 100644 packages/nodes-base/credentials/ShopifyOAuth2Api.credentials.ts diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 8bd837b6df..bba6e42169 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -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[]; @@ -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) { @@ -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]) { @@ -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 @@ -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); } diff --git a/packages/nodes-base/credentials/ShopifyAccessTokenApi.credentials.ts b/packages/nodes-base/credentials/ShopifyAccessTokenApi.credentials.ts new file mode 100644 index 0000000000..b87e196ff1 --- /dev/null +++ b/packages/nodes-base/credentials/ShopifyAccessTokenApi.credentials.ts @@ -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', + }, + }; +} diff --git a/packages/nodes-base/credentials/ShopifyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/ShopifyOAuth2Api.credentials.ts new file mode 100644 index 0000000000..625bf5903f --- /dev/null +++ b/packages/nodes-base/credentials/ShopifyOAuth2Api.credentials.ts @@ -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', + }, + ]; +} diff --git a/packages/nodes-base/nodes/HttpRequest/HttpRequest.node.ts b/packages/nodes-base/nodes/HttpRequest/HttpRequest.node.ts index f1be7f1d6d..8e8ea150fc 100644 --- a/packages/nodes-base/nodes/HttpRequest/HttpRequest.node.ts +++ b/packages/nodes-base/nodes/HttpRequest/HttpRequest.node.ts @@ -1216,6 +1216,10 @@ export class HttpRequest implements INodeType { boxOAuth2Api: { includeCredentialsOnRefreshOnBody: true, }, + shopifyOAuth2Api: { + tokenType: 'Bearer', + keyToIncludeInAccessTokenHeader: 'X-Shopify-Access-Token', + }, }; const additionalOAuth2Options = oAuth2Options[nodeCredentialType]; diff --git a/packages/nodes-base/nodes/Shopify/GenericFunctions.ts b/packages/nodes-base/nodes/Shopify/GenericFunctions.ts index 4648a9e9c9..bbf4d6c945 100644 --- a/packages/nodes-base/nodes/Shopify/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Shopify/GenericFunctions.ts @@ -11,7 +11,7 @@ import { } from 'n8n-core'; import { - IDataObject, NodeApiError, NodeOperationError, + IDataObject, IOAuth2Options, NodeApiError, } from 'n8n-workflow'; import { @@ -19,12 +19,25 @@ import { } 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 { // 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}`, @@ -32,6 +45,15 @@ export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions 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); } @@ -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 { // tslint:disable-line:no-any const returnData: IDataObject[] = []; diff --git a/packages/nodes-base/nodes/Shopify/Shopify.node.ts b/packages/nodes-base/nodes/Shopify/Shopify.node.ts index 88668e5207..f2f22d3ede 100644 --- a/packages/nodes-base/nodes/Shopify/Shopify.node.ts +++ b/packages/nodes-base/nodes/Shopify/Shopify.node.ts @@ -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', diff --git a/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts b/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts index fb9d862efd..74df777156 100644 --- a/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts +++ b/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts @@ -36,6 +36,35 @@ export class ShopifyTrigger 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', + ], + }, + }, }, ], webhooks: [ @@ -47,6 +76,26 @@ export class ShopifyTrigger implements INodeType { }, ], 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', name: 'topic', @@ -356,14 +405,33 @@ export class ShopifyTrigger implements INodeType { async webhook(this: IWebhookFunctions): Promise { const headerData = this.getHeaderData() as IDataObject; 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; if (headerData['x-shopify-topic'] !== undefined && headerData['x-shopify-hmac-sha256'] !== undefined && headerData['x-shopify-shop-domain'] !== undefined && headerData['x-shopify-api-version'] !== undefined) { // @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) { return {}; } diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 0e2dbeb44a..dec5e55ce2 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -260,6 +260,8 @@ "dist/credentials/ServiceNowBasicApi.credentials.js", "dist/credentials/Sftp.credentials.js", "dist/credentials/ShopifyApi.credentials.js", + "dist/credentials/ShopifyAccessTokenApi.credentials.js", + "dist/credentials/ShopifyOAuth2Api.credentials.js", "dist/credentials/Signl4Api.credentials.js", "dist/credentials/SlackApi.credentials.js", "dist/credentials/SlackOAuth2Api.credentials.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 908179deb8..4a87b614ba 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -38,12 +38,17 @@ export interface IBinaryData { 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 { includeCredentialsOnRefreshOnBody?: boolean; property?: string; tokenType?: string; keepBearer?: boolean; tokenExpiredStatusCode?: number; + keyToIncludeInAccessTokenHeader?: string; } export interface IConnection {