1
1
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:
कारतोफ्फेलस्क्रिप्ट™ 2023-05-17 14:40:53 +00:00 committed by GitHub
parent 16fade7d41
commit a1b1f24ddf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 992 additions and 166 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View 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',
},
};

View File

@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');

View 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"
}
}

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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(' '),
};

View File

@ -0,0 +1,2 @@
export { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2';
export { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token';

View File

@ -0,0 +1,2 @@
export type Headers = Record<string, string | string[]>;
export type Query = Record<string, string | string[]>;

View 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;
}

View 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');
});
});
});
});

View 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');
});
});
});
});

View 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';

View 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/**"]
}

View 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"]
}

View File

@ -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",

View File

@ -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,
);

View File

@ -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" }
]
}

View File

@ -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",

View File

@ -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) {

View File

@ -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);
};

View File

@ -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" }
]
}

View File

@ -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}

View File

@ -1,3 +1,4 @@
packages:
- packages/*
- packages/@n8n/*
- packages/@n8n_io/*