From 32b89e4d481368d69fce36b758308d6baaf202c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 16 Mar 2023 11:22:24 +0100 Subject: [PATCH] feat(core): Replace client-oauth2 with an in-repo package --- packages/@n8n_io/client-oauth2/.eslintrc.js | 14 ++ packages/@n8n_io/client-oauth2/package.json | 25 ++++ .../@n8n_io/client-oauth2/src/ClientOAuth2.ts | 116 +++++++++++++++ .../client-oauth2/src/ClientOAuth2Token.ts | 102 +++++++++++++ .../@n8n_io/client-oauth2/src/CodeFlow.ts | 117 +++++++++++++++ .../client-oauth2/src/CredentialsFlow.ts | 54 +++++++ .../@n8n_io/client-oauth2/src/constants.ts | 63 ++++++++ packages/@n8n_io/client-oauth2/src/index.ts | 2 + packages/@n8n_io/client-oauth2/src/types.ts | 2 + packages/@n8n_io/client-oauth2/src/utils.ts | 83 +++++++++++ .../@n8n_io/client-oauth2/tsconfig.build.json | 12 ++ packages/@n8n_io/client-oauth2/tsconfig.json | 15 ++ packages/cli/package.json | 2 +- .../src/credentials/oauth2Credential.api.ts | 13 +- packages/cli/tsconfig.json | 3 +- packages/core/package.json | 2 +- packages/core/src/NodeExecuteFunctions.ts | 23 ++- packages/core/src/OAuth2Helper.ts | 10 +- packages/core/tsconfig.json | 5 +- pnpm-lock.yaml | 139 ++---------------- 20 files changed, 654 insertions(+), 148 deletions(-) create mode 100644 packages/@n8n_io/client-oauth2/.eslintrc.js create mode 100644 packages/@n8n_io/client-oauth2/package.json create mode 100644 packages/@n8n_io/client-oauth2/src/ClientOAuth2.ts create mode 100644 packages/@n8n_io/client-oauth2/src/ClientOAuth2Token.ts create mode 100644 packages/@n8n_io/client-oauth2/src/CodeFlow.ts create mode 100644 packages/@n8n_io/client-oauth2/src/CredentialsFlow.ts create mode 100644 packages/@n8n_io/client-oauth2/src/constants.ts create mode 100644 packages/@n8n_io/client-oauth2/src/index.ts create mode 100644 packages/@n8n_io/client-oauth2/src/types.ts create mode 100644 packages/@n8n_io/client-oauth2/src/utils.ts create mode 100644 packages/@n8n_io/client-oauth2/tsconfig.build.json create mode 100644 packages/@n8n_io/client-oauth2/tsconfig.json diff --git a/packages/@n8n_io/client-oauth2/.eslintrc.js b/packages/@n8n_io/client-oauth2/.eslintrc.js new file mode 100644 index 0000000000000..fe7469cf3c7af --- /dev/null +++ b/packages/@n8n_io/client-oauth2/.eslintrc.js @@ -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', + }, +}; diff --git a/packages/@n8n_io/client-oauth2/package.json b/packages/@n8n_io/client-oauth2/package.json new file mode 100644 index 0000000000000..3770129534a0f --- /dev/null +++ b/packages/@n8n_io/client-oauth2/package.json @@ -0,0 +1,25 @@ +{ + "name": "@n8n_io/client-oauth2", + "version": "1.0.4", + "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" + } +} diff --git a/packages/@n8n_io/client-oauth2/src/ClientOAuth2.ts b/packages/@n8n_io/client-oauth2/src/ClientOAuth2.ts new file mode 100644 index 0000000000000..6b9a1db281483 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/ClientOAuth2.ts @@ -0,0 +1,116 @@ +/* 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; + 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; + 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( + access: string, + refresh: string, + type?: string, + data?: ClientOAuth2TokenData, + ): ClientOAuth2Token { + return new ClientOAuth2Token(this, { + ...data, + access_token: access, + refresh_token: refresh, + ...(typeof type === 'string' ? { token_type: type } : type), + }); + } + + /** + * Attempt to parse response body as JSON, fall back to parsing as a query string. + */ + private parseResponseBody(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(options: ClientOAuth2RequestObject): Promise { + 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, + }); + + const body = this.parseResponseBody(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; + } +} diff --git a/packages/@n8n_io/client-oauth2/src/ClientOAuth2Token.ts b/packages/@n8n_io/client-oauth2/src/ClientOAuth2Token.ts new file mode 100644 index 0000000000000..d2a26b134c336 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/ClientOAuth2Token.ts @@ -0,0 +1,102 @@ +/* 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, requestOptions } from './utils'; +import { DEFAULT_HEADERS } from './constants'; + +export interface ClientOAuth2TokenData extends Record { + 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(); + 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 { + const { clientId, clientSecret, accessTokenUri } = { ...this.client.options, ...opts }; + + if (!this.refreshToken) throw new Error('No refresh token'); + + const config = requestOptions( + { + url: accessTokenUri, + method: 'POST', + headers: { + ...DEFAULT_HEADERS, + Authorization: auth(clientId, clientSecret), + }, + body: { + refresh_token: this.refreshToken, + grant_type: 'refresh_token', + }, + }, + {}, + ); + + const data = await this.client.request(config); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.client.createToken({ ...this.data, ...data }); + } + + /** + * Check whether the token has expired. + */ + expired(): boolean { + return Date.now() > this.expires.getTime(); + } +} diff --git a/packages/@n8n_io/client-oauth2/src/CodeFlow.ts b/packages/@n8n_io/client-oauth2/src/CodeFlow.ts new file mode 100644 index 0000000000000..2509947a16e7d --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/CodeFlow.ts @@ -0,0 +1,117 @@ +import * as qs from 'querystring'; +import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2'; +import type { ClientOAuth2Token } from './ClientOAuth2Token'; +import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants'; +import { auth, expects, getAuthError, requestOptions, 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 = { + client_id: options.clientId, + redirect_uri: options.redirectUri, + response_type: 'code', + state: options.state, + }; + if (options.scopes !== undefined) { + query.scope = sanitizeScope(options.scopes); + } + + const sep = options.authorizationUri!.includes('?') ? '&' : '?'; + return options.authorizationUri! + sep + qs.stringify({ ...query, ...options.query }); + } + + /** + * Get the code token from the redirected uri and make another request for + * the user access token. + */ + async getToken(uri?: string | URL, opts?: ClientOAuth2Options): Promise { + 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 responseData = await this.client.request( + requestOptions( + { + url: options.accessTokenUri, + method: 'POST', + headers, + body, + }, + options, + ), + ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.client.createToken(responseData); + } +} diff --git a/packages/@n8n_io/client-oauth2/src/CredentialsFlow.ts b/packages/@n8n_io/client-oauth2/src/CredentialsFlow.ts new file mode 100644 index 0000000000000..2dc4b1a06f89b --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/CredentialsFlow.ts @@ -0,0 +1,54 @@ +import type { ClientOAuth2, ClientOAuth2Options } from './ClientOAuth2'; +import type { ClientOAuth2Token } from './ClientOAuth2Token'; +import { DEFAULT_HEADERS } from './constants'; +import { auth, expects, requestOptions, 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?: ClientOAuth2Options): Promise { + 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 data = await this.client.request( + requestOptions( + { + url: options.accessTokenUri, + method: 'POST', + headers: { + ...DEFAULT_HEADERS, + // eslint-disable-next-line @typescript-eslint/naming-convention + Authorization: auth(options.clientId, options.clientSecret), + }, + body, + }, + options, + ), + ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.client.createToken(data); + } +} diff --git a/packages/@n8n_io/client-oauth2/src/constants.ts b/packages/@n8n_io/client-oauth2/src/constants.ts new file mode 100644 index 0000000000000..e4895aa470a15 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/constants.ts @@ -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 = { + 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(' '), +}; diff --git a/packages/@n8n_io/client-oauth2/src/index.ts b/packages/@n8n_io/client-oauth2/src/index.ts new file mode 100644 index 0000000000000..376c10f1eec51 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/index.ts @@ -0,0 +1,2 @@ +export { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './ClientOAuth2'; +export { ClientOAuth2Token, ClientOAuth2TokenData } from './ClientOAuth2Token'; diff --git a/packages/@n8n_io/client-oauth2/src/types.ts b/packages/@n8n_io/client-oauth2/src/types.ts new file mode 100644 index 0000000000000..906efcc43e0fd --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/types.ts @@ -0,0 +1,2 @@ +export type Headers = Record; +export type Query = Record; diff --git a/packages/@n8n_io/client-oauth2/src/utils.ts b/packages/@n8n_io/client-oauth2/src/utils.ts new file mode 100644 index 0000000000000..f1e51dc1c3578 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/src/utils.ts @@ -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'); + } + } +} + +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 requestOptions( + { 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; +} diff --git a/packages/@n8n_io/client-oauth2/tsconfig.build.json b/packages/@n8n_io/client-oauth2/tsconfig.build.json new file mode 100644 index 0000000000000..c8f44354c7ac4 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/tsconfig.build.json @@ -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/**"] +} diff --git a/packages/@n8n_io/client-oauth2/tsconfig.json b/packages/@n8n_io/client-oauth2/tsconfig.json new file mode 100644 index 0000000000000..a693815582818 --- /dev/null +++ b/packages/@n8n_io/client-oauth2/tsconfig.json @@ -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"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index fac909bf86cee..1508f8533a182 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -117,6 +117,7 @@ }, "dependencies": { "@n8n_io/license-sdk": "~2.3.0", + "@n8n_io/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", diff --git a/packages/cli/src/credentials/oauth2Credential.api.ts b/packages/cli/src/credentials/oauth2Credential.api.ts index a8fd84cef7967..a6e475048fb53 100644 --- a/packages/cli/src/credentials/oauth2Credential.api.ts +++ b/packages/cli/src/credentials/oauth2Credential.api.ts @@ -1,4 +1,5 @@ -import ClientOAuth2 from 'client-oauth2'; +import type { ClientOAuth2Options } from '@n8n_io/client-oauth2'; +import { ClientOAuth2 } from '@n8n_io/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, @@ -251,11 +252,11 @@ oauth2CredentialController.get( return renderCallbackError(res, errorMessage); } - let options = {}; + let options: Partial = {}; - 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`, @@ -269,6 +270,7 @@ oauth2CredentialController.get( client_secret: get(oauthCredentials, 'clientSecret', '') as string, }, }; + // @ts-ignore delete oAuth2Parameters.clientSecret; } @@ -280,6 +282,7 @@ oauth2CredentialController.get( const oauthToken = await oAuthObj.code.getToken( `${oAuth2Parameters.redirectUri}?${queryParameters}`, + // @ts-ignore options, ); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 5326caf458dca..7b52926d17cb8 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -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_io/client-oauth2/tsconfig.build.json" } ] } diff --git a/packages/core/package.json b/packages/core/package.json index b1fc30e7a585c..683ec7566426c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "axios": "^0.21.1", - "client-oauth2": "^4.2.5", + "@n8n_io/client-oauth2": "workspace:*", "concat-stream": "^2.0.0", "cron": "~1.7.2", "crypto-js": "~4.1.1", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 5d86a914a0d08..ba43de3bd8848 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -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_io/client-oauth2'; +import { ClientOAuth2 } from '@n8n_io/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 ( @@ -1117,6 +1122,8 @@ export async function requestOAuth2( } const token = oAuthClient.createToken( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore get(oauthTokenData, oAuth2Options?.property as string) || oauthTokenData.accessToken, oauthTokenData.refreshToken, oAuth2Options?.tokenType || oauthTokenData.tokenType, @@ -1124,7 +1131,7 @@ export async function requestOAuth2( ); // 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 +1171,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 +1191,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, { @@ -1243,7 +1250,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 +1278,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) { diff --git a/packages/core/src/OAuth2Helper.ts b/packages/core/src/OAuth2Helper.ts index a66763505c501..b41349b26dc63 100644 --- a/packages/core/src/OAuth2Helper.ts +++ b/packages/core/src/OAuth2Helper.ts @@ -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_io/client-oauth2'; export const getClientCredentialsToken = async ( - oAuth2Client: clientOAuth2, + oAuth2Client: ClientOAuth2, credentials: ICredentialDataDecryptedObject, -): Promise => { +): Promise => { 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); }; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 69536da44842a..55f880dfcdf1e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -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_io/client-oauth2/tsconfig.build.json" } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87e40272d8b25..12c7733c781a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,12 @@ importers: specifier: ^5.0.3 version: 5.0.3 + packages/@n8n_io/client-oauth2: + dependencies: + axios: + specifier: ^0.21.1 + version: 0.21.4(debug@4.3.2) + packages/@n8n_io/eslint-config: devDependencies: '@types/eslint': @@ -163,6 +169,9 @@ importers: packages/cli: dependencies: + '@n8n_io/client-oauth2': + specifier: workspace:* + version: link:../@n8n_io/client-oauth2 '@n8n_io/license-sdk': specifier: ~2.3.0 version: 2.3.0 @@ -214,9 +223,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 @@ -602,12 +608,12 @@ importers: packages/core: dependencies: + '@n8n_io/client-oauth2': + specifier: workspace:* + version: link:../@n8n_io/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 @@ -4766,10 +4772,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: @@ -6992,10 +6994,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 @@ -8936,10 +8934,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'} @@ -9398,14 +9392,6 @@ packages: glob: 7.2.3 dev: true - /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: @@ -13449,11 +13435,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 @@ -15679,14 +15660,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==} @@ -17493,70 +17469,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'} @@ -19160,14 +19072,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==} @@ -20340,10 +20244,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'} @@ -20505,15 +20405,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'} @@ -20566,10 +20457,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.20.12)(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}