mirror of
https://github.com/n8n-io/n8n.git
synced 2024-08-16 16:40:30 +03:00
feat(core): Replace client-oauth2 with an in-repo package (#6266)
Co-authored-by: Marcus <marcus@n8n.io>
This commit is contained in:
parent
16fade7d41
commit
a1b1f24ddf
@ -27,7 +27,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build nodes-base
|
||||
run: pnpm --filter n8n-workflow --filter=n8n-core --filter=n8n-nodes-base build
|
||||
run: pnpm --filter @n8n/client-oauth2 --filter n8n-workflow --filter n8n-core --filter n8n-nodes-base build
|
||||
|
||||
- name: Test URLS
|
||||
run: node scripts/validate-docs-links.js
|
||||
|
2
.github/workflows/ci-master.yml
vendored
2
.github/workflows/ci-master.yml
vendored
@ -38,7 +38,7 @@ jobs:
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml
|
||||
files: packages/@n8n/client-oauth2/coverage/cobertura-coverage.xml,packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml
|
||||
|
||||
- name: Lint
|
||||
env:
|
||||
|
2
.github/workflows/ci-postgres-mysql.yml
vendored
2
.github/workflows/ci-postgres-mysql.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
compose-file: ./.github/docker-compose.yml
|
||||
|
||||
- name: Build Core, Workflow, and CLI
|
||||
run: pnpm --filter n8n-workflow --filter=n8n-core --filter=n8n build
|
||||
run: pnpm --filter @n8n/client-oauth2 --filter n8n-workflow --filter n8n-core --filter n8n build
|
||||
|
||||
- name: Test MySQL
|
||||
working-directory: packages/cli
|
||||
|
2
.github/workflows/ci-pull-requests.yml
vendored
2
.github/workflows/ci-pull-requests.yml
vendored
@ -70,7 +70,7 @@ jobs:
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml
|
||||
files: packages/@n8n/client-oauth2/coverage/cobertura-coverage.xml,packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml
|
||||
|
||||
lint:
|
||||
name: Lint changes
|
||||
|
2
.github/workflows/test-workflows.yml
vendored
2
.github/workflows/test-workflows.yml
vendored
@ -49,7 +49,7 @@ jobs:
|
||||
working-directory: n8n
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm --filter @n8n/client-oauth2 --filter n8n-workflow --filter n8n-core --filter n8n-nodes-base --filter n8n build
|
||||
shell: bash
|
||||
|
||||
- name: Import credentials
|
||||
|
14
packages/@n8n/client-oauth2/.eslintrc.js
Normal file
14
packages/@n8n/client-oauth2/.eslintrc.js
Normal file
@ -0,0 +1,14 @@
|
||||
const { sharedOptions } = require('@n8n_io/eslint-config/shared');
|
||||
|
||||
/**
|
||||
* @type {import('@types/eslint').ESLint.ConfigData}
|
||||
*/
|
||||
module.exports = {
|
||||
extends: ['@n8n_io/eslint-config/base'],
|
||||
|
||||
...sharedOptions(__dirname),
|
||||
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
},
|
||||
};
|
2
packages/@n8n/client-oauth2/jest.config.js
Normal file
2
packages/@n8n/client-oauth2/jest.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
25
packages/@n8n/client-oauth2/package.json
Normal file
25
packages/@n8n/client-oauth2/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"typecheck": "tsc",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "prettier --write . --ignore-path ../../../.prettierignore",
|
||||
"lint": "eslint --quiet .",
|
||||
"lintfix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "jest",
|
||||
"test:dev": "jest --watch"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1"
|
||||
}
|
||||
}
|
112
packages/@n8n/client-oauth2/src/ClientOAuth2.ts
Normal file
112
packages/@n8n/client-oauth2/src/ClientOAuth2.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as qs from 'querystring';
|
||||
import axios from 'axios';
|
||||
import { getAuthError } from './utils';
|
||||
import type { ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||
import { ClientOAuth2Token } from './ClientOAuth2Token';
|
||||
import { CodeFlow } from './CodeFlow';
|
||||
import { CredentialsFlow } from './CredentialsFlow';
|
||||
import type { Headers, Query } from './types';
|
||||
|
||||
export interface ClientOAuth2RequestObject {
|
||||
url: string;
|
||||
method: 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT';
|
||||
body?: Record<string, any>;
|
||||
query?: Query;
|
||||
headers?: Headers;
|
||||
}
|
||||
|
||||
export interface ClientOAuth2Options {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
accessTokenUri: string;
|
||||
authorizationUri?: string;
|
||||
redirectUri?: string;
|
||||
scopes?: string[];
|
||||
authorizationGrants?: string[];
|
||||
state?: string;
|
||||
body?: Record<string, any>;
|
||||
query?: Query;
|
||||
headers?: Headers;
|
||||
}
|
||||
|
||||
class ResponseError extends Error {
|
||||
constructor(readonly status: number, readonly body: object, readonly code = 'ESTATUS') {
|
||||
super(`HTTP status ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an object that can handle the multiple OAuth 2.0 flows.
|
||||
*/
|
||||
export class ClientOAuth2 {
|
||||
code: CodeFlow;
|
||||
|
||||
credentials: CredentialsFlow;
|
||||
|
||||
constructor(readonly options: ClientOAuth2Options) {
|
||||
this.code = new CodeFlow(this);
|
||||
this.credentials = new CredentialsFlow(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new token from existing data.
|
||||
*/
|
||||
createToken(data: ClientOAuth2TokenData, type?: string): ClientOAuth2Token {
|
||||
return new ClientOAuth2Token(this, {
|
||||
...data,
|
||||
...(typeof type === 'string' ? { token_type: type } : type),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse response body as JSON, fall back to parsing as a query string.
|
||||
*/
|
||||
private parseResponseBody<T extends object>(body: string): T {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch (e) {
|
||||
return qs.parse(body) as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Using the built-in request method, we'll automatically attempt to parse
|
||||
* the response.
|
||||
*/
|
||||
async request<T extends object>(options: ClientOAuth2RequestObject): Promise<T> {
|
||||
let url = options.url;
|
||||
const query = qs.stringify(options.query);
|
||||
|
||||
if (query) {
|
||||
url += (url.indexOf('?') === -1 ? '?' : '&') + query;
|
||||
}
|
||||
|
||||
const response = await axios.request({
|
||||
url,
|
||||
method: options.method,
|
||||
data: qs.stringify(options.body),
|
||||
headers: options.headers,
|
||||
transformResponse: (res) => res,
|
||||
// Axios rejects the promise by default for all status codes 4xx.
|
||||
// We override this to reject promises only on 5xxs
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
|
||||
const body = this.parseResponseBody<T>(response.data);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const authErr = getAuthError(body);
|
||||
if (authErr) throw authErr;
|
||||
|
||||
if (response.status < 200 || response.status >= 399)
|
||||
throw new ResponseError(response.status, response.data);
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
100
packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts
Normal file
100
packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
|
||||
import { auth, getRequestOptions } from './utils';
|
||||
import { DEFAULT_HEADERS } from './constants';
|
||||
|
||||
export interface ClientOAuth2TokenData extends Record<string, string | undefined> {
|
||||
token_type?: string | undefined;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in?: string;
|
||||
scope?: string | undefined;
|
||||
}
|
||||
/**
|
||||
* General purpose client token generator.
|
||||
*/
|
||||
export class ClientOAuth2Token {
|
||||
readonly tokenType?: string;
|
||||
|
||||
readonly accessToken: string;
|
||||
|
||||
readonly refreshToken: string;
|
||||
|
||||
private expires: Date;
|
||||
|
||||
constructor(readonly client: ClientOAuth2, readonly data: ClientOAuth2TokenData) {
|
||||
this.tokenType = data.token_type?.toLowerCase() ?? 'bearer';
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
|
||||
this.expires = new Date();
|
||||
this.expires.setSeconds(this.expires.getSeconds() + Number(data.expires_in));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a standardized request object with user authentication information.
|
||||
*/
|
||||
sign(requestObject: ClientOAuth2RequestObject): ClientOAuth2RequestObject {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('Unable to sign without access token');
|
||||
}
|
||||
|
||||
requestObject.headers = requestObject.headers ?? {};
|
||||
|
||||
if (this.tokenType === 'bearer') {
|
||||
requestObject.headers.Authorization = 'Bearer ' + this.accessToken;
|
||||
} else {
|
||||
const parts = requestObject.url.split('#');
|
||||
const token = 'access_token=' + this.accessToken;
|
||||
const url = parts[0].replace(/[?&]access_token=[^&#]/, '');
|
||||
const fragment = parts[1] ? '#' + parts[1] : '';
|
||||
|
||||
// Prepend the correct query string parameter to the url.
|
||||
requestObject.url = url + (url.indexOf('?') > -1 ? '&' : '?') + token + fragment;
|
||||
|
||||
// Attempt to avoid storing the url in proxies, since the access token
|
||||
// is exposed in the query parameters.
|
||||
requestObject.headers.Pragma = 'no-store';
|
||||
requestObject.headers['Cache-Control'] = 'no-store';
|
||||
}
|
||||
|
||||
return requestObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user access token with the supplied token.
|
||||
*/
|
||||
async refresh(opts?: ClientOAuth2Options): Promise<ClientOAuth2Token> {
|
||||
const options = { ...this.client.options, ...opts };
|
||||
|
||||
if (!this.refreshToken) throw new Error('No refresh token');
|
||||
|
||||
const requestOptions = getRequestOptions(
|
||||
{
|
||||
url: options.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: auth(options.clientId, options.clientSecret),
|
||||
},
|
||||
body: {
|
||||
refresh_token: this.refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.request<ClientOAuth2TokenData>(requestOptions);
|
||||
return this.client.createToken({ ...this.data, ...responseData });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the token has expired.
|
||||
*/
|
||||
expired(): boolean {
|
||||
return Date.now() > this.expires.getTime();
|
||||
}
|
||||
}
|
121
packages/@n8n/client-oauth2/src/CodeFlow.ts
Normal file
121
packages/@n8n/client-oauth2/src/CodeFlow.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import * as qs from 'querystring';
|
||||
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
||||
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||
import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants';
|
||||
import { auth, expects, getAuthError, getRequestOptions, sanitizeScope } from './utils';
|
||||
|
||||
interface CodeFlowBody {
|
||||
code: string | string[];
|
||||
grant_type: 'authorization_code';
|
||||
redirect_uri?: string;
|
||||
client_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support authorization code OAuth 2.0 grant.
|
||||
*
|
||||
* Reference: http://tools.ietf.org/html/rfc6749#section-4.1
|
||||
*/
|
||||
export class CodeFlow {
|
||||
constructor(private client: ClientOAuth2) {}
|
||||
|
||||
/**
|
||||
* Generate the uri for doing the first redirect.
|
||||
*/
|
||||
getUri(opts?: ClientOAuth2Options): string {
|
||||
const options = { ...this.client.options, ...opts };
|
||||
|
||||
// Check the required parameters are set.
|
||||
expects(options, 'clientId', 'authorizationUri');
|
||||
|
||||
const query: Record<string, string | undefined> = {
|
||||
client_id: options.clientId,
|
||||
redirect_uri: options.redirectUri,
|
||||
response_type: 'code',
|
||||
state: options.state,
|
||||
};
|
||||
if (options.scopes !== undefined) {
|
||||
query.scope = sanitizeScope(options.scopes);
|
||||
}
|
||||
|
||||
if (options.authorizationUri) {
|
||||
const sep = options.authorizationUri.includes('?') ? '&' : '?';
|
||||
return options.authorizationUri + sep + qs.stringify({ ...query, ...options.query });
|
||||
}
|
||||
throw new TypeError('Missing authorization uri, unable to get redirect uri');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the code token from the redirected uri and make another request for
|
||||
* the user access token.
|
||||
*/
|
||||
async getToken(
|
||||
uri: string | URL,
|
||||
opts?: Partial<ClientOAuth2Options>,
|
||||
): Promise<ClientOAuth2Token> {
|
||||
const options = { ...this.client.options, ...opts };
|
||||
|
||||
expects(options, 'clientId', 'accessTokenUri');
|
||||
|
||||
const url = uri instanceof URL ? uri : new URL(uri, DEFAULT_URL_BASE);
|
||||
if (
|
||||
typeof options.redirectUri === 'string' &&
|
||||
typeof url.pathname === 'string' &&
|
||||
url.pathname !== new URL(options.redirectUri, DEFAULT_URL_BASE).pathname
|
||||
) {
|
||||
throw new TypeError('Redirected path should match configured path, but got: ' + url.pathname);
|
||||
}
|
||||
|
||||
if (!url.search?.substring(1)) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new TypeError(`Unable to process uri: ${uri.toString()}`);
|
||||
}
|
||||
|
||||
const data =
|
||||
typeof url.search === 'string' ? qs.parse(url.search.substring(1)) : url.search || {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const error = getAuthError(data);
|
||||
if (error) throw error;
|
||||
|
||||
if (options.state && data.state !== options.state) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new TypeError(`Invalid state: ${data.state}`);
|
||||
}
|
||||
|
||||
// Check whether the response code is set.
|
||||
if (!data.code) {
|
||||
throw new TypeError('Missing code, unable to request token');
|
||||
}
|
||||
|
||||
const headers = { ...DEFAULT_HEADERS };
|
||||
const body: CodeFlowBody = {
|
||||
code: data.code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: options.redirectUri,
|
||||
};
|
||||
|
||||
// `client_id`: REQUIRED, if the client is not authenticating with the
|
||||
// authorization server as described in Section 3.2.1.
|
||||
// Reference: https://tools.ietf.org/html/rfc6749#section-3.2.1
|
||||
if (options.clientSecret) {
|
||||
headers.Authorization = auth(options.clientId, options.clientSecret);
|
||||
} else {
|
||||
body.client_id = options.clientId;
|
||||
}
|
||||
|
||||
const requestOptions = getRequestOptions(
|
||||
{
|
||||
url: options.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.request<ClientOAuth2TokenData>(requestOptions);
|
||||
return this.client.createToken(responseData);
|
||||
}
|
||||
}
|
52
packages/@n8n/client-oauth2/src/CredentialsFlow.ts
Normal file
52
packages/@n8n/client-oauth2/src/CredentialsFlow.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2';
|
||||
import type { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
||||
import { DEFAULT_HEADERS } from './constants';
|
||||
import { auth, expects, getRequestOptions, sanitizeScope } from './utils';
|
||||
|
||||
interface CredentialsFlowBody {
|
||||
grant_type: 'client_credentials';
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support client credentials OAuth 2.0 grant.
|
||||
*
|
||||
* Reference: http://tools.ietf.org/html/rfc6749#section-4.4
|
||||
*/
|
||||
export class CredentialsFlow {
|
||||
constructor(private client: ClientOAuth2) {}
|
||||
|
||||
/**
|
||||
* Request an access token using the client credentials.
|
||||
*/
|
||||
async getToken(opts?: Partial<ClientOAuth2Options>): Promise<ClientOAuth2Token> {
|
||||
const options = { ...this.client.options, ...opts };
|
||||
|
||||
expects(options, 'clientId', 'clientSecret', 'accessTokenUri');
|
||||
|
||||
const body: CredentialsFlowBody = {
|
||||
grant_type: 'client_credentials',
|
||||
};
|
||||
|
||||
if (options.scopes !== undefined) {
|
||||
body.scope = sanitizeScope(options.scopes);
|
||||
}
|
||||
|
||||
const requestOptions = getRequestOptions(
|
||||
{
|
||||
url: options.accessTokenUri,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Authorization: auth(options.clientId, options.clientSecret),
|
||||
},
|
||||
body,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const responseData = await this.client.request<ClientOAuth2TokenData>(requestOptions);
|
||||
return this.client.createToken(responseData);
|
||||
}
|
||||
}
|
63
packages/@n8n/client-oauth2/src/constants.ts
Normal file
63
packages/@n8n/client-oauth2/src/constants.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { Headers } from './types';
|
||||
|
||||
export const DEFAULT_URL_BASE = 'https://example.org/';
|
||||
|
||||
/**
|
||||
* Default headers for executing OAuth 2.0 flows.
|
||||
*/
|
||||
export const DEFAULT_HEADERS: Headers = {
|
||||
Accept: 'application/json, application/x-www-form-urlencoded',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format error response types to regular strings for displaying to clients.
|
||||
*
|
||||
* Reference: http://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
*/
|
||||
export const ERROR_RESPONSES: Record<string, string> = {
|
||||
invalid_request: [
|
||||
'The request is missing a required parameter, includes an',
|
||||
'invalid parameter value, includes a parameter more than',
|
||||
'once, or is otherwise malformed.',
|
||||
].join(' '),
|
||||
invalid_client: [
|
||||
'Client authentication failed (e.g., unknown client, no',
|
||||
'client authentication included, or unsupported',
|
||||
'authentication method).',
|
||||
].join(' '),
|
||||
invalid_grant: [
|
||||
'The provided authorization grant (e.g., authorization',
|
||||
'code, resource owner credentials) or refresh token is',
|
||||
'invalid, expired, revoked, does not match the redirection',
|
||||
'URI used in the authorization request, or was issued to',
|
||||
'another client.',
|
||||
].join(' '),
|
||||
unauthorized_client: [
|
||||
'The client is not authorized to request an authorization',
|
||||
'code using this method.',
|
||||
].join(' '),
|
||||
unsupported_grant_type: [
|
||||
'The authorization grant type is not supported by the',
|
||||
'authorization server.',
|
||||
].join(' '),
|
||||
access_denied: ['The resource owner or authorization server denied the request.'].join(' '),
|
||||
unsupported_response_type: [
|
||||
'The authorization server does not support obtaining',
|
||||
'an authorization code using this method.',
|
||||
].join(' '),
|
||||
invalid_scope: ['The requested scope is invalid, unknown, or malformed.'].join(' '),
|
||||
server_error: [
|
||||
'The authorization server encountered an unexpected',
|
||||
'condition that prevented it from fulfilling the request.',
|
||||
'(This error code is needed because a 500 Internal Server',
|
||||
'Error HTTP status code cannot be returned to the client',
|
||||
'via an HTTP redirect.)',
|
||||
].join(' '),
|
||||
temporarily_unavailable: [
|
||||
'The authorization server is currently unable to handle',
|
||||
'the request due to a temporary overloading or maintenance',
|
||||
'of the server.',
|
||||
].join(' '),
|
||||
};
|
2
packages/@n8n/client-oauth2/src/index.ts
Normal file
2
packages/@n8n/client-oauth2/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
|
||||
export { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';
|
2
packages/@n8n/client-oauth2/src/types.ts
Normal file
2
packages/@n8n/client-oauth2/src/types.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type Headers = Record<string, string | string[]>;
|
||||
export type Query = Record<string, string | string[]>;
|
83
packages/@n8n/client-oauth2/src/utils.ts
Normal file
83
packages/@n8n/client-oauth2/src/utils.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ClientOAuth2RequestObject } from './ClientOAuth2';
|
||||
import { ERROR_RESPONSES } from './constants';
|
||||
|
||||
/**
|
||||
* Check if properties exist on an object and throw when they aren't.
|
||||
*/
|
||||
export function expects(obj: any, ...args: any[]) {
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const prop = args[i];
|
||||
if (obj[prop] === null) {
|
||||
throw new TypeError('Expected "' + prop + '" to exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(message: string, readonly body: any, readonly code = 'EAUTH') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an authentication error from the response data.
|
||||
*/
|
||||
export function getAuthError(body: {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}): Error | undefined {
|
||||
const message: string | undefined =
|
||||
ERROR_RESPONSES[body.error] ?? body.error_description ?? body.error;
|
||||
|
||||
if (message) {
|
||||
return new AuthError(message, body);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a value is a string.
|
||||
*/
|
||||
function toString(str: string | null | undefined) {
|
||||
return str === null ? '' : String(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the scopes option to be a string.
|
||||
*/
|
||||
export function sanitizeScope(scopes: string[] | string): string {
|
||||
return Array.isArray(scopes) ? scopes.join(' ') : toString(scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create basic auth header.
|
||||
*/
|
||||
export function auth(username: string, password: string): string {
|
||||
return 'Basic ' + Buffer.from(toString(username) + ':' + toString(password)).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge request options from an options object.
|
||||
*/
|
||||
export function getRequestOptions(
|
||||
{ url, method, body, query, headers }: ClientOAuth2RequestObject,
|
||||
options: any,
|
||||
): ClientOAuth2RequestObject {
|
||||
const rOptions = {
|
||||
url,
|
||||
method,
|
||||
body: { ...body, ...options.body },
|
||||
query: { ...query, ...options.query },
|
||||
headers: { ...headers, ...options.headers },
|
||||
};
|
||||
// if request authorization was overridden delete it from header
|
||||
if (rOptions.headers.Authorization === '') {
|
||||
delete rOptions.headers.Authorization;
|
||||
}
|
||||
return rOptions;
|
||||
}
|
189
packages/@n8n/client-oauth2/test/CodeFlow.test.ts
Normal file
189
packages/@n8n/client-oauth2/test/CodeFlow.test.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import nock from 'nock';
|
||||
import { ClientOAuth2, ClientOAuth2Token } from '../src';
|
||||
import * as config from './config';
|
||||
import { AuthError } from '@/utils';
|
||||
|
||||
describe('CodeFlow', () => {
|
||||
beforeAll(async () => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
const uri = `/auth/callback?code=${config.code}&state=${config.state}`;
|
||||
|
||||
const githubAuth = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: ['notifications'],
|
||||
});
|
||||
|
||||
describe('#getUri', () => {
|
||||
it('should return a valid uri', () => {
|
||||
expect(githubAuth.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&state=&scope=notifications',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when scopes are undefined', () => {
|
||||
it('should not include scope in the uri', () => {
|
||||
const authWithoutScopes = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
});
|
||||
expect(authWithoutScopes.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&state=',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include empty scopes array as an empty string', () => {
|
||||
const authWithEmptyScopes = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: [],
|
||||
});
|
||||
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&state=&scope=',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include empty scopes string as an empty string', () => {
|
||||
const authWithEmptyScopes = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: config.authorizationUri,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: [],
|
||||
});
|
||||
expect(authWithEmptyScopes.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&state=&scope=',
|
||||
);
|
||||
});
|
||||
|
||||
describe('when authorizationUri contains query parameters', () => {
|
||||
it('should preserve query string parameters', () => {
|
||||
const authWithParams = new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationUri: `${config.authorizationUri}?bar=qux`,
|
||||
authorizationGrants: ['code'],
|
||||
redirectUri: config.redirectUri,
|
||||
scopes: ['notifications'],
|
||||
});
|
||||
expect(authWithParams.code.getUri()).toEqual(
|
||||
`${config.authorizationUri}?bar=qux&client_id=abc&` +
|
||||
`redirect_uri=${encodeURIComponent(config.redirectUri)}&` +
|
||||
'response_type=code&state=&scope=notifications',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getToken', () => {
|
||||
const mockTokenCall = () =>
|
||||
nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ code, grant_type, redirect_uri }) =>
|
||||
code === config.code &&
|
||||
grant_type === 'authorization_code' &&
|
||||
redirect_uri === config.redirectUri,
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
});
|
||||
|
||||
it('should request the token', async () => {
|
||||
mockTokenCall();
|
||||
const user = await githubAuth.code.getToken(uri);
|
||||
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
});
|
||||
|
||||
it('should reject with auth errors', async () => {
|
||||
let errored = false;
|
||||
|
||||
try {
|
||||
await githubAuth.code.getToken(`${config.redirectUri}?error=invalid_request`);
|
||||
} catch (err) {
|
||||
errored = true;
|
||||
expect(err).toBeInstanceOf(AuthError);
|
||||
if (err instanceof AuthError) {
|
||||
expect(err.code).toEqual('EAUTH');
|
||||
expect(err.body.error).toEqual('invalid_request');
|
||||
}
|
||||
}
|
||||
expect(errored).toEqual(true);
|
||||
});
|
||||
|
||||
describe('#sign', () => {
|
||||
it('should be able to sign a standard request object', async () => {
|
||||
mockTokenCall();
|
||||
const token = await githubAuth.code.getToken(uri);
|
||||
const requestOptions = token.sign({
|
||||
method: 'GET',
|
||||
url: 'http://api.github.com/user',
|
||||
});
|
||||
expect(requestOptions.headers?.Authorization).toEqual(`Bearer ${config.accessToken}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh', () => {
|
||||
const mockRefreshCall = () =>
|
||||
nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ refresh_token, grant_type }) =>
|
||||
refresh_token === config.refreshToken && grant_type === 'refresh_token',
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.refreshedAccessToken,
|
||||
refresh_token: config.refreshedRefreshToken,
|
||||
});
|
||||
|
||||
it('should make a request to get a new access token', async () => {
|
||||
mockTokenCall();
|
||||
const token = await githubAuth.code.getToken(uri, { state: config.state });
|
||||
expect(token.refreshToken).toEqual(config.refreshToken);
|
||||
|
||||
mockRefreshCall();
|
||||
const token1 = await token.refresh();
|
||||
expect(token1).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(token1.refreshToken).toEqual(config.refreshedRefreshToken);
|
||||
expect(token1.tokenType).toEqual('bearer');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
116
packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts
Normal file
116
packages/@n8n/client-oauth2/test/CredentialsFlow.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import nock from 'nock';
|
||||
import { ClientOAuth2, ClientOAuth2Token } from '../src';
|
||||
import * as config from './config';
|
||||
|
||||
describe('CredentialsFlow', () => {
|
||||
beforeAll(async () => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
describe('#getToken', () => {
|
||||
const createAuthClient = (scopes?: string[]) =>
|
||||
new ClientOAuth2({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
accessTokenUri: config.accessTokenUri,
|
||||
authorizationGrants: ['credentials'],
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenCall = (requestedScope?: string) =>
|
||||
nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ scope, grant_type }) =>
|
||||
scope === requestedScope && grant_type === 'client_credentials',
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.accessToken,
|
||||
refresh_token: config.refreshToken,
|
||||
scope: requestedScope,
|
||||
});
|
||||
|
||||
it('should request the token', async () => {
|
||||
const authClient = createAuthClient(['notifications']);
|
||||
mockTokenCall('notifications');
|
||||
|
||||
const user = await authClient.credentials.getToken();
|
||||
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
expect(user.data.scope).toEqual('notifications');
|
||||
});
|
||||
|
||||
it('when scopes are undefined, it should not send scopes to an auth server', async () => {
|
||||
const authClient = createAuthClient();
|
||||
mockTokenCall();
|
||||
|
||||
const user = await authClient.credentials.getToken();
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
expect(user.data.scope).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('when scopes is an empty array, it should send empty scope string to an auth server', async () => {
|
||||
const authClient = createAuthClient([]);
|
||||
mockTokenCall('');
|
||||
|
||||
const user = await authClient.credentials.getToken();
|
||||
expect(user).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(user.accessToken).toEqual(config.accessToken);
|
||||
expect(user.tokenType).toEqual('bearer');
|
||||
expect(user.data.scope).toEqual('');
|
||||
});
|
||||
|
||||
describe('#sign', () => {
|
||||
it('should be able to sign a standard request object', async () => {
|
||||
const authClient = createAuthClient(['notifications']);
|
||||
mockTokenCall('notifications');
|
||||
|
||||
const token = await authClient.credentials.getToken();
|
||||
const requestOptions = token.sign({
|
||||
method: 'GET',
|
||||
url: `${config.baseUrl}/test`,
|
||||
});
|
||||
|
||||
expect(requestOptions.headers?.Authorization).toEqual(`Bearer ${config.accessToken}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh', () => {
|
||||
const mockRefreshCall = () =>
|
||||
nock(config.baseUrl)
|
||||
.post(
|
||||
'/login/oauth/access_token',
|
||||
({ refresh_token, grant_type }) =>
|
||||
refresh_token === config.refreshToken && grant_type === 'refresh_token',
|
||||
)
|
||||
.once()
|
||||
.reply(200, {
|
||||
access_token: config.refreshedAccessToken,
|
||||
refresh_token: config.refreshedRefreshToken,
|
||||
});
|
||||
|
||||
it('should make a request to get a new access token', async () => {
|
||||
const authClient = createAuthClient(['notifications']);
|
||||
mockTokenCall('notifications');
|
||||
|
||||
const token = await authClient.credentials.getToken();
|
||||
expect(token.accessToken).toEqual(config.accessToken);
|
||||
|
||||
mockRefreshCall();
|
||||
const token1 = await token.refresh();
|
||||
expect(token1).toBeInstanceOf(ClientOAuth2Token);
|
||||
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
|
||||
expect(token1.tokenType).toEqual('bearer');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
15
packages/@n8n/client-oauth2/test/config.ts
Normal file
15
packages/@n8n/client-oauth2/test/config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const baseUrl = 'https://mock.auth.service';
|
||||
export const accessTokenUri = baseUrl + '/login/oauth/access_token';
|
||||
export const authorizationUri = baseUrl + '/login/oauth/authorize';
|
||||
export const redirectUri = 'http://example.com/auth/callback';
|
||||
|
||||
export const accessToken = '4430eb1615fb6127cbf828a8e403';
|
||||
export const refreshToken = 'def456token';
|
||||
export const refreshedAccessToken = 'f456okeendt';
|
||||
export const refreshedRefreshToken = 'f4f6577c0f3af456okeendt';
|
||||
|
||||
export const clientId = 'abc';
|
||||
export const clientSecret = '123';
|
||||
|
||||
export const code = 'fbe55d970377e0686746';
|
||||
export const state = '7076840850058943';
|
12
packages/@n8n/client-oauth2/tsconfig.build.json
Normal file
12
packages/@n8n/client-oauth2/tsconfig.build.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"noEmit": false,
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["test/**"]
|
||||
}
|
15
packages/@n8n/client-oauth2/tsconfig.json
Normal file
15
packages/@n8n/client-oauth2/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
@ -117,6 +117,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n_io/license-sdk": "~2.4.0",
|
||||
"@n8n/client-oauth2": "workspace:*",
|
||||
"@oclif/command": "^1.8.16",
|
||||
"@oclif/core": "^1.16.4",
|
||||
"@oclif/errors": "^1.3.6",
|
||||
@ -133,7 +134,6 @@
|
||||
"change-case": "^4.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"client-oauth2": "^4.2.5",
|
||||
"compression": "^1.7.4",
|
||||
"connect-history-api-fallback": "^1.6.0",
|
||||
"convict": "^6.2.4",
|
||||
|
@ -1,4 +1,5 @@
|
||||
import ClientOAuth2 from 'client-oauth2';
|
||||
import type { ClientOAuth2Options } from '@n8n/client-oauth2';
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import Csrf from 'csrf';
|
||||
import express from 'express';
|
||||
import get from 'lodash.get';
|
||||
@ -119,7 +120,7 @@ oauth2CredentialController.get(
|
||||
};
|
||||
const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64');
|
||||
|
||||
const oAuthOptions: ClientOAuth2.Options = {
|
||||
const oAuthOptions: ClientOAuth2Options = {
|
||||
clientId: get(oauthCredentials, 'clientId') as string,
|
||||
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
||||
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
||||
@ -250,11 +251,11 @@ oauth2CredentialController.get(
|
||||
return renderCallbackError(res, errorMessage);
|
||||
}
|
||||
|
||||
let options = {};
|
||||
let options: Partial<ClientOAuth2Options> = {};
|
||||
|
||||
const oAuth2Parameters = {
|
||||
const oAuth2Parameters: ClientOAuth2Options = {
|
||||
clientId: get(oauthCredentials, 'clientId') as string,
|
||||
clientSecret: get(oauthCredentials, 'clientSecret', '') as string | undefined,
|
||||
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
|
||||
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
|
||||
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
|
||||
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
|
||||
@ -268,6 +269,7 @@ oauth2CredentialController.get(
|
||||
client_secret: get(oauthCredentials, 'clientSecret', '') as string,
|
||||
},
|
||||
};
|
||||
// @ts-ignore
|
||||
delete oAuth2Parameters.clientSecret;
|
||||
}
|
||||
|
||||
@ -278,7 +280,8 @@ oauth2CredentialController.get(
|
||||
const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');
|
||||
|
||||
const oauthToken = await oAuthObj.code.getToken(
|
||||
`${oAuth2Parameters.redirectUri}?${queryParameters}`,
|
||||
`${oAuth2Parameters.redirectUri as string}?${queryParameters}`,
|
||||
// @ts-ignore
|
||||
options,
|
||||
);
|
||||
|
||||
|
@ -21,6 +21,7 @@
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "src/sso/saml/saml-schema-metadata-2.0.xsd"],
|
||||
"references": [
|
||||
{ "path": "../workflow/tsconfig.build.json" },
|
||||
{ "path": "../core/tsconfig.build.json" }
|
||||
{ "path": "../core/tsconfig.build.json" },
|
||||
{ "path": "../@n8n/client-oauth2/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
|
@ -46,7 +46,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"client-oauth2": "^4.2.5",
|
||||
"@n8n/client-oauth2": "workspace:*",
|
||||
"concat-stream": "^2.0.0",
|
||||
"cron": "~1.7.2",
|
||||
"crypto-js": "~4.1.1",
|
||||
|
@ -82,7 +82,12 @@ import { IncomingMessage } from 'http';
|
||||
import { stringify } from 'qs';
|
||||
import type { Token } from 'oauth-1.0a';
|
||||
import clientOAuth1 from 'oauth-1.0a';
|
||||
import clientOAuth2 from 'client-oauth2';
|
||||
import type {
|
||||
ClientOAuth2Options,
|
||||
ClientOAuth2RequestObject,
|
||||
ClientOAuth2TokenData,
|
||||
} from '@n8n/client-oauth2';
|
||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import crypto, { createHmac } from 'crypto';
|
||||
import get from 'lodash.get';
|
||||
import type { Request, Response } from 'express';
|
||||
@ -1081,14 +1086,14 @@ export async function requestOAuth2(
|
||||
throw new Error('OAuth credentials not connected!');
|
||||
}
|
||||
|
||||
const oAuthClient = new clientOAuth2({
|
||||
const oAuthClient = new ClientOAuth2({
|
||||
clientId: credentials.clientId as string,
|
||||
clientSecret: credentials.clientSecret as string,
|
||||
accessTokenUri: credentials.accessTokenUrl as string,
|
||||
scopes: (credentials.scope as string).split(' '),
|
||||
});
|
||||
|
||||
let oauthTokenData = credentials.oauthTokenData as clientOAuth2.Data;
|
||||
let oauthTokenData = credentials.oauthTokenData as ClientOAuth2TokenData;
|
||||
|
||||
// if it's the first time using the credentials, get the access token and save it into the DB.
|
||||
if (
|
||||
@ -1116,15 +1121,20 @@ export async function requestOAuth2(
|
||||
oauthTokenData = data;
|
||||
}
|
||||
|
||||
const accessToken =
|
||||
get(oauthTokenData, oAuth2Options?.property as string) || oauthTokenData.accessToken;
|
||||
const refreshToken = oauthTokenData.refreshToken;
|
||||
const token = oAuthClient.createToken(
|
||||
get(oauthTokenData, oAuth2Options?.property as string) || oauthTokenData.accessToken,
|
||||
oauthTokenData.refreshToken,
|
||||
{
|
||||
...oauthTokenData,
|
||||
...(accessToken ? { access_token: accessToken } : {}),
|
||||
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
||||
},
|
||||
oAuth2Options?.tokenType || oauthTokenData.tokenType,
|
||||
oauthTokenData,
|
||||
);
|
||||
// Signs the request by adding authorization headers or query parameters depending
|
||||
// on the token-type used.
|
||||
const newRequestOptions = token.sign(requestOptions as clientOAuth2.RequestObject);
|
||||
const newRequestOptions = token.sign(requestOptions as ClientOAuth2RequestObject);
|
||||
const newRequestHeaders = (newRequestOptions.headers = newRequestOptions.headers ?? {});
|
||||
// If keep bearer is false remove the it from the authorization header
|
||||
if (oAuth2Options?.keepBearer === false && typeof newRequestHeaders.Authorization === 'string') {
|
||||
@ -1164,7 +1174,7 @@ export async function requestOAuth2(
|
||||
if (OAuth2GrantType.clientCredentials === credentials.grantType) {
|
||||
newToken = await getClientCredentialsToken(token.client, credentials);
|
||||
} else {
|
||||
newToken = await token.refresh(tokenRefreshOptions);
|
||||
newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
|
||||
}
|
||||
|
||||
Logger.debug(
|
||||
@ -1184,7 +1194,7 @@ export async function requestOAuth2(
|
||||
credentialsType,
|
||||
credentials,
|
||||
);
|
||||
const refreshedRequestOption = newToken.sign(requestOptions as clientOAuth2.RequestObject);
|
||||
const refreshedRequestOption = newToken.sign(requestOptions as ClientOAuth2RequestObject);
|
||||
|
||||
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
||||
Object.assign(newRequestHeaders, {
|
||||
@ -1197,6 +1207,11 @@ export async function requestOAuth2(
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
const tokenExpiredStatusCode =
|
||||
oAuth2Options?.tokenExpiredStatusCode === undefined
|
||||
? 401
|
||||
: oAuth2Options?.tokenExpiredStatusCode;
|
||||
|
||||
return this.helpers
|
||||
.request(newRequestOptions)
|
||||
.then((response) => {
|
||||
@ -1204,21 +1219,14 @@ export async function requestOAuth2(
|
||||
if (
|
||||
requestOptions.resolveWithFullResponse === true &&
|
||||
requestOptions.simple === false &&
|
||||
response.statusCode ===
|
||||
(oAuth2Options?.tokenExpiredStatusCode === undefined
|
||||
? 401
|
||||
: oAuth2Options?.tokenExpiredStatusCode)
|
||||
response.statusCode === tokenExpiredStatusCode
|
||||
) {
|
||||
throw response;
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async (error: IResponseError) => {
|
||||
const statusCodeReturned =
|
||||
oAuth2Options?.tokenExpiredStatusCode === undefined
|
||||
? 401
|
||||
: oAuth2Options?.tokenExpiredStatusCode;
|
||||
if (error.statusCode === statusCodeReturned) {
|
||||
if (error.statusCode === tokenExpiredStatusCode) {
|
||||
// Token is probably not valid anymore. So try refresh it.
|
||||
const tokenRefreshOptions: IDataObject = {};
|
||||
if (oAuth2Options?.includeCredentialsOnRefreshOnBody) {
|
||||
@ -1243,7 +1251,7 @@ export async function requestOAuth2(
|
||||
if (OAuth2GrantType.clientCredentials === credentials.grantType) {
|
||||
newToken = await getClientCredentialsToken(token.client, credentials);
|
||||
} else {
|
||||
newToken = await token.refresh(tokenRefreshOptions);
|
||||
newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
|
||||
}
|
||||
Logger.debug(
|
||||
`OAuth2 token for "${credentialsType}" used by node "${node.name}" has been renewed.`,
|
||||
@ -1271,7 +1279,7 @@ export async function requestOAuth2(
|
||||
);
|
||||
|
||||
// Make the request again with the new token
|
||||
const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject);
|
||||
const newRequestOptions = newToken.sign(requestOptions as ClientOAuth2RequestObject);
|
||||
newRequestOptions.headers = newRequestOptions.headers ?? {};
|
||||
|
||||
if (oAuth2Options?.keyToIncludeInAccessTokenHeader) {
|
||||
|
@ -1,15 +1,15 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import type clientOAuth2 from 'client-oauth2';
|
||||
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2Token } from '@n8n/client-oauth2';
|
||||
|
||||
export const getClientCredentialsToken = async (
|
||||
oAuth2Client: clientOAuth2,
|
||||
oAuth2Client: ClientOAuth2,
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
): Promise<clientOAuth2.Token> => {
|
||||
): Promise<ClientOAuth2Token> => {
|
||||
const options = {};
|
||||
if (credentials.authentication === 'body') {
|
||||
Object.assign(options, {
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Authorization: '',
|
||||
},
|
||||
body: {
|
||||
@ -18,5 +18,5 @@ export const getClientCredentialsToken = async (
|
||||
},
|
||||
});
|
||||
}
|
||||
return oAuth2Client.credentials.getToken(options);
|
||||
return oAuth2Client.credentials.getToken(options as ClientOAuth2Options);
|
||||
};
|
||||
|
@ -14,5 +14,8 @@
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"references": [{ "path": "../workflow/tsconfig.build.json" }]
|
||||
"references": [
|
||||
{ "path": "../workflow/tsconfig.build.json" },
|
||||
{ "path": "../@n8n/client-oauth2/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
|
139
pnpm-lock.yaml
139
pnpm-lock.yaml
@ -137,6 +137,12 @@ importers:
|
||||
specifier: ^1.0.24
|
||||
version: 1.0.24(typescript@5.0.3)
|
||||
|
||||
packages/@n8n/client-oauth2:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^0.21.1
|
||||
version: 0.21.4(debug@4.3.2)
|
||||
|
||||
packages/@n8n_io/eslint-config:
|
||||
devDependencies:
|
||||
'@types/eslint':
|
||||
@ -187,6 +193,9 @@ importers:
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@n8n/client-oauth2':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/client-oauth2
|
||||
'@n8n_io/license-sdk':
|
||||
specifier: ~2.4.0
|
||||
version: 2.4.0
|
||||
@ -238,9 +247,6 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0
|
||||
client-oauth2:
|
||||
specifier: ^4.2.5
|
||||
version: 4.3.3
|
||||
compression:
|
||||
specifier: ^1.7.4
|
||||
version: 1.7.4
|
||||
@ -626,12 +632,12 @@ importers:
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
'@n8n/client-oauth2':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/client-oauth2
|
||||
axios:
|
||||
specifier: ^0.21.1
|
||||
version: 0.21.4(debug@4.3.2)
|
||||
client-oauth2:
|
||||
specifier: ^4.2.5
|
||||
version: 4.3.3
|
||||
concat-stream:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@ -5970,10 +5976,6 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@servie/events@1.0.0:
|
||||
resolution: {integrity: sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw==}
|
||||
dev: false
|
||||
|
||||
/@sideway/address@4.1.4:
|
||||
resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==}
|
||||
dependencies:
|
||||
@ -8165,10 +8167,6 @@ packages:
|
||||
resolution: {integrity: sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA==}
|
||||
dev: true
|
||||
|
||||
/@types/tough-cookie@2.3.8:
|
||||
resolution: {integrity: sha512-7axfYN8SW9pWg78NgenHasSproWQee5rzyPVLC9HpaQSDgNArsnKJD88EaMfi4Pl48AyciO3agYCFqpHS1gLpg==}
|
||||
dev: false
|
||||
|
||||
/@types/tough-cookie@4.0.2:
|
||||
resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==}
|
||||
dev: true
|
||||
@ -10097,10 +10095,6 @@ packages:
|
||||
streamsearch: 1.1.0
|
||||
dev: false
|
||||
|
||||
/byte-length@1.0.2:
|
||||
resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==}
|
||||
dev: false
|
||||
|
||||
/bytes@3.0.0:
|
||||
resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -10543,14 +10537,6 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
dev: false
|
||||
|
||||
/client-oauth2@4.3.3:
|
||||
resolution: {integrity: sha512-k8AvUYJon0vv75ufoVo4nALYb/qwFFicO3I0+39C6xEdflqVtr+f9cy+0ZxAduoVSTfhP5DX2tY2XICAd5hy6Q==}
|
||||
engines: {node: '>=4.2.0'}
|
||||
dependencies:
|
||||
popsicle: 12.1.0
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
|
||||
/cliui@3.2.0:
|
||||
resolution: {integrity: sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==}
|
||||
dependencies:
|
||||
@ -14564,11 +14550,6 @@ packages:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/ip-regex@2.1.0:
|
||||
resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==}
|
||||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/ip@1.1.8:
|
||||
resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
|
||||
dev: false
|
||||
@ -16786,14 +16767,9 @@ packages:
|
||||
dependencies:
|
||||
semver: 6.3.0
|
||||
|
||||
/make-error-cause@2.3.0:
|
||||
resolution: {integrity: sha512-etgt+n4LlOkGSJbBTV9VROHA5R7ekIPS4vfh+bCAoJgRrJWdqJCBbpS3osRJ/HrT7R68MzMiY3L3sDJ/Fd8aBg==}
|
||||
dependencies:
|
||||
make-error: 1.3.6
|
||||
dev: false
|
||||
|
||||
/make-error@1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
dev: true
|
||||
|
||||
/make-fetch-happen@9.1.0:
|
||||
resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==}
|
||||
@ -18595,70 +18571,6 @@ packages:
|
||||
'@babel/runtime': 7.20.7
|
||||
dev: true
|
||||
|
||||
/popsicle-content-encoding@1.0.0(servie@4.3.3):
|
||||
resolution: {integrity: sha512-4Df+vTfM8wCCJVTzPujiI6eOl3SiWQkcZg0AMrOkD1enMXsF3glIkFUZGvour1Sj7jOWCsNSEhBxpbbhclHhzw==}
|
||||
peerDependencies:
|
||||
servie: ^4.0.0
|
||||
dependencies:
|
||||
servie: 4.3.3
|
||||
dev: false
|
||||
|
||||
/popsicle-cookie-jar@1.0.0(servie@4.3.3):
|
||||
resolution: {integrity: sha512-vrlOGvNVELko0+J8NpGC5lHWDGrk8LQJq9nwAMIVEVBfN1Lib3BLxAaLRGDTuUnvl45j5N9dT2H85PULz6IjjQ==}
|
||||
peerDependencies:
|
||||
servie: ^4.0.0
|
||||
dependencies:
|
||||
'@types/tough-cookie': 2.3.8
|
||||
servie: 4.3.3
|
||||
tough-cookie: 3.0.1
|
||||
dev: false
|
||||
|
||||
/popsicle-redirects@1.1.1(servie@4.3.3):
|
||||
resolution: {integrity: sha512-mC2HrKjdTAWDalOjGxlXw9j6Qxrz/Yd2ui6bPxpi2IQDYWpF4gUAMxbA8EpSWJhLi0PuWKDwTHHPrUPGutAoIA==}
|
||||
peerDependencies:
|
||||
servie: ^4.1.0
|
||||
dependencies:
|
||||
servie: 4.3.3
|
||||
dev: false
|
||||
|
||||
/popsicle-transport-http@1.2.1(servie@4.3.3):
|
||||
resolution: {integrity: sha512-i5r3IGHkGiBDm1oPFvOfEeSGWR0lQJcsdTqwvvDjXqcTHYJJi4iSi3ecXIttDiTBoBtRAFAE9nF91fspQr63FQ==}
|
||||
peerDependencies:
|
||||
servie: ^4.2.0
|
||||
dependencies:
|
||||
make-error-cause: 2.3.0
|
||||
servie: 4.3.3
|
||||
dev: false
|
||||
|
||||
/popsicle-transport-xhr@2.0.0(servie@4.3.3):
|
||||
resolution: {integrity: sha512-5Sbud4Widngf1dodJE5cjEYXkzEUIl8CzyYRYR57t6vpy9a9KPGQX6KBKdPjmBZlR5A06pOBXuJnVr23l27rtA==}
|
||||
peerDependencies:
|
||||
servie: ^4.2.0
|
||||
dependencies:
|
||||
servie: 4.3.3
|
||||
dev: false
|
||||
|
||||
/popsicle-user-agent@1.0.0(servie@4.3.3):
|
||||
resolution: {integrity: sha512-epKaq3TTfTzXcxBxjpoKYMcTTcAX8Rykus6QZu77XNhJuRHSRxMd+JJrbX/3PFI0opFGSN0BabbAYCbGxbu0mA==}
|
||||
peerDependencies:
|
||||
servie: ^4.0.0
|
||||
dependencies:
|
||||
servie: 4.3.3
|
||||
dev: false
|
||||
|
||||
/popsicle@12.1.0:
|
||||
resolution: {integrity: sha512-muNC/cIrWhfR6HqqhHazkxjob3eyECBe8uZYSQ/N5vixNAgssacVleerXnE8Are5fspR0a+d2qWaBR1g7RYlmw==}
|
||||
dependencies:
|
||||
popsicle-content-encoding: 1.0.0(servie@4.3.3)
|
||||
popsicle-cookie-jar: 1.0.0(servie@4.3.3)
|
||||
popsicle-redirects: 1.1.1(servie@4.3.3)
|
||||
popsicle-transport-http: 1.2.1(servie@4.3.3)
|
||||
popsicle-transport-xhr: 2.0.0(servie@4.3.3)
|
||||
popsicle-user-agent: 1.0.0(servie@4.3.3)
|
||||
servie: 4.3.3
|
||||
throwback: 4.1.0
|
||||
dev: false
|
||||
|
||||
/posix-character-classes@0.1.1:
|
||||
resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -20234,14 +20146,6 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/servie@4.3.3:
|
||||
resolution: {integrity: sha512-b0IrY3b1gVMsWvJppCf19g1p3JSnS0hQi6xu4Hi40CIhf0Lx8pQHcvBL+xunShpmOiQzg1NOia812NAWdSaShw==}
|
||||
dependencies:
|
||||
'@servie/events': 1.0.0
|
||||
byte-length: 1.0.2
|
||||
ts-expect: 1.3.0
|
||||
dev: false
|
||||
|
||||
/set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
@ -21382,10 +21286,6 @@ packages:
|
||||
/through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
/throwback@4.1.0:
|
||||
resolution: {integrity: sha512-dLFe8bU8SeH0xeqeKL7BNo8XoPC/o91nz9/ooeplZPiso+DZukhoyZcSz9TFnUNScm+cA9qjU1m1853M6sPOng==}
|
||||
dev: false
|
||||
|
||||
/time-stamp@1.1.0:
|
||||
resolution: {integrity: sha512-gLCeArryy2yNTRzTGKbZbloctj64jkZ57hj5zdraXue6aFgd6PmvVtEyiUU+hvU0v7q08oVv8r8ev0tRo6bvgw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -21547,15 +21447,6 @@ packages:
|
||||
psl: 1.9.0
|
||||
punycode: 2.2.0
|
||||
|
||||
/tough-cookie@3.0.1:
|
||||
resolution: {integrity: sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
ip-regex: 2.1.0
|
||||
psl: 1.9.0
|
||||
punycode: 2.2.0
|
||||
dev: false
|
||||
|
||||
/tough-cookie@4.1.2:
|
||||
resolution: {integrity: sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==}
|
||||
engines: {node: '>=6'}
|
||||
@ -21604,10 +21495,6 @@ packages:
|
||||
typescript: 5.0.3
|
||||
dev: true
|
||||
|
||||
/ts-expect@1.3.0:
|
||||
resolution: {integrity: sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==}
|
||||
dev: false
|
||||
|
||||
/ts-jest@29.1.0(@babel/core@7.21.8)(jest@29.5.0)(typescript@5.0.3):
|
||||
resolution: {integrity: sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
@ -1,3 +1,4 @@
|
||||
packages:
|
||||
- packages/*
|
||||
- packages/@n8n/*
|
||||
- packages/@n8n_io/*
|
||||
|
Loading…
Reference in New Issue
Block a user