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

feat: Create TOTP node #5901

Merged
merged 10 commits into from
Apr 11, 2023
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
2 changes: 2 additions & 0 deletions packages/editor-ui/src/plugins/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
faFileImport,
faFilePdf,
faFilter,
faFingerprint,
faFlask,
faFolderOpen,
faFont,
Expand Down Expand Up @@ -189,6 +190,7 @@ addIcon(faFileExport);
addIcon(faFileImport);
addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFingerprint);
addIcon(faFlask);
addIcon(faFolderOpen);
addIcon(faFont);
Expand Down
33 changes: 33 additions & 0 deletions packages/nodes-base/credentials/TotpApi.credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';

export class TotpApi implements ICredentialType {
name = 'totpApi';

displayName = 'TOTP API';

documentationUrl = 'totp';

properties: INodeProperties[] = [
{
displayName: 'Secret',
name: 'secret',
type: 'string',
typeOptions: { password: true },
default: '',
placeholder: 'e.g. BVDRSBXQB2ZEL5HE',
required: true,
description:
'Secret key encoded in the QR code during setup. <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format#secret">Learn more</a>.',
},
{
displayName: 'Label',
name: 'label',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. GitHub:john-doe',
description:
'Identifier for the TOTP account, in the <code>issuer:username</code> format. <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format#label">Learn more</a>.',
},
];
}
16 changes: 16 additions & 0 deletions packages/nodes-base/nodes/Totp/Totp.node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"node": "n8n-nodes-base.totp",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Core Nodes"],
"subcategories": ["Helpers"],
"details": "Generate a time-based one-time password",
"alias": ["2FA", "MFA", "authentication", "Security", "OTP", "password", "multi", "factor"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.totp/"
}
]
}
}
174 changes: 174 additions & 0 deletions packages/nodes-base/nodes/Totp/Totp.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';

import { NodeOperationError } from 'n8n-workflow';

import OTPAuth from 'otpauth';

export class Totp implements INodeType {
description: INodeTypeDescription = {
displayName: 'TOTP',
name: 'totp',
icon: 'fa:fingerprint',
group: ['transform'],
version: 1,
subtitle: '={{ $parameter["operation"] }}',
description: 'Generate a time-based one-time password',
defaults: {
name: 'TOTP',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'totpApi',
required: true,
},
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Generate Secret',
value: 'generateSecret',
action: 'Generate secret',
},
],
default: 'generateSecret',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
operation: ['generateSecret'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Algorithm',
name: 'algorithm',
type: 'options',
default: 'SHA1',
description: 'HMAC hashing algorithm. Defaults to SHA1.',
options: [
{
name: 'SHA1',
value: 'SHA1',
},
{
name: 'SHA224',
value: 'SHA224',
},
{
name: 'SHA256',
value: 'SHA256',
},
{
name: 'SHA3-224',
value: 'SHA3-224',
},
{
name: 'SHA3-256',
value: 'SHA3-256',
},
{
name: 'SHA3-384',
value: 'SHA3-384',
},
{
name: 'SHA3-512',
value: 'SHA3-512',
},
{
name: 'SHA384',
value: 'SHA384',
},
{
name: 'SHA512',
value: 'SHA512',
},
],
},
{
displayName: 'Digits',
name: 'digits',
type: 'number',
default: 6,
description: 'Number of digits in the generated TOTP code. Defaults to 6 digits.',
},
{
displayName: 'Period',
name: 'period',
type: 'number',
default: 30,
description:
'How many seconds the generated TOTP code is valid for. Defaults to 30 seconds.',
},
],
},
],
};

async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];

const operation = this.getNodeParameter('operation', 0);
const credentials = (await this.getCredentials('totpApi')) as { label: string; secret: string };

if (!credentials.label.includes(':')) {
throw new NodeOperationError(this.getNode(), 'Malformed label - expected `issuer:username`');
}

const options = this.getNodeParameter('options', 0) as {
algorithm?: string;
digits?: number;
period?: number;
};

if (!options.algorithm) options.algorithm = 'SHA1';
if (!options.digits) options.digits = 6;
if (!options.period) options.period = 30;
Comment on lines +141 to +143
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a cleaner way to specify default options? Adding default: { algorithm: 'SHA1', period: 30, digits: 6 } causes the options to start off revealed, which is not what we want, but otherwise options defaults to {}, which is also not what we want.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something like this?

const options = {
	algorithm: 'SHA1',
	digits: 6,
	period: 30,
	...(this.getNodeParameter('options', 0, {}) as IDataObject)),
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I should've been clearer. By "cleaner" I meant to avoid repeating all these defaults that were already specified in the node params above, for maintainability.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not that I'm aware of, we could use some const/enum defaultOptions to make it dryer

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I think the ideal would be for getNodeParameter to return an object with every field set to its default value, so that making it dryer does not introduce too much indirection. Let's ignore for now then.


const [issuer] = credentials.label.split(':');

const totp = new OTPAuth.TOTP({
issuer,
label: credentials.label,
secret: credentials.secret,
algorithm: options.algorithm,
digits: options.digits,
period: options.period,
});

const token = totp.generate();

const secondsRemaining =
(options.period * (1 - ((Date.now() / 1000 / options.period) % 1))) | 0;

if (operation === 'generateSecret') {
for (let i = 0; i < items.length; i++) {
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ token, secondsRemaining }),
{ itemData: { item: i } },
);

returnData.push(...executionData);
}
}

return this.prepareOutputData(returnData);
}
}
47 changes: 47 additions & 0 deletions packages/nodes-base/nodes/Totp/test/Totp.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as Helpers from '../../../test/nodes/Helpers';
import { executeWorkflow } from '../../../test/nodes/ExecuteWorkflow';
import type { WorkflowTestData } from '../../../test/nodes/types';

jest.mock('otpauth', () => {
return {
TOTP: jest.fn().mockImplementation(() => {
return {
generate: jest.fn().mockReturnValue('123456'),
};
}),
};
});

describe('Execute TOTP node', () => {
const tests: WorkflowTestData[] = [
{
description: 'Generate TOTP Token',
input: {
workflowData: Helpers.readJsonFileSync('nodes/Totp/test/Totp.workflow.test.json'),
},
output: {
nodeData: {
TOTP: [[{ json: { token: '123456' } }]], // ignore secondsRemaining to prevent flakiness
},
},
},
];

const nodeTypes = Helpers.setup(tests);

for (const testData of tests) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
test(testData.description, async () => {
const { result } = await executeWorkflow(testData, nodeTypes);

Helpers.getResultNodeData(result, testData).forEach(({ nodeName, resultData }) => {
const expected = testData.output.nodeData[nodeName][0][0].json;
const actual = resultData[0]?.[0].json;

expect(actual?.token).toEqual(expected.token);
});

expect(result.finished).toEqual(true);
});
}
});
41 changes: 41 additions & 0 deletions packages/nodes-base/nodes/Totp/test/Totp.workflow.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"nodes": [
{
"parameters": {},
"id": "f2e03169-0e94-4a42-821b-3e8f67f449d7",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [580, 320]
},
{
"parameters": {
"additionalOptions": {}
},
"id": "831f657d-2724-4a25-bb94-cf37355654bb",
"name": "TOTP",
"type": "n8n-nodes-base.totp",
"typeVersion": 1,
"position": [800, 320],
"credentials": {
"totpApi": {
"id": "1",
"name": "TOTP account"
}
}
}
],
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "TOTP",
"type": "main",
"index": 0
}
]
]
}
}
}
3 changes: 3 additions & 0 deletions packages/nodes-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@
"dist/credentials/TodoistApi.credentials.js",
"dist/credentials/TodoistOAuth2Api.credentials.js",
"dist/credentials/TogglApi.credentials.js",
"dist/credentials/TotpApi.credentials.js",
"dist/credentials/TravisCiApi.credentials.js",
"dist/credentials/TrelloApi.credentials.js",
"dist/credentials/TwakeCloudApi.credentials.js",
Expand Down Expand Up @@ -687,6 +688,7 @@
"dist/nodes/TimescaleDb/TimescaleDb.node.js",
"dist/nodes/Todoist/Todoist.node.js",
"dist/nodes/Toggl/TogglTrigger.node.js",
"dist/nodes/Totp/Totp.node.js",
"dist/nodes/TravisCi/TravisCi.node.js",
"dist/nodes/Trello/Trello.node.js",
"dist/nodes/Trello/TrelloTrigger.node.js",
Expand Down Expand Up @@ -882,6 +884,7 @@
"node-html-markdown": "^1.1.3",
"node-ssh": "^12.0.0",
"nodemailer": "^6.7.1",
"otpauth": "^9.1.1",
"pdf-parse": "^1.1.1",
"pg": "^8.3.0",
"pg-promise": "^10.5.8",
Expand Down
22 changes: 22 additions & 0 deletions packages/nodes-base/test/nodes/FakeCredentialsMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IDataObject } from 'n8n-workflow';

// If your test needs data from credentials, you can add it here.
// as JSON.stringify({ id: 'credentials_ID', name: 'credentials_name' }) for specific credentials
// or as 'credentials_type' for all credentials of that type
// expected keys for credentials can be found in packages/nodes-base/credentials/[credentials_type].credentials.ts
export const FAKE_CREDENTIALS_DATA: IDataObject = {
[JSON.stringify({ id: '20', name: 'Airtable account' })]: {
apiKey: 'key456',
},
airtableApi: {
apiKey: 'key123',
},
n8nApi: {
apiKey: 'key123',
baseUrl: 'https://test.app.n8n.cloud/api/v1',
},
totpApi: {
label: 'GitHub:john-doe',
secret: 'BVDRSBXQB2ZEL5HE',
},
};
Loading