Asana OAuth2 support (#669)

* OAuth2 support

*  Improvements

*  Improvements

*  Improvements to Asana Trigger Node

Co-authored-by: Rupenieks <ru@myos,co>
Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Rupenieks
2020-09-16 18:20:27 +02:00
committed by GitHub
parent 393bc8fd54
commit 1a411ebef7
6 changed files with 219 additions and 91 deletions

View File

@@ -3,7 +3,6 @@ import {
NodePropertyTypes, NodePropertyTypes,
} from 'n8n-workflow'; } from 'n8n-workflow';
export class AsanaApi implements ICredentialType { export class AsanaApi implements ICredentialType {
name = 'asanaApi'; name = 'asanaApi';
displayName = 'Asana API'; displayName = 'Asana API';

View File

@@ -0,0 +1,47 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class AsanaOAuth2Api implements ICredentialType {
name = 'asanaOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'Asana OAuth2 API';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://app.asana.com/-/oauth_authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://app.asana.com/-/oauth_token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'body',
description: 'Resource to consume.',
},
];
}

View File

@@ -14,6 +14,7 @@ import {
import { import {
asanaApiRequest, asanaApiRequest,
asanaApiRequestAllItems, asanaApiRequestAllItems,
getWorkspaces,
} from './GenericFunctions'; } from './GenericFunctions';
export class Asana implements INodeType { export class Asana implements INodeType {
@@ -35,9 +36,44 @@ export class Asana implements INodeType {
{ {
name: 'asanaApi', name: 'asanaApi',
required: true, required: true,
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'asanaOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
}, },
], ],
properties: [ properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'accessToken',
description: 'The resource to operate on.',
},
{ {
displayName: 'Resource', displayName: 'Resource',
name: 'resource', name: 'resource',
@@ -1004,32 +1040,7 @@ export class Asana implements INodeType {
loadOptions: { loadOptions: {
// Get all the available workspaces to display them to user so that he can // Get all the available workspaces to display them to user so that he can
// select them easily // select them easily
async getWorkspaces(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> { getWorkspaces,
const endpoint = '/workspaces';
const responseData = await asanaApiRequestAllItems.call(this, 'GET', endpoint, {});
const returnData: INodePropertyOptions[] = [];
for (const workspaceData of responseData) {
if (workspaceData.resource_type !== 'workspace') {
// Not sure if for some reason also ever other resources
// get returned but just in case filter them out
continue;
}
returnData.push({
name: workspaceData.name,
value: workspaceData.gid,
});
}
returnData.sort((a, b) => {
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
});
return returnData;
},
// Get all the available projects to display them to user so that they can be // Get all the available projects to display them to user so that they can be
// selected easily // selected easily
@@ -1215,12 +1226,6 @@ export class Asana implements INodeType {
const items = this.getInputData(); const items = this.getInputData();
const returnData: IDataObject[] = []; const returnData: IDataObject[] = [];
const credentials = this.getCredentials('asanaApi');
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
const resource = this.getNodeParameter('resource', 0) as string; const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string; const operation = this.getNodeParameter('operation', 0) as string;

View File

@@ -5,6 +5,8 @@ import {
import { import {
IDataObject, IDataObject,
ILoadOptionsFunctions,
INodePropertyOptions,
INodeTypeDescription, INodeTypeDescription,
INodeType, INodeType,
IWebhookResponseData, IWebhookResponseData,
@@ -12,9 +14,12 @@ import {
import { import {
asanaApiRequest, asanaApiRequest,
getWorkspaces,
} from './GenericFunctions'; } from './GenericFunctions';
import { createHmac } from 'crypto'; import {
createHmac,
} from 'crypto';
export class AsanaTrigger implements INodeType { export class AsanaTrigger implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@@ -26,7 +31,7 @@ export class AsanaTrigger implements INodeType {
description: 'Starts the workflow when Asana events occure.', description: 'Starts the workflow when Asana events occure.',
defaults: { defaults: {
name: 'Asana-Trigger', name: 'Asana-Trigger',
color: '#559922', color: '#FC636B',
}, },
inputs: [], inputs: [],
outputs: ['main'], outputs: ['main'],
@@ -34,7 +39,25 @@ export class AsanaTrigger implements INodeType {
{ {
name: 'asanaApi', name: 'asanaApi',
required: true, required: true,
} displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'asanaOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
},
], ],
webhooks: [ webhooks: [
{ {
@@ -45,6 +68,23 @@ export class AsanaTrigger implements INodeType {
}, },
], ],
properties: [ properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'accessToken',
description: 'The resource to operate on.',
},
{ {
displayName: 'Resource', displayName: 'Resource',
name: 'resource', name: 'resource',
@@ -56,13 +96,31 @@ export class AsanaTrigger implements INodeType {
{ {
displayName: 'Workspace', displayName: 'Workspace',
name: 'workspace', name: 'workspace',
type: 'string', type: 'options',
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
options: [],
default: '', default: '',
required: false, required: false,
description: 'The workspace ID the resource is registered under. This is only required if you want to allow overriding existing webhooks.', description: 'The workspace ID the resource is registered under. This is only required if you want to allow overriding existing webhooks.',
}, },
], ],
};
methods = {
loadOptions: {
// Get all the available workspaces to display them to user so that he can
// select them easily
async getWorkspaces(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const workspaces = await getWorkspaces.call(this);
workspaces.unshift({
name: '',
value: '',
});
return workspaces;
},
},
}; };
// @ts-ignore (because of request) // @ts-ignore (because of request)
@@ -71,32 +129,29 @@ export class AsanaTrigger implements INodeType {
async checkExists(this: IHookFunctions): Promise<boolean> { async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node'); const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId === undefined) { const webhookUrl = this.getNodeWebhookUrl('default') as string;
// No webhook id is set so no webhook can exist
return false;
}
// Webhook got created before so check if it still exists const resource = this.getNodeParameter('resource') as string;
const endpoint = `webhooks/${webhookData.webhookId}`;
try { const workspace = this.getNodeParameter('workspace') as string;
await asanaApiRequest.call(this, 'GET', endpoint, {});
} catch (e) {
if (e.statusCode === 404) {
// Webhook does not exist
delete webhookData.webhookId;
return false; const endpoint = '/webhooks';
const { data } = await asanaApiRequest.call(this, 'GET', endpoint, {}, { workspace });
for (const webhook of data) {
if (webhook.resource.gid === resource && webhook.target === webhookUrl) {
webhookData.webhookId = webhook.gid;
return true;
} }
// Some error occured
throw e;
} }
// If it did not error then the webhook exists // If it did not error then the webhook exists
return true; return false;
}, },
async create(this: IHookFunctions): Promise<boolean> { async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default') as string; const webhookUrl = this.getNodeWebhookUrl('default') as string;
if (webhookUrl.includes('%20')) { if (webhookUrl.includes('%20')) {
@@ -105,9 +160,7 @@ export class AsanaTrigger implements INodeType {
const resource = this.getNodeParameter('resource') as string; const resource = this.getNodeParameter('resource') as string;
const workspace = this.getNodeParameter('workspace') as string; const endpoint = `/webhooks`;
const endpoint = `webhooks`;
const body = { const body = {
resource, resource,
@@ -115,29 +168,15 @@ export class AsanaTrigger implements INodeType {
}; };
let responseData; let responseData;
try {
responseData = await asanaApiRequest.call(this, 'POST', endpoint, body);
} catch(error) {
// delete webhook if it already exists
if (error.statusCode === 403) {
const webhookData = await asanaApiRequest.call(this, 'GET', endpoint, {}, { workspace });
const webhook = webhookData.data.find((webhook: any) => { // tslint:disable-line:no-any
return webhook.target === webhookUrl && webhook.resource.gid === resource;
});
await asanaApiRequest.call(this, 'DELETE', `${endpoint}/${webhook.gid}`, {});
responseData = await asanaApiRequest.call(this, 'POST', endpoint, body);
} else {
throw error;
}
}
if (responseData.data === undefined || responseData.data.id === undefined) { responseData = await asanaApiRequest.call(this, 'POST', endpoint, body);
if (responseData.data === undefined || responseData.data.gid === undefined) {
// Required data is missing so was not successful // Required data is missing so was not successful
return false; return false;
} }
const webhookData = this.getWorkflowStaticData('node'); webhookData.webhookId = responseData.data.gid as string;
webhookData.webhookId = responseData.data.id as string;
return true; return true;
}, },
@@ -145,7 +184,7 @@ export class AsanaTrigger implements INodeType {
const webhookData = this.getWorkflowStaticData('node'); const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) { if (webhookData.webhookId !== undefined) {
const endpoint = `webhooks/${webhookData.webhookId}`; const endpoint = `/webhooks/${webhookData.webhookId}`;
const body = {}; const body = {};
try { try {
@@ -165,15 +204,12 @@ export class AsanaTrigger implements INodeType {
}, },
}; };
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> { async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData() as IDataObject; const bodyData = this.getBodyData() as IDataObject;
const headerData = this.getHeaderData() as IDataObject; const headerData = this.getHeaderData() as IDataObject;
const req = this.getRequestObject(); const req = this.getRequestObject();
const webhookData = this.getWorkflowStaticData('node') as IDataObject; const webhookData = this.getWorkflowStaticData('node');
if (headerData['x-hook-secret'] !== undefined) { if (headerData['x-hook-secret'] !== undefined) {
// Is a create webhook confirmation request // Is a create webhook confirmation request
@@ -182,6 +218,7 @@ export class AsanaTrigger implements INodeType {
const res = this.getResponseObject(); const res = this.getResponseObject();
res.set('X-Hook-Secret', webhookData.hookSecret as string); res.set('X-Hook-Secret', webhookData.hookSecret as string);
res.status(200).end(); res.status(200).end();
return { return {
noWebhookResponse: true, noWebhookResponse: true,
}; };
@@ -198,7 +235,7 @@ export class AsanaTrigger implements INodeType {
// Check if the request is valid // Check if the request is valid
// (if the signature matches to data and hookSecret) // (if the signature matches to data and hookSecret)
const computedSignature = createHmac("sha256", webhookData.hookSecret as string).update(JSON.stringify(req.body)).digest("hex"); const computedSignature = createHmac('sha256', webhookData.hookSecret as string).update(JSON.stringify(req.body)).digest('hex');
if (headerData['x-hook-signature'] !== computedSignature) { if (headerData['x-hook-signature'] !== computedSignature) {
// Signature is not valid so ignore call // Signature is not valid so ignore call
return {}; return {};
@@ -206,7 +243,7 @@ export class AsanaTrigger implements INodeType {
return { return {
workflowData: [ workflowData: [
this.helpers.returnJsonArray(req.body) this.helpers.returnJsonArray(req.body.events)
], ],
}; };
} }

View File

@@ -10,6 +10,7 @@ import {
import { import {
IDataObject, IDataObject,
INodePropertyOptions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
@@ -26,16 +27,10 @@ import {
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: object, uri?: string | undefined): Promise<any> { // tslint:disable-line:no-any export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: object, uri?: string | undefined): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('asanaApi'); const authenticationMethod = this.getNodeParameter('authentication', 0);
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
const options: OptionsWithUri = { const options: OptionsWithUri = {
headers: { headers: {},
Authorization: `Bearer ${credentials.accessToken}`,
},
method, method,
body: { data: body }, body: { data: body },
qs: query, qs: query,
@@ -44,13 +39,30 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions |
}; };
try { try {
return await this.helpers.request!(options); if (authenticationMethod === 'accessToken') {
const credentials = this.getCredentials('asanaApi');
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
options.headers!['Authorization'] = `Bearer ${credentials.accessToken}`;
return await this.helpers.request!(options);
} else {
//@ts-ignore
return await this.helpers.requestOAuth2.call(this, 'asanaOAuth2Api', options);
}
} catch (error) { } catch (error) {
if (error.statusCode === 401) { if (error.statusCode === 401) {
// Return a clear error // Return a clear error
throw new Error('The Asana credentials are not valid!'); throw new Error('The Asana credentials are not valid!');
} }
if (error.statusCode === 403) {
throw error;
}
if (error.response && error.response.body && error.response.body.errors) { if (error.response && error.response.body && error.response.body.errors) {
// Try to return the error prettier // Try to return the error prettier
const errorMessages = error.response.body.errors.map((errorData: { message: string }) => { const errorMessages = error.response.body.errors.map((errorData: { message: string }) => {
@@ -82,3 +94,30 @@ export async function asanaApiRequestAllItems(this: IExecuteFunctions | ILoadOpt
return returnData; return returnData;
} }
export async function getWorkspaces(this: ILoadOptionsFunctions): Promise < INodePropertyOptions[] > {
const endpoint = '/workspaces';
const responseData = await asanaApiRequestAllItems.call(this, 'GET', endpoint, {});
const returnData: INodePropertyOptions[] = [];
for(const workspaceData of responseData) {
if (workspaceData.resource_type !== 'workspace') {
// Not sure if for some reason also ever other resources
// get returned but just in case filter them out
continue;
}
returnData.push({
name: workspaceData.name,
value: workspaceData.gid,
});
}
returnData.sort((a, b) => {
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
});
return returnData;
}

View File

@@ -33,6 +33,7 @@
"dist/credentials/AirtableApi.credentials.js", "dist/credentials/AirtableApi.credentials.js",
"dist/credentials/Amqp.credentials.js", "dist/credentials/Amqp.credentials.js",
"dist/credentials/AsanaApi.credentials.js", "dist/credentials/AsanaApi.credentials.js",
"dist/credentials/AsanaOAuth2Api.credentials.js",
"dist/credentials/Aws.credentials.js", "dist/credentials/Aws.credentials.js",
"dist/credentials/AffinityApi.credentials.js", "dist/credentials/AffinityApi.credentials.js",
"dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BannerbearApi.credentials.js",