Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Replace client-oauth2 with an in-repo package #6056

Merged
merged 12 commits into from
May 16, 2023
2 changes: 1 addition & 1 deletion .github/workflows/ci-master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci-pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/@n8n/client-oauth2/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/@n8n/client-oauth2/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');
25 changes: 25 additions & 0 deletions packages/@n8n/client-oauth2/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
109 changes: 109 additions & 0 deletions packages/@n8n/client-oauth2/src/ClientOAuth2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* 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,
});

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 changes: 100 additions & 0 deletions packages/@n8n/client-oauth2/src/ClientOAuth2Token.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 121 additions & 0 deletions packages/@n8n/client-oauth2/src/CodeFlow.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading