Skip to content

Commit

Permalink
feat(core): Replace client-oauth2 with an in-repo package (#6056)
Browse files Browse the repository at this point in the history
Co-authored-by: Marcus <[email protected]>
  • Loading branch information
netroy and maspio committed May 17, 2023
1 parent b7d30f3 commit 2a2f583
Show file tree
Hide file tree
Showing 27 changed files with 986 additions and 163 deletions.
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

0 comments on commit 2a2f583

Please sign in to comment.