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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 219 additions and 91 deletions

View file

@ -3,7 +3,6 @@ import {
NodePropertyTypes,
} from 'n8n-workflow';
export class AsanaApi implements ICredentialType {
name = 'asanaApi';
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 {
asanaApiRequest,
asanaApiRequestAllItems,
getWorkspaces,
} from './GenericFunctions';
export class Asana implements INodeType {
@ -35,9 +36,44 @@ export class Asana implements INodeType {
{
name: 'asanaApi',
required: true,
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'asanaOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
},
],
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',
name: 'resource',
@ -1004,32 +1040,7 @@ export class Asana implements INodeType {
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 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;
},
getWorkspaces,
// Get all the available projects to display them to user so that they can be
// selected easily
@ -1215,12 +1226,6 @@ export class Asana implements INodeType {
const items = this.getInputData();
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 operation = this.getNodeParameter('operation', 0) as string;

View file

@ -5,6 +5,8 @@ import {
import {
IDataObject,
ILoadOptionsFunctions,
INodePropertyOptions,
INodeTypeDescription,
INodeType,
IWebhookResponseData,
@ -12,9 +14,12 @@ import {
import {
asanaApiRequest,
getWorkspaces,
} from './GenericFunctions';
import { createHmac } from 'crypto';
import {
createHmac,
} from 'crypto';
export class AsanaTrigger implements INodeType {
description: INodeTypeDescription = {
@ -26,7 +31,7 @@ export class AsanaTrigger implements INodeType {
description: 'Starts the workflow when Asana events occure.',
defaults: {
name: 'Asana-Trigger',
color: '#559922',
color: '#FC636B',
},
inputs: [],
outputs: ['main'],
@ -34,7 +39,25 @@ export class AsanaTrigger implements INodeType {
{
name: 'asanaApi',
required: true,
}
displayOptions: {
show: {
authentication: [
'accessToken',
],
},
},
},
{
name: 'asanaOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2',
],
},
},
},
],
webhooks: [
{
@ -45,6 +68,23 @@ export class AsanaTrigger implements INodeType {
},
],
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',
name: 'resource',
@ -56,13 +96,31 @@ export class AsanaTrigger implements INodeType {
{
displayName: 'Workspace',
name: 'workspace',
type: 'string',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
options: [],
default: '',
required: false,
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)
@ -71,32 +129,29 @@ export class AsanaTrigger implements INodeType {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId === undefined) {
// No webhook id is set so no webhook can exist
return false;
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const resource = this.getNodeParameter('resource') as string;
const workspace = this.getNodeParameter('workspace') as string;
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;
}
// Webhook got created before so check if it still exists
const endpoint = `webhooks/${webhookData.webhookId}`;
try {
await asanaApiRequest.call(this, 'GET', endpoint, {});
} catch (e) {
if (e.statusCode === 404) {
// Webhook does not exist
delete webhookData.webhookId;
return false;
}
// Some error occured
throw e;
}
// If it did not error then the webhook exists
return true;
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default') as string;
if (webhookUrl.includes('%20')) {
@ -105,9 +160,7 @@ export class AsanaTrigger implements INodeType {
const resource = this.getNodeParameter('resource') as string;
const workspace = this.getNodeParameter('workspace') as string;
const endpoint = `webhooks`;
const endpoint = `/webhooks`;
const body = {
resource,
@ -115,29 +168,15 @@ export class AsanaTrigger implements INodeType {
};
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
return false;
}
const webhookData = this.getWorkflowStaticData('node');
webhookData.webhookId = responseData.data.id as string;
webhookData.webhookId = responseData.data.gid as string;
return true;
},
@ -145,7 +184,7 @@ export class AsanaTrigger implements INodeType {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
const endpoint = `webhooks/${webhookData.webhookId}`;
const endpoint = `/webhooks/${webhookData.webhookId}`;
const body = {};
try {
@ -165,15 +204,12 @@ export class AsanaTrigger implements INodeType {
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData() as IDataObject;
const headerData = this.getHeaderData() as IDataObject;
const req = this.getRequestObject();
const webhookData = this.getWorkflowStaticData('node') as IDataObject;
const webhookData = this.getWorkflowStaticData('node');
if (headerData['x-hook-secret'] !== undefined) {
// Is a create webhook confirmation request
@ -182,6 +218,7 @@ export class AsanaTrigger implements INodeType {
const res = this.getResponseObject();
res.set('X-Hook-Secret', webhookData.hookSecret as string);
res.status(200).end();
return {
noWebhookResponse: true,
};
@ -198,7 +235,7 @@ export class AsanaTrigger implements INodeType {
// Check if the request is valid
// (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) {
// Signature is not valid so ignore call
return {};
@ -206,7 +243,7 @@ export class AsanaTrigger implements INodeType {
return {
workflowData: [
this.helpers.returnJsonArray(req.body)
this.helpers.returnJsonArray(req.body.events)
],
};
}

View file

@ -10,6 +10,7 @@ import {
import {
IDataObject,
INodePropertyOptions,
} from 'n8n-workflow';
import {
@ -26,16 +27,10 @@ import {
* @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
const credentials = this.getCredentials('asanaApi');
if (credentials === undefined) {
throw new Error('No credentials got returned!');
}
const authenticationMethod = this.getNodeParameter('authentication', 0);
const options: OptionsWithUri = {
headers: {
Authorization: `Bearer ${credentials.accessToken}`,
},
headers: {},
method,
body: { data: body },
qs: query,
@ -44,13 +39,30 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions |
};
try {
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) {
if (error.statusCode === 401) {
// Return a clear error
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) {
// Try to return the error prettier
const errorMessages = error.response.body.errors.map((errorData: { message: string }) => {
@ -82,3 +94,30 @@ export async function asanaApiRequestAllItems(this: IExecuteFunctions | ILoadOpt
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/Amqp.credentials.js",
"dist/credentials/AsanaApi.credentials.js",
"dist/credentials/AsanaOAuth2Api.credentials.js",
"dist/credentials/Aws.credentials.js",
"dist/credentials/AffinityApi.credentials.js",
"dist/credentials/BannerbearApi.credentials.js",