diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index 8c0a82d4db008..7656b7aef4e1f 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -55,6 +55,7 @@ import { faFileImport, faFilePdf, faFilter, + faFingerprint, faFlask, faFolderOpen, faFont, @@ -189,6 +190,7 @@ addIcon(faFileExport); addIcon(faFileImport); addIcon(faFilePdf); addIcon(faFilter); +addIcon(faFingerprint); addIcon(faFlask); addIcon(faFolderOpen); addIcon(faFont); diff --git a/packages/nodes-base/credentials/TotpApi.credentials.ts b/packages/nodes-base/credentials/TotpApi.credentials.ts new file mode 100644 index 0000000000000..72c1884cd01ea --- /dev/null +++ b/packages/nodes-base/credentials/TotpApi.credentials.ts @@ -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. Learn more.', + }, + { + displayName: 'Label', + name: 'label', + type: 'string', + default: '', + required: true, + placeholder: 'e.g. GitHub:john-doe', + description: + 'Identifier for the TOTP account, in the issuer:username format. Learn more.', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Totp/Totp.node.json b/packages/nodes-base/nodes/Totp/Totp.node.json new file mode 100644 index 0000000000000..5e1c03d5097b6 --- /dev/null +++ b/packages/nodes-base/nodes/Totp/Totp.node.json @@ -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/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Totp/Totp.node.ts b/packages/nodes-base/nodes/Totp/Totp.node.ts new file mode 100644 index 0000000000000..ad52fd33abdb8 --- /dev/null +++ b/packages/nodes-base/nodes/Totp/Totp.node.ts @@ -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 { + 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; + + 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); + } +} diff --git a/packages/nodes-base/nodes/Totp/test/Totp.node.test.ts b/packages/nodes-base/nodes/Totp/test/Totp.node.test.ts new file mode 100644 index 0000000000000..200aa41d35776 --- /dev/null +++ b/packages/nodes-base/nodes/Totp/test/Totp.node.test.ts @@ -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); + }); + } +}); diff --git a/packages/nodes-base/nodes/Totp/test/Totp.workflow.test.json b/packages/nodes-base/nodes/Totp/test/Totp.workflow.test.json new file mode 100644 index 0000000000000..3d3b7a159128e --- /dev/null +++ b/packages/nodes-base/nodes/Totp/test/Totp.workflow.test.json @@ -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 + } + ] + ] + } + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index cddac3f85981d..bea8abf289626 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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", @@ -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", @@ -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", diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts new file mode 100644 index 0000000000000..ab7b4680fad73 --- /dev/null +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -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', + }, +}; diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index 16288ac331514..09a4e9e44ccc7 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -30,6 +30,24 @@ import path from 'path'; import { tmpdir } from 'os'; import { isEmpty } from 'lodash'; +import { FAKE_CREDENTIALS_DATA } from './FakeCredentialsMap'; + +const getFakeDecryptedCredentials = ( + nodeCredentials: INodeCredentialsDetails, + type: string, + fakeCredentialsMap: IDataObject, +) => { + if (nodeCredentials && fakeCredentialsMap[JSON.stringify(nodeCredentials)]) { + return fakeCredentialsMap[JSON.stringify(nodeCredentials)] as ICredentialDataDecryptedObject; + } + + if (type && fakeCredentialsMap[type]) { + return fakeCredentialsMap[type] as ICredentialDataDecryptedObject; + } + + return {}; +}; + export class CredentialsHelper extends ICredentialsHelper { async authenticate( credentials: ICredentialDataDecryptedObject, @@ -57,7 +75,7 @@ export class CredentialsHelper extends ICredentialsHelper { nodeCredentials: INodeCredentialsDetails, type: string, ): Promise { - return {}; + return getFakeDecryptedCredentials(nodeCredentials, type, FAKE_CREDENTIALS_DATA); } async getCredentials( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8f46c209d0f6..a0deaf859f9cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1355,6 +1355,9 @@ importers: nodemailer: specifier: ^6.7.1 version: 6.8.0 + otpauth: + specifier: ^9.1.1 + version: 9.1.1 pdf-parse: specifier: ^1.1.1 version: 1.1.1 @@ -14691,6 +14694,10 @@ packages: verror: 1.10.0 dev: true + /jssha@3.3.0: + resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==} + dev: false + /jstransformer@1.0.0: resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} dependencies: @@ -16740,6 +16747,12 @@ packages: minimist: 1.2.7 dev: false + /otpauth@9.1.1: + resolution: {integrity: sha512-XhimxmkREwf6GJvV4svS9OVMFJ/qRGz+QBEGwtW5OMf9jZlx9yw25RZMXdrO6r7DHgfIaETJb1lucZXZtn3jgw==} + dependencies: + jssha: 3.3.0 + dev: false + /p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'}