feat(core): Allow credential reuse on HTTP Request node (#3228)

*  Create controller

*  Mount controller

* ✏️ Add error messages

*  Create scopes fetcher

*  Account for non-existent credential type

* 📘 Type scopes request

*  Adjust error message

* 🧪 Add tests

*  Introduce simple node versioning

*  Add example how to read version in node-code for custom logic

* 🐛 Fix setting of parameters

* 🐛 Fix another instance where it sets the wrong parameter

*  Remove unnecessary TOODs

*  Re-version HTTP Request node

* 👕 Satisfy linter

*  Retrieve node version

*  Undo Jan's changes to Set node

* 🧪 Fix CI/CD for `/oauth2-credential` tests (#3230)

* 🐛 Fix notice warning missing background color (#3231)

* 🐛 Check for generic auth in node cred types

*  Refactor credentials dropdown for HTTP Request node (#3222)

*  Discoverability flow (#3229)

*  Added node credentials type proxy. Changed node credentials input order.

*  Add computed property from versioning branch

* 🐛 Fix cred ref lost and unsaved

*  Make options consistent with cred type names

*  Use prop to set component order

*  Use constant and version

*  Fix rendering for generic auth creds

*  Mark as required on first selection

*  Implement discoverability flow

*  Mark as required on subsequent selections

*  Fix marking as required after cred deletion

*  Refactor to clean up

*  Detect position automatically

*  Add i18n to option label

*  Hide subtitle for custom action

*  Detect active credential type

*  Prop drilling to re-render select

* 🔥 Remove unneeded property

* ✏️ Rename arg

* 🔥 Remove unused import

* 🔥 Remove unneeded getters

* 🔥 Remove unused import

*  Generalize cred component positioning

*  Set up request

* 🐛 Fix edge case in endpoint

*  Display scopes alert box

*  Revert "Generalize cred comp positioning"

This reverts commit 75eea89273b854110fa6d1f96c7c1d78dd3b0731.

*  Consolidate HTTPRN check

*  Fix hue percentage to degree

* 🔥 Remove unused import

* 🔥 Remove unused import

* 🔥 Remove unused class

* 🔥 Remove unused import

* 📘 Create type for HTTPRN v2 auth params

* ✏️ Rename check

* 🔥 Remove unused import

* ✏️ Add i18n to `reportUnsetCredential()`

*  Refactor Alex's spacing changes

*  Post-merge fixes

*  Add docs link

* 🔥 Exclude Notion OAuth cred

* ✏️ Update copy

* ✏️ Rename param

* 🎨 Reposition notice and simplify styling

* ✏️ Update copy

* ✏️ Update copy

*  Hide params during custom action

*  Show notice if any cred type supported

* 🐛 Prevent scopes text overflow

* 🔥 Remove superfluous check

* ✏️ Break up docstring

* 🎨 Tweak notice styling

*  Reorder cred param in Webhook node

* ✏️ Shorten cred name in scopes notice

* 🧪 Update Notice snapshots

* 🐛 Fix check when `globalRole` is `undefined`

*  Revert 3f2c4a6

*  Apply feedback from Product

* 🧪 Update snapshot

*  Adjust regex expansion pattern for singular

* 🔥 Remove unused import

* 🔥 Remove logging

*  Make `somethingElse` key more unique

*  Move something else to constants

*  Consolidate notice component

*  Apply latest feedback

* 🧪 Update tests

* 🧪 Update snapshot

* ✏️ Fix singular version

* 🧪 Finalize tests

* ✏️ Rename constant

* 🧪 Expand tests

* 🔥 Remove `truncate` prop

* 🚚 Move scopes fetching to store

* 🚚 Move method to component

*  Use constant

*  Refactor `Notice` component

* 🧪 Update tests

* 🔥 Remove unused keys

*  Inject custom API call option

* 🔥 Remove unused props

* 🎨 Use `compact` prop

* 🧪 Update snapshots

* 🚚 Move scopes to store

* 🚚 Move `nodeCredentialTypes` to parent

* ✏️ Rename cred types per branding

* 🐛 Clear scopes when none

*  Add default

* 🚚 Move `newHttpRequestNodeCredentialType` to parent

* 🔥 Remove test data

*  Separate lines for readability

*  Change reference from node to node name

* ✏️ Rename i18n keys

*  Refactor OAuth check

* 🔥 Remove unused key

* 🚚 Move `OAuth1/2 API` to i18n

*  Refactor `skipCheck`

*  Add `stopPropagation` and `preventDefault`

* 🚚 Move active credential scopes logic to store

* 🎨 Fix spacing for `NodeWebhooks` component

*  Implement feedback

*  Update HTTPRN default and issue copy

* Refactor to use `CredentialsSelect` param (#3304)

*  Refactor into cred type param

*  Componentize scopes notice

* 🔥 Remove unused data

* 🔥 Remove unused `loadOptions`

*  Componentize `NodeCredentialType`

* 🐛 Fix param validation

* 🔥 Remove dup methods

*  Refactor all references to `isHttpRequestNodeV2`

* 🎨 Fix styling

* 🔥 Remove unused import

* 🔥 Remove unused properties

* 🎨 Fix spacing for Pipedrive Trigger node

* 🎨 Undo Webhook node styling change

* 🔥 Remove unused style

*  Cover `httpHeaderAuth` edge case

* 🐛 Fix `this.node` reference

* 🚚 Rename to `credentialsSelect`

* 🐛 Fix mistaken renaming

*  Set one attribute per line

*  Move condition to instantiation site

* 🚚 Rename prop

*  Refactor away `prepareScopesNotice`

* ✏️ Rename i18n keys

* ✏️ Update i18n calls

* ✏️ Add more i18n keys

* 🔥 Remove unused props

* ✏️ Add explanatory comment

*  Adjust check in `hasProxyAuth`

*  Refactor `credentialSelected` from prop to event

*  Eventify `valueChanged`, `setFocus`, `onBlur`

*  Eventify `optionSelected`

*  Add `noDataExpression`

* 🔥 Remove logging

* 🔥 Remove URL from scopes

*  Disregard expressions for display

* 🎨 Use CSS modules

* 📘 Tigthen interface

* 🐛 Fix generic auth display

* 🐛 Fix generic auth validation

* 📘 Loosen type

* 🚚 Move event params to end

*  Generalize reference

*  Refactor generic auth as `credentialsSelect` param

*  Restore check for `httpHeaderAuth `

* 🚚 Rename `existing` to `predefined`

* Extend metrics for HTTP Request node (#3282)

*  Extend metrics

* 🧪 Add tests

*  Update param names

Co-authored-by: Alex Grozav <alex@grozav.com>

*  Update check per new branch

*  Include generic auth check

*  Adjust telemetry (#3359)

*  Filter credential types by label

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Iván Ovejero
2022-05-24 11:36:19 +02:00
committed by GitHub
parent 0212d65dae
commit 336fc9e2a8
43 changed files with 1396 additions and 228 deletions

View File

@@ -276,6 +276,7 @@ export interface ICredentialType {
__overwrittenProperties?: string[];
authenticate?: IAuthenticate;
test?: ICredentialTestRequest;
genericAuth?: boolean;
}
export interface ICredentialTypes {
@@ -831,7 +832,8 @@ export type NodePropertyTypes =
| 'multiOptions'
| 'number'
| 'options'
| 'string';
| 'string'
| 'credentialsSelect';
export type CodeAutocompleteTypes = 'function' | 'functionItem';
@@ -861,8 +863,6 @@ export interface INodePropertyTypeOptions {
rows?: number; // Supported by: string
showAlpha?: boolean; // Supported by: color
sortable?: boolean; // Supported when "multipleValues" set to true
truncate?: boolean; // Supported by: notice
truncateAt?: number; // Supported by: notice
[key: string]: any;
}
@@ -890,6 +890,9 @@ export interface INodeProperties {
noDataExpression?: boolean;
required?: boolean;
routing?: INodePropertyRouting;
credentialTypes?: Array<
'extends:oAuth2Api' | 'extends:oAuth1Api' | 'has:authenticate' | 'has:genericAuth'
>;
}
export interface INodePropertyOptions {
name: string;
@@ -1434,9 +1437,14 @@ export interface INodeGraphItem {
type: string;
resource?: string;
operation?: string;
domain?: string;
domain?: string; // HTTP Request node v1
domain_base?: string; // HTTP Request node v2
domain_path?: string; // HTTP Request node v2
position: [number, number];
mode?: string;
credential_type?: string; // HTTP Request node v2
credential_set?: boolean; // HTTP Request node v2
method?: string; // HTTP Request node v2
}
export interface INodeNameIndex {

View File

@@ -60,6 +60,59 @@ function areOverlapping(
);
}
const URL_PARTS_REGEX = /(?<protocolPlusDomain>.*?\..*?)(?<pathname>\/.*)/;
export function getDomainBase(raw: string, urlParts = URL_PARTS_REGEX): string {
try {
const url = new URL(raw);
return [url.protocol, url.hostname].join('//');
} catch (_) {
const match = urlParts.exec(raw);
if (!match?.groups?.protocolPlusDomain) return '';
return match.groups.protocolPlusDomain;
}
}
function isSensitive(segment: string) {
if (/^v\d+$/.test(segment)) return false;
return /%40/.test(segment) || /\d/.test(segment) || /^[0-9A-F]{8}/i.test(segment);
}
export const ANONYMIZATION_CHARACTER = '*';
function sanitizeRoute(raw: string, check = isSensitive, char = ANONYMIZATION_CHARACTER) {
return raw
.split('/')
.map((segment) => (check(segment) ? char.repeat(segment.length) : segment))
.join('/');
}
/**
* Return pathname plus query string from URL, anonymizing IDs in route and query params.
*/
export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string {
try {
const url = new URL(raw);
if (!url.hostname) throw new Error('Malformed URL');
return sanitizeRoute(url.pathname);
} catch (_) {
const match = urlParts.exec(raw);
if (!match?.groups?.pathname) return '';
// discard query string
const route = match.groups.pathname.split('?').shift() as string;
return sanitizeRoute(route);
}
}
export function generateNodesGraph(
workflow: IWorkflowBase,
nodeTypes: INodeTypes,
@@ -100,12 +153,30 @@ export function generateNodesGraph(
position: node.position,
};
if (node.type === 'n8n-nodes-base.httpRequest') {
if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) {
try {
nodeItem.domain = new URL(node.parameters.url as string).hostname;
} catch (e) {
nodeItem.domain = node.parameters.url as string;
} catch (_) {
nodeItem.domain = getDomainBase(node.parameters.url as string);
}
} else if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 2) {
const { authentication } = node.parameters as { authentication: string };
nodeItem.credential_type = {
none: 'none',
genericCredentialType: node.parameters.genericAuthType as string,
existingCredentialType: node.parameters.nodeCredentialType as string,
}[authentication];
nodeItem.credential_set = node.credentials
? Object.keys(node.credentials).length > 0
: false;
const { url } = node.parameters as { url: string };
nodeItem.domain_base = getDomainBase(url);
nodeItem.domain_path = getDomainPath(url);
nodeItem.method = node.parameters.requestMethod as string;
} else {
const nodeType = nodeTypes.getByNameAndVersion(node.type);

View File

@@ -0,0 +1,191 @@
import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import {
ANONYMIZATION_CHARACTER as CHAR,
getDomainBase,
getDomainPath,
} from '../src/TelemetryHelpers';
describe('getDomainBase should return protocol plus domain', () => {
test('in valid URLs', () => {
for (const url of validUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
});
describe('getDomainPath should return pathname, excluding query string', () => {
describe('anonymizing strings containing at least one number', () => {
test('in valid URLs', () => {
for (const url of validUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing UUIDs', () => {
test('in valid URLs', () => {
for (const url of uuidUrls(validUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of uuidUrls(malformedUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing emails', () => {
test('in valid URLs', () => {
for (const url of validUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
});
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `https://test.com/api/v1/users/${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
];
}
function malformedUrls(idMaker: typeof numericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `htp://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'htp://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
{
full: `test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
];
}
const email = () => encodeURIComponent('test@test.com');
function uuidUrls(
urlsMaker: typeof validUrls | typeof malformedUrls,
baseName = 'test',
namespaceUuid = uuidv4(),
) {
return [
...urlsMaker(() => uuidv5(baseName, namespaceUuid)),
...urlsMaker(uuidv4),
...urlsMaker(() => uuidv3(baseName, namespaceUuid)),
...urlsMaker(uuidv1),
];
}
function digit() {
return Math.floor(Math.random() * 10);
}
function positiveDigit(): number {
const d = digit();
return d === 0 ? positiveDigit() : d;
}
function numericId(length = positiveDigit()) {
return Array.from({ length }, digit).join('');
}
function alphanumericId() {
return chooseRandomly([
`john${numericId()}`,
`title${numericId(1)}`,
numericId(),
]);
}
const chooseRandomly = <T>(array: T[]) => array[Math.floor(Math.random() * array.length)];