first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,29 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'@typescript-eslint/consistent-type-imports': 'error',
'n8n-local-rules/no-plain-errors': 'off',
'n8n-local-rules/no-uncaught-json-parse': 'off',
// TODO: Remove this
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/require-await': 'warn',
},
},
{
files: ['**/*.test.ts'],
rules: {
// TODO: Remove this
'id-denylist': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
},
},
);
@@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@n8n/client-oauth2",
"version": "1.0.0-rc.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write src test",
"format:check": "biome ci src test",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"axios": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
}
}
@@ -0,0 +1,121 @@
import * as a from 'node:assert';
import type { ClientOAuth2, ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
import { DEFAULT_HEADERS } from './constants';
import { auth, getRequestOptions } from './utils';
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 refresh token.
* As in RFC 6749 Section 6: https://www.rfc-editor.org/rfc/rfc6749.html#section-6
* Supports PKCE flows (RFC 7636) for public clients without client secret
*/
async refresh(opts?: ClientOAuth2Options): Promise<ClientOAuth2Token> {
const options = { ...this.client.options, ...opts };
a.ok(this.refreshToken, 'refreshToken is required');
const { clientId, clientSecret } = options;
const headers = { ...DEFAULT_HEADERS };
const body: Record<string, string> = {
refresh_token: this.refreshToken,
grant_type: 'refresh_token',
};
// Handle different authentication methods
if (clientSecret) {
// Confidential client (traditional OAuth2 or PKCE with client secret)
if (options.authentication === 'body') {
body.client_id = clientId;
body.client_secret = clientSecret;
} else {
headers.Authorization = auth(clientId, clientSecret);
}
} else {
// Public client (PKCE without client secret per RFC 7636)
// Always include client_id in body for public clients
body.client_id = clientId;
}
const requestOptions = getRequestOptions(
{
url: options.accessTokenUri,
method: 'POST',
headers,
body,
},
options,
);
const responseData = await this.client.accessTokenRequest(requestOptions);
return this.client.createToken({ ...this.data, ...responseData });
}
/**
* Check whether the token has expired.
*/
expired(): boolean {
return Date.now() > this.expires.getTime();
}
}
@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { Agent } from 'https';
import * as qs from 'querystring';
import type { ClientOAuth2TokenData } from './client-oauth2-token';
import { ClientOAuth2Token } from './client-oauth2-token';
import { CodeFlow } from './code-flow';
import { CredentialsFlow } from './credentials-flow';
import type { Headers, OAuth2AccessTokenErrorResponse } from './types';
import { getAuthError } from './utils';
export interface ClientOAuth2RequestObject {
url: string;
method: 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT';
body?: Record<string, any>;
query?: qs.ParsedUrlQuery;
headers?: Headers;
ignoreSSLIssues?: boolean;
}
export interface ClientOAuth2Options {
clientId: string;
clientSecret?: string;
accessTokenUri: string;
authentication?: 'header' | 'body';
authorizationUri?: string;
redirectUri?: string;
scopes?: string[];
scopesSeparator?: ',' | ' ';
authorizationGrants?: string[];
state?: string;
additionalBodyProperties?: Record<string, any>;
body?: Record<string, any>;
query?: qs.ParsedUrlQuery;
ignoreSSLIssues?: boolean;
}
export class ResponseError extends Error {
constructor(
readonly status: number,
readonly body: unknown,
readonly code = 'ESTATUS',
readonly message = `HTTP status ${status}`,
) {
super(message);
}
}
const sslIgnoringAgent = new Agent({ rejectUnauthorized: false });
/**
* 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),
});
}
/**
* Request an access token from the OAuth2 server.
*
* @throws {ResponseError} If the response is an unexpected status code.
* @throws {AuthError} If the response is an authentication error.
*/
async accessTokenRequest(options: ClientOAuth2RequestObject): Promise<ClientOAuth2TokenData> {
let url = options.url;
const query = qs.stringify(options.query);
if (query) {
url += (url.indexOf('?') === -1 ? '?' : '&') + query;
}
const requestConfig: AxiosRequestConfig = {
url,
method: options.method,
data: qs.stringify(options.body),
headers: options.headers,
transformResponse: (res: unknown) => 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,
};
if (options.ignoreSSLIssues) {
requestConfig.httpsAgent = sslIgnoringAgent;
}
const response = await axios.request(requestConfig);
if (response.status >= 400) {
const body = this.parseResponseBody<OAuth2AccessTokenErrorResponse>(response);
const authErr = getAuthError(body);
if (authErr) throw authErr;
else throw new ResponseError(response.status, response.data);
}
if (response.status >= 300) {
throw new ResponseError(response.status, response.data);
}
return this.parseResponseBody<ClientOAuth2TokenData>(response);
}
/**
* Attempt to parse response body based on the content type.
*/
private parseResponseBody<T extends object>(response: AxiosResponse<unknown>): T {
const contentType = (response.headers['content-type'] as string) ?? '';
const body = response.data as string;
if (contentType.startsWith('application/json')) {
return JSON.parse(body) as T;
}
if (contentType.startsWith('application/x-www-form-urlencoded')) {
return qs.parse(body) as T;
}
throw new ResponseError(
response.status,
body,
undefined,
`Unsupported content type: ${contentType}`,
);
}
}
@@ -0,0 +1,123 @@
import * as qs from 'querystring';
import type { ClientOAuth2, ClientOAuth2Options } from './client-oauth2';
import type { ClientOAuth2Token } from './client-oauth2-token';
import { DEFAULT_HEADERS, DEFAULT_URL_BASE } from './constants';
import { auth, expects, getAuthError, getRequestOptions } 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?: Partial<ClientOAuth2Options>): string {
const options: ClientOAuth2Options = { ...this.client.options, ...opts };
// Check the required parameters are set.
expects(options, 'clientId', 'authorizationUri');
const url = new URL(options.authorizationUri);
const queryParams = {
...options.query,
client_id: options.clientId,
redirect_uri: options.redirectUri,
response_type: 'code',
state: options.state,
...(options.scopes ? { scope: options.scopes.join(options.scopesSeparator ?? ' ') } : {}),
};
for (const [key, value] of Object.entries(queryParams)) {
if (value !== null && value !== undefined) {
url.searchParams.append(key, value);
}
}
return url.toString();
}
/**
* Get the code token from the redirected uri and make another request for
* the user access token.
*/
async getToken(
urlString: string,
opts?: Partial<ClientOAuth2Options>,
): Promise<ClientOAuth2Token> {
const options: ClientOAuth2Options = { ...this.client.options, ...opts };
expects(options, 'clientId', 'accessTokenUri');
const url = new URL(urlString, 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)) {
throw new TypeError(`Unable to process uri: ${urlString}`);
}
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.accessTokenRequest(requestOptions);
return this.client.createToken(responseData);
}
}
@@ -0,0 +1,62 @@
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(' '),
};
@@ -0,0 +1,62 @@
import type { ClientOAuth2 } from './client-oauth2';
import type { ClientOAuth2Token } from './client-oauth2-token';
import { DEFAULT_HEADERS } from './constants';
import type { Headers } from './types';
import { auth, expects, getRequestOptions } from './utils';
interface CredentialsFlowBody {
client_id?: string;
client_secret?: string;
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(): Promise<ClientOAuth2Token> {
const options = { ...this.client.options };
expects(options, 'clientId', 'clientSecret', 'accessTokenUri');
const headers: Headers = { ...DEFAULT_HEADERS };
const body: CredentialsFlowBody = {
grant_type: 'client_credentials',
...(options.additionalBodyProperties ?? {}),
};
if (options.scopes !== undefined) {
body.scope = options.scopes.join(options.scopesSeparator ?? ' ');
}
const clientId = options.clientId;
const clientSecret = options.clientSecret;
if (options.authentication === 'body') {
body.client_id = clientId;
body.client_secret = clientSecret;
} else {
headers.Authorization = auth(clientId, clientSecret);
}
const requestOptions = getRequestOptions(
{
url: options.accessTokenUri,
method: 'POST',
headers,
body,
},
options,
);
const responseData = await this.client.accessTokenRequest(requestOptions);
return this.client.createToken(responseData);
}
}
+5
View File
@@ -0,0 +1,5 @@
export type { ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
export { ClientOAuth2 } from './client-oauth2';
export type { ClientOAuth2TokenData } from './client-oauth2-token';
export { ClientOAuth2Token } from './client-oauth2-token';
export type * from './types';
+35
View File
@@ -0,0 +1,35 @@
export type Headers = Record<string, string | string[]>;
export type OAuth2GrantType = 'pkce' | 'authorizationCode' | 'clientCredentials';
export type OAuth2AuthenticationMethod = 'header' | 'body';
export interface OAuth2CredentialData {
clientId: string;
clientSecret?: string;
accessTokenUrl: string;
authentication?: OAuth2AuthenticationMethod;
authUrl?: string;
scope?: string;
authQueryParameters?: string;
additionalBodyProperties?: string;
grantType: OAuth2GrantType;
ignoreSSLIssues?: boolean;
oauthTokenData?: {
access_token: string;
refresh_token?: string;
};
useDynamicClientRegistration?: boolean;
serverUrl?: string;
}
/**
* The response from the OAuth2 server when the access token is not successfully
* retrieved. As specified in RFC 6749 Section 5.2:
* https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
*/
export interface OAuth2AccessTokenErrorResponse extends Record<string, unknown> {
error: string;
error_description?: string;
error_uri?: string;
}
+82
View File
@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oauth2';
import { ERROR_RESPONSES } from './constants';
/**
* Check if properties exist on an object and throw when they aren't.
*/
export function expects<Keys extends keyof ClientOAuth2Options>(
obj: ClientOAuth2Options,
...keys: Keys[]
): asserts obj is ClientOAuth2Options & {
[K in Keys]: NonNullable<ClientOAuth2Options[K]>;
} {
for (const key of keys) {
if (obj[key] === null || obj[key] === undefined) {
throw new TypeError('Expected "' + key + '" 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);
}
/**
* 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: ClientOAuth2Options,
): ClientOAuth2RequestObject {
const rOptions = {
url,
method,
body: { ...body, ...options.body },
query: { ...query, ...options.query },
headers: headers ?? {},
ignoreSSLIssues: options.ignoreSSLIssues,
};
// if request authorization was overridden delete it from header
if (rOptions.headers.Authorization === '') {
delete rOptions.headers.Authorization;
}
return rOptions;
}
@@ -0,0 +1,168 @@
import axios from 'axios';
import nock from 'nock';
import { ClientOAuth2, ResponseError } from '@/client-oauth2';
import { ERROR_RESPONSES } from '@/constants';
import { auth, AuthError } from '@/utils';
import * as config from './config';
describe('ClientOAuth2', () => {
const client = new ClientOAuth2({
clientId: config.clientId,
clientSecret: config.clientSecret,
accessTokenUri: config.accessTokenUri,
authentication: 'header',
});
beforeAll(async () => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
});
describe('accessTokenRequest', () => {
const authHeader = auth(config.clientId, config.clientSecret);
const makeTokenCall = async () =>
await client.accessTokenRequest({
url: config.accessTokenUri,
method: 'POST',
headers: {
Authorization: authHeader,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
refresh_token: 'test',
grant_type: 'refresh_token',
},
});
const mockTokenResponse = ({
status = 200,
headers,
body,
}: {
status: number;
body: string;
headers: Record<string, string>;
}) =>
nock(config.baseUrl).post('/login/oauth/access_token').once().reply(status, body, headers);
it('should send the correct request based on given options', async () => {
mockTokenResponse({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
});
const axiosSpy = jest.spyOn(axios, 'request');
await makeTokenCall();
expect(axiosSpy).toHaveBeenCalledWith(
expect.objectContaining({
url: config.accessTokenUri,
method: 'POST',
data: 'refresh_token=test&grant_type=refresh_token',
headers: {
Authorization: authHeader,
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
}),
);
});
test.each([
{
contentType: 'application/json',
body: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
},
{
contentType: 'application/json; charset=utf-8',
body: JSON.stringify({
access_token: config.accessToken,
refresh_token: config.refreshToken,
}),
},
{
contentType: 'application/x-www-form-urlencoded',
body: `access_token=${config.accessToken}&refresh_token=${config.refreshToken}`,
},
])('should parse response with content type $contentType', async ({ contentType, body }) => {
mockTokenResponse({
status: 200,
headers: { 'Content-Type': contentType },
body,
});
const response = await makeTokenCall();
expect(response).toEqual({
access_token: config.accessToken,
refresh_token: config.refreshToken,
});
});
test.each([
{
contentType: 'text/html',
body: '<html><body>Hello, world!</body></html>',
},
{
contentType: 'application/xml',
body: '<xml><body>Hello, world!</body></xml>',
},
{
contentType: 'text/plain',
body: 'Hello, world!',
},
])('should reject content type $contentType', async ({ contentType, body }) => {
mockTokenResponse({
status: 200,
headers: { 'Content-Type': contentType },
body,
});
const result = await makeTokenCall().catch((err) => err);
expect(result).toBeInstanceOf(Error);
expect(result.message).toEqual(`Unsupported content type: ${contentType}`);
});
it('should reject 4xx responses with auth errors', async () => {
mockTokenResponse({
status: 401,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'access_denied' }),
});
const result = await makeTokenCall().catch((err) => err);
expect(result).toBeInstanceOf(AuthError);
expect(result.message).toEqual(ERROR_RESPONSES.access_denied);
expect(result.body).toEqual({ error: 'access_denied' });
});
it('should reject 3xx responses with response errors', async () => {
mockTokenResponse({
status: 302,
headers: {},
body: 'Redirected',
});
const result = await makeTokenCall().catch((err) => err);
expect(result).toBeInstanceOf(ResponseError);
expect(result.message).toEqual('HTTP status 302');
expect(result.body).toEqual('Redirected');
});
});
});
@@ -0,0 +1,192 @@
import nock from 'nock';
import { ClientOAuth2 } from '@/client-oauth2';
import { ClientOAuth2Token } from '@/client-oauth2-token';
import { AuthError } from '@/utils';
import * as config from './config';
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&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',
);
});
});
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&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&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&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');
});
});
});
});
@@ -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';
@@ -0,0 +1,215 @@
import nock from 'nock';
import { ClientOAuth2, type ClientOAuth2Options } from '@/client-oauth2';
import { ClientOAuth2Token } from '@/client-oauth2-token';
import type { Headers } from '@/types';
import * as config from './config';
describe('CredentialsFlow', () => {
beforeAll(async () => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
});
beforeEach(() => jest.clearAllMocks());
describe('#getToken', () => {
const createAuthClient = ({
scopes,
authentication,
}: Pick<ClientOAuth2Options, 'scopes' | 'authentication'> = {}) =>
new ClientOAuth2({
clientId: config.clientId,
clientSecret: config.clientSecret,
accessTokenUri: config.accessTokenUri,
authentication,
authorizationGrants: ['credentials'],
scopes,
});
const mockTokenCall = async ({ requestedScope }: { requestedScope?: string } = {}) => {
const nockScope = 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,
});
return await new Promise<{ headers: Headers; body: unknown }>((resolve) => {
nockScope.once('request', (req) => {
resolve({
headers: req.headers,
body: req.requestBodyBuffers.toString('utf-8'),
});
});
});
};
it('should request the token', async () => {
const authClient = createAuthClient({ scopes: ['notifications'] });
const requestPromise = mockTokenCall({ requestedScope: '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');
const { headers, body } = await requestPromise;
expect(headers.authorization).toBe('Basic YWJjOjEyMw==');
expect(body).toEqual('grant_type=client_credentials&scope=notifications');
});
it('when scopes are undefined, it should not send scopes to an auth server', async () => {
const authClient = createAuthClient();
const requestPromise = 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);
const { body } = await requestPromise;
expect(body).toEqual('grant_type=client_credentials');
});
it('when scopes is an empty array, it should send empty scope string to an auth server', async () => {
const authClient = createAuthClient({ scopes: [] });
const requestPromise = mockTokenCall({ requestedScope: '' });
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('');
const { body } = await requestPromise;
expect(body).toEqual('grant_type=client_credentials&scope=');
});
it('should handle authentication = "header"', async () => {
const authClient = createAuthClient({ scopes: [] });
const requestPromise = mockTokenCall({ requestedScope: '' });
await authClient.credentials.getToken();
const { headers, body } = await requestPromise;
expect(headers?.authorization).toBe('Basic YWJjOjEyMw==');
expect(body).toEqual('grant_type=client_credentials&scope=');
});
it('should handle authentication = "body"', async () => {
const authClient = createAuthClient({ scopes: [], authentication: 'body' });
const requestPromise = mockTokenCall({ requestedScope: '' });
await authClient.credentials.getToken();
const { headers, body } = await requestPromise;
expect(headers?.authorization).toBe(undefined);
expect(body).toEqual('grant_type=client_credentials&scope=&client_id=abc&client_secret=123');
});
describe('#sign', () => {
it('should be able to sign a standard request object', async () => {
const authClient = createAuthClient({ scopes: ['notifications'] });
void mockTokenCall({ requestedScope: '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 = async () => {
const nockScope = 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,
});
return await new Promise<{ headers: Headers; body: unknown }>((resolve) => {
nockScope.once('request', (req) => {
resolve({
headers: req.headers,
body: req.requestBodyBuffers.toString('utf-8'),
});
});
});
};
it('should make a request to get a new access token', async () => {
const authClient = createAuthClient({ scopes: ['notifications'] });
void mockTokenCall({ requestedScope: 'notifications' });
const token = await authClient.credentials.getToken();
expect(token.accessToken).toEqual(config.accessToken);
const requestPromise = mockRefreshCall();
const token1 = await token.refresh();
await requestPromise;
expect(token1).toBeInstanceOf(ClientOAuth2Token);
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
expect(token1.tokenType).toEqual('bearer');
});
it('should make a request to get a new access token with authentication = "body"', async () => {
const authClient = createAuthClient({ scopes: ['notifications'], authentication: 'body' });
void mockTokenCall({ requestedScope: 'notifications' });
const token = await authClient.credentials.getToken();
expect(token.accessToken).toEqual(config.accessToken);
const requestPromise = mockRefreshCall();
const token1 = await token.refresh();
const { headers, body } = await requestPromise;
expect(token1).toBeInstanceOf(ClientOAuth2Token);
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
expect(token1.tokenType).toEqual('bearer');
expect(headers?.authorization).toBe(undefined);
expect(body).toEqual(
'refresh_token=def456token&grant_type=refresh_token&client_id=abc&client_secret=123',
);
});
it('should make a request to get a new access token with authentication = "header"', async () => {
const authClient = createAuthClient({
scopes: ['notifications'],
authentication: 'header',
});
void mockTokenCall({ requestedScope: 'notifications' });
const token = await authClient.credentials.getToken();
expect(token.accessToken).toEqual(config.accessToken);
const requestPromise = mockRefreshCall();
const token1 = await token.refresh();
const { headers, body } = await requestPromise;
expect(token1).toBeInstanceOf(ClientOAuth2Token);
expect(token1.accessToken).toEqual(config.refreshedAccessToken);
expect(token1.tokenType).toEqual('bearer');
expect(headers?.authorization).toBe('Basic YWJjOjEyMw==');
expect(body).toEqual('refresh_token=def456token&grant_type=refresh_token');
});
});
});
});
@@ -0,0 +1,202 @@
import nock from 'nock';
import { ClientOAuth2 } from '@/client-oauth2';
import { ClientOAuth2Token } from '@/client-oauth2-token';
import type { Headers } from '@/types';
import * as config from './config';
describe('PKCE Flow', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
});
beforeEach(() => jest.clearAllMocks());
describe('PKCE Authorization Code Flow', () => {
const createPkceClient = (clientSecret?: string) =>
new ClientOAuth2({
clientId: config.clientId,
clientSecret,
accessTokenUri: config.accessTokenUri,
authorizationUri: config.authorizationUri,
authorizationGrants: ['code'],
redirectUri: config.redirectUri,
scopes: ['read', 'write'],
});
describe('#getUri with PKCE parameters', () => {
it('should include code_challenge and code_challenge_method in authorization URI', () => {
const client = createPkceClient();
const codeChallenge = 'test_code_challenge';
const uri = client.code.getUri({
query: {
code_challenge: codeChallenge,
code_challenge_method: 'S256',
},
});
expect(uri).toContain(`code_challenge=${codeChallenge}`);
expect(uri).toContain('code_challenge_method=S256');
});
});
describe('#getToken with PKCE', () => {
const mockPkceTokenCall = (
options: {
expectClientSecret?: boolean;
expectCodeVerifier?: boolean;
} = {},
) => {
const { expectClientSecret = false, expectCodeVerifier = true } = options;
return nock(config.baseUrl)
.post('/login/oauth/access_token')
.once()
.reply(200, function (this: nock.ReplyFnContext, _uri: string, requestBody: string) {
// Verify PKCE parameters are included correctly
if (expectCodeVerifier) {
expect(requestBody).toContain('code_verifier=test_code_verifier');
}
expect(requestBody).toContain('grant_type=authorization_code');
// For confidential clients, credentials are in Basic Auth header
if (expectClientSecret) {
expect(this.req.headers.authorization).toMatch(/^Basic /);
} else {
// For public clients, no authorization header but client_id may be in body
expect(this.req.headers.authorization).toBeUndefined();
}
return {
access_token: config.accessToken,
refresh_token: config.refreshToken,
token_type: 'Bearer',
};
});
};
it('should exchange authorization code with PKCE code_verifier for public client (no client_secret)', async () => {
const client = createPkceClient(); // No client secret
mockPkceTokenCall({ expectClientSecret: false });
const uri = `${config.redirectUri}?code=${config.code}&state=${config.state}`;
const token = await client.code.getToken(uri, {
body: { code_verifier: 'test_code_verifier' },
});
expect(token).toBeInstanceOf(ClientOAuth2Token);
expect(token.accessToken).toEqual(config.accessToken);
expect(token.refreshToken).toEqual(config.refreshToken);
});
it('should exchange authorization code with PKCE code_verifier for confidential client (with client_secret)', async () => {
const client = createPkceClient(config.clientSecret);
mockPkceTokenCall({ expectClientSecret: true });
const uri = `${config.redirectUri}?code=${config.code}&state=${config.state}`;
const token = await client.code.getToken(uri, {
body: { code_verifier: 'test_code_verifier' },
});
expect(token).toBeInstanceOf(ClientOAuth2Token);
expect(token.accessToken).toEqual(config.accessToken);
expect(token.refreshToken).toEqual(config.refreshToken);
});
});
describe('#refresh for PKCE flows', () => {
const mockRefreshCall = async (
options: {
expectClientSecret?: boolean;
} = {},
) => {
const { expectClientSecret = false } = options;
const nockScope = nock(config.baseUrl)
.post('/login/oauth/access_token')
.once()
.reply(200, function (this: nock.ReplyFnContext, _uri: string, requestBody: string) {
// Verify refresh token parameters
expect(requestBody).toContain(`refresh_token=${config.refreshToken}`);
expect(requestBody).toContain('grant_type=refresh_token');
// For confidential clients, credentials are in Basic Auth header
if (expectClientSecret) {
expect(this.req.headers.authorization).toMatch(/^Basic /);
// client_id should not be in body when using Basic Auth
expect(requestBody).not.toContain('client_id=');
} else {
// For public clients, client_id should be in body
expect(requestBody).toContain(`client_id=${config.clientId}`);
expect(this.req.headers.authorization).toBeUndefined();
}
return {
access_token: config.refreshedAccessToken,
refresh_token: config.refreshedRefreshToken,
token_type: 'Bearer',
};
});
return await new Promise<{ headers: Headers; body: string }>((resolve) => {
nockScope.once('request', (req: { headers: Headers; requestBodyBuffers: Buffer }) => {
resolve({
headers: req.headers,
body: req.requestBodyBuffers.toString('utf-8'),
});
});
});
};
it('should refresh token for PKCE public client without client_secret', async () => {
// Create token with public client (no client secret)
const publicClient = createPkceClient();
const token = new ClientOAuth2Token(publicClient, {
access_token: config.accessToken,
refresh_token: config.refreshToken,
token_type: 'Bearer',
});
const requestPromise = mockRefreshCall({ expectClientSecret: false });
const refreshedToken = await token.refresh();
const { body } = await requestPromise;
expect(refreshedToken).toBeInstanceOf(ClientOAuth2Token);
expect(refreshedToken.accessToken).toEqual(config.refreshedAccessToken);
expect(refreshedToken.refreshToken).toEqual(config.refreshedRefreshToken);
// Verify request body doesn't include client_secret but includes client_id
expect(body).toContain(`client_id=${config.clientId}`);
expect(body).not.toContain('client_secret=');
});
it('should refresh token for PKCE confidential client with client_secret', async () => {
// Create token with confidential client (with client secret)
const confidentialClient = createPkceClient(config.clientSecret);
const token = new ClientOAuth2Token(confidentialClient, {
access_token: config.accessToken,
refresh_token: config.refreshToken,
token_type: 'Bearer',
});
const requestPromise = mockRefreshCall({ expectClientSecret: true });
const refreshedToken = await token.refresh();
const { headers, body } = await requestPromise;
expect(refreshedToken).toBeInstanceOf(ClientOAuth2Token);
expect(refreshedToken.accessToken).toEqual(config.refreshedAccessToken);
expect(refreshedToken.refreshToken).toEqual(config.refreshedRefreshToken);
// Should use Basic Auth header for confidential clients
expect(headers?.authorization).toBe('Basic YWJjOjEyMw==');
expect(body).not.toContain('client_secret=');
});
});
});
});
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}