-
Notifications
You must be signed in to change notification settings - Fork 8.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): Replace client-oauth2 with an in-repo package
- Loading branch information
Showing
20 changed files
with
654 additions
and
148 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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( | ||
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<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; | ||
} | ||
} |
102 changes: 102 additions & 0 deletions
102
packages/@n8n_io/client-oauth2/src/ClientOAuth2Token.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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(); | ||
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 { 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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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); | ||
} | ||
|
||
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<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 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); | ||
} | ||
} |
Oops, something went wrong.