refactor(core): Convert OAuth1/OAuth2 routes to decorated controller classes (no-changelog) (#5973)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-11-03 17:20:54 +01:00 committed by GitHub
parent c6049a2e97
commit acec9bad71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 977 additions and 671 deletions

View file

@ -50,7 +50,6 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { NodeTypes } from '@/NodeTypes';
import { CredentialTypes } from '@/CredentialTypes';
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { whereClause } from './UserManagement/UserManagementHelper';
import { RESPONSE_ERROR_MESSAGES } from './constants';
import { isObjectLiteral } from './utils';
import { Logger } from '@/Logger';
@ -213,7 +212,7 @@ export class CredentialsHelper extends ICredentialsHelper {
/**
* Resolves the given value in case it is an expression
*/
resolveValue(
private resolveValue(
parameterValue: string,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
workflow: Workflow,
@ -248,9 +247,6 @@ export class CredentialsHelper extends ICredentialsHelper {
/**
* Returns the credentials instance
*
* @param {INodeCredentialsDetails} nodeCredential id and name to return instance of
* @param {string} type Type of the credential to return instance of
*/
async getCredentials(
nodeCredential: INodeCredentialsDetails,
@ -284,8 +280,6 @@ export class CredentialsHelper extends ICredentialsHelper {
/**
* Returns all the properties of the credentials with the given name
*
* @param {string} type The name of the type to return credentials off
*/
getCredentialsProperties(type: string): INodeProperties[] {
const credentialTypeData = this.credentialTypes.getByName(type);
@ -327,10 +321,6 @@ export class CredentialsHelper extends ICredentialsHelper {
/**
* Returns the decrypted credential data with applied overwrites
*
* @param {INodeCredentialsDetails} nodeCredentials id and name to return instance of
* @param {string} type Type of the credentials to return data of
* @param {boolean} [raw] Return the data as supplied without defaults or overwrites
*/
async getDecrypted(
additionalData: IWorkflowExecuteAdditionalData,
@ -443,10 +433,6 @@ export class CredentialsHelper extends ICredentialsHelper {
/**
* Updates credentials in the database
*
* @param {string} name Name of the credentials to set data of
* @param {string} type Type of the credentials to set data of
* @param {ICredentialDataDecryptedObject} data The data to set
*/
async updateCredentials(
nodeCredentials: INodeCredentialsDetails,
@ -804,36 +790,6 @@ export class CredentialsHelper extends ICredentialsHelper {
}
}
/**
* Get a credential if it has been shared with a user.
*/
export async function getCredentialForUser(
credentialId: string,
user: User,
): Promise<ICredentialsDb | null> {
const sharedCredential = await Db.collections.SharedCredentials.findOne({
relations: ['credentials'],
where: whereClause({
user,
entityType: 'credentials',
entityId: credentialId,
}),
});
if (!sharedCredential) return null;
return sharedCredential.credentials as ICredentialsDb;
}
/**
* Get a credential without user check
*/
export async function getCredentialWithoutUser(
credentialId: string,
): Promise<ICredentialsDb | null> {
return Db.collections.Credentials.findOneBy({ id: credentialId });
}
export function createCredentialsFromCredentialsEntity(
credential: CredentialsEntity,
encrypt = false,

View file

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
/* eslint-disable prefer-const */
/* eslint-disable @typescript-eslint/no-shadow */
@ -11,8 +10,7 @@ import assert from 'assert';
import { exec as callbackExec } from 'child_process';
import { access as fsAccess } from 'fs/promises';
import os from 'os';
import { join as pathJoin, resolve as pathResolve } from 'path';
import { createHmac } from 'crypto';
import { join as pathJoin } from 'path';
import { promisify } from 'util';
import cookieParser from 'cookie-parser';
import express from 'express';
@ -20,26 +18,15 @@ import { engine as expressHandlebars } from 'express-handlebars';
import type { ServeStaticOptions } from 'serve-static';
import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
import { Not, In } from 'typeorm';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import {
Credentials,
LoadMappingOptions,
LoadNodeParameterOptions,
LoadNodeListSearch,
} from 'n8n-core';
import { LoadMappingOptions, LoadNodeParameterOptions, LoadNodeListSearch } from 'n8n-core';
import type {
INodeCredentials,
INodeCredentialsDetails,
INodeListSearchResult,
INodeParameters,
INodePropertyOptions,
INodeTypeNameVersion,
WorkflowExecuteMode,
ICredentialTypes,
ExecutionStatus,
IExecutionsSummary,
@ -63,17 +50,14 @@ import {
inDevelopment,
inE2ETests,
N8N_VERSION,
RESPONSE_ERROR_MESSAGES,
TEMPLATES_DIR,
} from '@/constants';
import { credentialsController } from '@/credentials/credentials.controller';
import { oauth2CredentialController } from '@/credentials/oauth2Credential.api';
import type {
CurlHelper,
ExecutionRequest,
NodeListSearchRequest,
NodeParameterOptionsRequest,
OAuthRequest,
ResourceMapperRequest,
WorkflowRequest,
} from '@/requests';
@ -84,6 +68,8 @@ import {
MeController,
MFAController,
NodeTypesController,
OAuth1CredentialController,
OAuth2CredentialController,
OwnerController,
PasswordResetController,
TagsController,
@ -99,25 +85,14 @@ import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
import { whereClause } from '@/UserManagement/UserManagementHelper';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import type {
ICredentialsDb,
ICredentialsOverwrite,
IDiagnosticInfo,
IExecutionsStopData,
} from '@/Interfaces';
import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces';
import { ActiveExecutions } from '@/ActiveExecutions';
import {
CredentialsHelper,
getCredentialForUser,
getCredentialWithoutUser,
} from '@/CredentialsHelper';
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { CredentialTypes } from '@/CredentialTypes';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { NodeTypes } from '@/NodeTypes';
import * as ResponseHelper from '@/ResponseHelper';
import { WaitTracker } from '@/WaitTracker';
import * as WebhookHelpers from '@/WebhookHelpers';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { toHttpNodeParameters } from '@/CurlConverterHelper';
import { EventBusController } from '@/eventbus/eventBus.controller';
@ -285,6 +260,8 @@ export class Server extends AbstractServer {
new EventBusController(),
new EventBusControllerEE(),
Container.get(AuthController),
Container.get(OAuth1CredentialController),
Container.get(OAuth2CredentialController),
new OwnerController(
config,
logger,
@ -699,253 +676,6 @@ export class Server extends AbstractServer {
),
);
// ----------------------------------------
// OAuth1-Credential/Auth
// ----------------------------------------
// Authorize OAuth Data
this.app.get(
`/${this.restEndpoint}/oauth1-credential/auth`,
ResponseHelper.send(async (req: OAuthRequest.OAuth1Credential.Auth): Promise<string> => {
const { id: credentialId } = req.query;
if (!credentialId) {
this.logger.error('OAuth1 credential authorization failed due to missing credential ID');
throw new ResponseHelper.BadRequestError('Required credential ID is missing');
}
const credential = await getCredentialForUser(credentialId, req.user);
if (!credential) {
this.logger.error(
'OAuth1 credential authorization failed because the current user does not have the correct permissions',
{ userId: req.user.id },
);
throw new ResponseHelper.NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = Container.get(CredentialsHelper);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
additionalData,
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
decryptedDataOriginal,
credential.type,
mode,
);
const signatureMethod = oauthCredentials.signatureMethod as string;
const oAuthOptions: clientOAuth1.Options = {
consumer: {
key: oauthCredentials.consumerKey as string,
secret: oauthCredentials.consumerSecret as string,
},
signature_method: signatureMethod,
// eslint-disable-next-line @typescript-eslint/naming-convention
hash_function(base, key) {
let algorithm: string;
switch (signatureMethod) {
case 'HMAC-SHA256':
algorithm = 'sha256';
break;
case 'HMAC-SHA512':
algorithm = 'sha512';
break;
default:
algorithm = 'sha1';
break;
}
return createHmac(algorithm, key).update(base).digest('base64');
},
};
const oauthRequestData = {
oauth_callback: `${WebhookHelpers.getWebhookBaseUrl()}${
this.restEndpoint
}/oauth1-credential/callback?cid=${credentialId}`,
};
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
const oauth = new clientOAuth1(oAuthOptions);
const options: RequestOptions = {
method: 'POST',
url: oauthCredentials.requestTokenUrl as string,
data: oauthRequestData,
};
const data = oauth.toHeader(oauth.authorize(options));
// @ts-ignore
options.headers = data;
const response = await axios.request(options as Partial<AxiosRequestConfig>);
// Response comes as x-www-form-urlencoded string so convert it to JSON
const paramsParser = new URLSearchParams(response.data);
const responseJson = Object.fromEntries(paramsParser.entries());
const returnUri = `${oauthCredentials.authUrl as string}?oauth_token=${
responseJson.oauth_token
}`;
// Encrypt the data
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
credentials.setData(decryptedDataOriginal);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = new Date();
// Update the credentials in DB
await Db.collections.Credentials.update(credentialId, newCredentialsData);
this.logger.verbose('OAuth1 authorization successful for new credential', {
userId: req.user.id,
credentialId,
});
return returnUri;
}),
);
// Verify and store app code. Generate access tokens and store for respective credential.
this.app.get(
`/${this.restEndpoint}/oauth1-credential/callback`,
async (req: OAuthRequest.OAuth1Credential.Callback, res: express.Response) => {
try {
const { oauth_verifier, oauth_token, cid: credentialId } = req.query;
if (!oauth_verifier || !oauth_token) {
const errorResponse = new ResponseHelper.ServiceUnavailableError(
`Insufficient parameters for OAuth1 callback. Received following query parameters: ${JSON.stringify(
req.query,
)}`,
);
this.logger.error(
'OAuth1 callback failed because of insufficient parameters received',
{
userId: req.user?.id,
credentialId,
},
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const credential = await getCredentialWithoutUser(credentialId);
if (!credential) {
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user?.id,
credentialId,
});
const errorResponse = new ResponseHelper.NotFoundError(
RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = Container.get(CredentialsHelper);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
additionalData,
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
decryptedDataOriginal,
credential.type,
mode,
);
const options: AxiosRequestConfig = {
method: 'POST',
url: oauthCredentials.accessTokenUrl as string,
params: {
oauth_token,
oauth_verifier,
},
};
let oauthToken;
try {
oauthToken = await axios.request(options);
} catch (error) {
this.logger.error('Unable to fetch tokens for OAuth1 callback', {
userId: req.user?.id,
credentialId,
});
const errorResponse = new ResponseHelper.NotFoundError('Unable to get access tokens!');
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
// Response comes as x-www-form-urlencoded string so convert it to JSON
const paramParser = new URLSearchParams(oauthToken.data);
const oauthTokenJson = Object.fromEntries(paramParser.entries());
decryptedDataOriginal.oauthTokenData = oauthTokenJson;
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
credentials.setData(decryptedDataOriginal);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = new Date();
// Save the credentials in DB
await Db.collections.Credentials.update(credentialId, newCredentialsData);
this.logger.verbose('OAuth1 callback successful for new credential', {
userId: req.user?.id,
credentialId,
});
res.sendFile(pathResolve(TEMPLATES_DIR, 'oauth-callback.html'));
} catch (error) {
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user?.id,
credentialId: req.query.cid,
});
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// OAuth2-Credential
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/oauth2-credential`, oauth2CredentialController);
// ----------------------------------------
// Executions
// ----------------------------------------

View file

@ -3,6 +3,8 @@ export { LdapController } from './ldap.controller';
export { MeController } from './me.controller';
export { MFAController } from './mfa.controller';
export { NodeTypesController } from './nodeTypes.controller';
export { OAuth1CredentialController } from './oauth/oAuth1Credential.controller';
export { OAuth2CredentialController } from './oauth/oAuth2Credential.controller';
export { OwnerController } from './owner.controller';
export { PasswordResetController } from './passwordReset.controller';
export { TagsController } from './tags.controller';

View file

@ -0,0 +1,106 @@
import { Service } from 'typedi';
import { Credentials } from 'n8n-core';
import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import config from '@/config';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { User } from '@db/entities/User';
import { CredentialsRepository, SharedCredentialsRepository } from '@db/repositories';
import type { ICredentialsDb } from '@/Interfaces';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import type { OAuthRequest } from '@/requests';
import { BadRequestError, NotFoundError } from '@/ResponseHelper';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { CredentialsHelper } from '@/CredentialsHelper';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { Logger } from '@/Logger';
import { ExternalHooks } from '@/ExternalHooks';
@Service()
export abstract class AbstractOAuthController {
abstract oauthVersion: number;
constructor(
protected readonly logger: Logger,
protected readonly externalHooks: ExternalHooks,
private readonly credentialsHelper: CredentialsHelper,
private readonly credentialsRepository: CredentialsRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
) {}
get baseUrl() {
const restUrl = `${getInstanceBaseUrl()}/${config.getEnv('endpoints.rest')}`;
return `${restUrl}/oauth${this.oauthVersion}-credential`;
}
protected async getCredential(
req: OAuthRequest.OAuth2Credential.Auth,
): Promise<CredentialsEntity> {
const { id: credentialId } = req.query;
if (!credentialId) {
throw new BadRequestError('Required credential ID is missing');
}
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
req.user,
);
if (!credential) {
this.logger.error(
`OAuth${this.oauthVersion} credential authorization failed because the current user does not have the correct permissions`,
{ userId: req.user.id },
);
throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
}
return credential;
}
protected async getAdditionalData(user: User) {
return WorkflowExecuteAdditionalData.getBase(user.id);
}
protected async getDecryptedData(
credential: ICredentialsDb,
additionalData: IWorkflowExecuteAdditionalData,
) {
return this.credentialsHelper.getDecrypted(
additionalData,
credential,
credential.type,
'internal',
true,
);
}
protected applyDefaultsAndOverwrites<T>(
credential: ICredentialsDb,
decryptedData: ICredentialDataDecryptedObject,
additionalData: IWorkflowExecuteAdditionalData,
) {
return this.credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
decryptedData,
credential.type,
'internal',
) as unknown as T;
}
protected async encryptAndSaveData(
credential: ICredentialsDb,
decryptedData: ICredentialDataDecryptedObject,
) {
const credentials = new Credentials(credential, credential.type, credential.nodesAccess);
credentials.setData(decryptedData);
await this.credentialsRepository.update(credential.id, {
...credentials.getDataToSave(),
updatedAt: new Date(),
});
}
/** Get a credential without user check */
protected async getCredentialWithoutUser(credentialId: string): Promise<ICredentialsDb | null> {
return this.credentialsRepository.findOneBy({ id: credentialId });
}
}

View file

@ -0,0 +1,188 @@
import { Service } from 'typedi';
import { Response } from 'express';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import { createHmac } from 'crypto';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Authorized, Get, RestController } from '@/decorators';
import { OAuthRequest } from '@/requests';
import { NotFoundError, sendErrorResponse, ServiceUnavailableError } from '@/ResponseHelper';
import { AbstractOAuthController } from './abstractOAuth.controller';
interface OAuth1CredentialData {
signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1';
consumerKey: string;
consumerSecret: string;
authUrl: string;
accessTokenUrl: string;
requestTokenUrl: string;
}
const algorithmMap = {
/* eslint-disable @typescript-eslint/naming-convention */
'HMAC-SHA256': 'sha256',
'HMAC-SHA512': 'sha512',
'HMAC-SHA1': 'sha1',
/* eslint-enable */
} as const;
@Service()
@Authorized()
@RestController('/oauth1-credential')
export class OAuth1CredentialController extends AbstractOAuthController {
override oauthVersion = 1;
/** Get Authorization url */
@Get('/auth')
async getAuthUri(req: OAuthRequest.OAuth1Credential.Auth): Promise<string> {
const credential = await this.getCredential(req);
const additionalData = await this.getAdditionalData(req.user);
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth1CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
const signatureMethod = oauthCredentials.signatureMethod;
const oAuthOptions: clientOAuth1.Options = {
consumer: {
key: oauthCredentials.consumerKey,
secret: oauthCredentials.consumerSecret,
},
signature_method: signatureMethod,
// eslint-disable-next-line @typescript-eslint/naming-convention
hash_function(base, key) {
const algorithm = algorithmMap[signatureMethod] ?? 'sha1';
return createHmac(algorithm, key).update(base).digest('base64');
},
};
const oauthRequestData = {
oauth_callback: `${this.baseUrl}/callback?cid=${credential.id}`,
};
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
const oauth = new clientOAuth1(oAuthOptions);
const options: RequestOptions = {
method: 'POST',
url: oauthCredentials.requestTokenUrl,
data: oauthRequestData,
};
const data = oauth.toHeader(oauth.authorize(options));
// @ts-ignore
options.headers = data;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data: response } = await axios.request(options as Partial<AxiosRequestConfig>);
// Response comes as x-www-form-urlencoded string so convert it to JSON
const paramsParser = new URLSearchParams(response as string);
const responseJson = Object.fromEntries(paramsParser.entries());
const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`;
await this.encryptAndSaveData(credential, decryptedDataOriginal);
this.logger.verbose('OAuth1 authorization successful for new credential', {
userId: req.user.id,
credentialId: credential.id,
});
return returnUri;
}
/** Verify and store app code. Generate access tokens and store for respective credential */
@Get('/callback', { usesTemplates: true })
async handleCallback(req: OAuthRequest.OAuth1Credential.Callback, res: Response) {
try {
const { oauth_verifier, oauth_token, cid: credentialId } = req.query;
if (!oauth_verifier || !oauth_token) {
const errorResponse = new ServiceUnavailableError(
`Insufficient parameters for OAuth1 callback. Received following query parameters: ${JSON.stringify(
req.query,
)}`,
);
this.logger.error('OAuth1 callback failed because of insufficient parameters received', {
userId: req.user?.id,
credentialId,
});
return sendErrorResponse(res, errorResponse);
}
const credential = await this.getCredentialWithoutUser(credentialId);
if (!credential) {
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user?.id,
credentialId,
});
const errorResponse = new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
return sendErrorResponse(res, errorResponse);
}
const additionalData = await this.getAdditionalData(req.user);
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth1CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
const options: AxiosRequestConfig = {
method: 'POST',
url: oauthCredentials.accessTokenUrl,
params: {
oauth_token,
oauth_verifier,
},
};
let oauthToken;
try {
oauthToken = await axios.request(options);
} catch (error) {
this.logger.error('Unable to fetch tokens for OAuth1 callback', {
userId: req.user?.id,
credentialId,
});
const errorResponse = new NotFoundError('Unable to get access tokens!');
return sendErrorResponse(res, errorResponse);
}
// Response comes as x-www-form-urlencoded string so convert it to JSON
const paramParser = new URLSearchParams(oauthToken.data as string);
const oauthTokenJson = Object.fromEntries(paramParser.entries());
decryptedDataOriginal.oauthTokenData = oauthTokenJson;
await this.encryptAndSaveData(credential, decryptedDataOriginal);
this.logger.verbose('OAuth1 callback successful for new credential', {
userId: req.user?.id,
credentialId,
});
return res.render('oauth-callback');
} catch (error) {
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user?.id,
credentialId: req.query.cid,
});
// Error response
return sendErrorResponse(res, error as Error);
}
}
}

View file

@ -0,0 +1,262 @@
import { Service } from 'typedi';
import type { ClientOAuth2Options } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import Csrf from 'csrf';
import { Response } from 'express';
import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import omit from 'lodash/omit';
import set from 'lodash/set';
import split from 'lodash/split';
import type { OAuth2GrantType } from 'n8n-workflow';
import { jsonParse, jsonStringify } from 'n8n-workflow';
import { Authorized, Get, RestController } from '@/decorators';
import { OAuthRequest } from '@/requests';
import { AbstractOAuthController } from './abstractOAuth.controller';
interface OAuth2CredentialData {
clientId: string;
clientSecret?: string;
accessTokenUrl?: string;
authUrl?: string;
scope?: string;
authQueryParameters?: string;
authentication?: 'header' | 'body';
grantType: OAuth2GrantType;
ignoreSSLIssues?: boolean;
}
interface CsrfStateParam {
cid: string;
token: string;
}
@Service()
@Authorized()
@RestController('/oauth2-credential')
export class OAuth2CredentialController extends AbstractOAuthController {
override oauthVersion = 2;
/** Get Authorization url */
@Get('/auth')
async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise<string> {
const credential = await this.getCredential(req);
const additionalData = await this.getAdditionalData(req.user);
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
// At some point in the past we saved hidden scopes to credentials (but shouldn't)
// Delete scope before applying defaults to make sure new scopes are present on reconnect
// Generic Oauth2 API is an exception because it needs to save the scope
const genericOAuth2 = ['oAuth2Api', 'googleOAuth2Api', 'microsoftOAuth2Api'];
if (
decryptedDataOriginal?.scope &&
credential.type.includes('OAuth2') &&
!genericOAuth2.includes(credential.type)
) {
delete decryptedDataOriginal.scope;
}
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth2CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
// Generate a CSRF prevention token and send it as an OAuth2 state string
const [csrfSecret, state] = this.createCsrfState(credential.id);
const oAuthOptions = {
...this.convertCredentialToOptions(oauthCredentials),
state,
};
if (oauthCredentials.authQueryParameters) {
oAuthOptions.query = qs.parse(oauthCredentials.authQueryParameters);
}
await this.externalHooks.run('oauth2.authenticate', [oAuthOptions]);
decryptedDataOriginal.csrfSecret = csrfSecret;
if (oauthCredentials.grantType === 'pkce') {
const { code_verifier, code_challenge } = pkceChallenge();
oAuthOptions.query = {
...oAuthOptions.query,
code_challenge,
code_challenge_method: 'S256',
};
decryptedDataOriginal.codeVerifier = code_verifier;
}
await this.encryptAndSaveData(credential, decryptedDataOriginal);
const oAuthObj = new ClientOAuth2(oAuthOptions);
const returnUri = oAuthObj.code.getUri();
this.logger.verbose('OAuth2 authorization url created for credential', {
userId: req.user.id,
credentialId: credential.id,
});
return returnUri.toString();
}
/** Verify and store app code. Generate access tokens and store for respective credential */
@Get('/callback', { usesTemplates: true })
async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) {
try {
// realmId it's currently just use for the quickbook OAuth2 flow
const { code, state: encodedState } = req.query;
if (!code || !encodedState) {
return this.renderCallbackError(
res,
'Insufficient parameters for OAuth2 callback.',
`Received following query parameters: ${JSON.stringify(req.query)}`,
);
}
let state: CsrfStateParam;
try {
state = this.decodeCsrfState(encodedState);
} catch (error) {
return this.renderCallbackError(res, (error as Error).message);
}
const credential = await this.getCredentialWithoutUser(state.cid);
if (!credential) {
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
this.logger.error(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
return this.renderCallbackError(res, errorMessage);
}
const additionalData = await this.getAdditionalData(req.user);
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth2CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
const token = new Csrf();
if (
decryptedDataOriginal.csrfSecret === undefined ||
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
) {
const errorMessage = 'The OAuth2 callback state is invalid!';
this.logger.debug(errorMessage, {
userId: req.user?.id,
credentialId: credential.id,
});
return this.renderCallbackError(res, errorMessage);
}
let options: Partial<ClientOAuth2Options> = {};
const oAuthOptions = this.convertCredentialToOptions(oauthCredentials);
if (oauthCredentials.grantType === 'pkce') {
options = {
body: { code_verifier: decryptedDataOriginal.codeVerifier },
};
} else if (oauthCredentials.authentication === 'body') {
options = {
body: {
client_id: oAuthOptions.clientId,
client_secret: oAuthOptions.clientSecret,
},
};
delete oAuthOptions.clientSecret;
}
await this.externalHooks.run('oauth2.callback', [oAuthOptions]);
const oAuthObj = new ClientOAuth2(oAuthOptions);
const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');
const oauthToken = await oAuthObj.code.getToken(
`${oAuthOptions.redirectUri as string}?${queryParameters}`,
options,
);
if (Object.keys(req.query).length > 2) {
set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code'));
}
if (oauthToken === undefined) {
const errorMessage = 'Unable to get OAuth2 access tokens!';
this.logger.error(errorMessage, {
userId: req.user?.id,
credentialId: credential.id,
});
return this.renderCallbackError(res, errorMessage);
}
if (decryptedDataOriginal.oauthTokenData) {
// Only overwrite supplied data as some providers do for example just return the
// refresh_token on the very first request and not on subsequent ones.
Object.assign(decryptedDataOriginal.oauthTokenData, oauthToken.data);
} else {
// No data exists so simply set
decryptedDataOriginal.oauthTokenData = oauthToken.data;
}
delete decryptedDataOriginal.csrfSecret;
await this.encryptAndSaveData(credential, decryptedDataOriginal);
this.logger.verbose('OAuth2 callback successful for credential', {
userId: req.user?.id,
credentialId: credential.id,
});
return res.render('oauth-callback');
} catch (error) {
return this.renderCallbackError(
res,
(error as Error).message,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
'body' in error ? jsonStringify(error.body) : undefined,
);
}
}
private convertCredentialToOptions(credential: OAuth2CredentialData): ClientOAuth2Options {
return {
clientId: credential.clientId,
clientSecret: credential.clientSecret ?? '',
accessTokenUri: credential.accessTokenUrl ?? '',
authorizationUri: credential.authUrl ?? '',
redirectUri: `${this.baseUrl}/callback`,
scopes: split(credential.scope ?? 'openid', ','),
scopesSeparator: credential.scope?.includes(',') ? ',' : ' ',
ignoreSSLIssues: credential.ignoreSSLIssues ?? false,
};
}
private renderCallbackError(res: Response, message: string, reason?: string) {
res.render('oauth-error-callback', { error: { message, reason } });
}
private createCsrfState(credentialsId: string): [string, string] {
const token = new Csrf();
const csrfSecret = token.secretSync();
const state: CsrfStateParam = {
token: token.create(csrfSecret),
cid: credentialsId,
};
return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')];
}
private decodeCsrfState(encodedState: string): CsrfStateParam {
const errorMessage = 'Invalid state format';
const decoded = jsonParse<CsrfStateParam>(Buffer.from(encodedState, 'base64').toString(), {
errorMessage,
});
if (typeof decoded.cid !== 'string' || typeof decoded.token !== 'string') {
throw new Error(errorMessage);
}
return decoded;
}
}

View file

@ -1,327 +0,0 @@
import type { ClientOAuth2Options } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2';
import Csrf from 'csrf';
import express from 'express';
import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import get from 'lodash/get';
import omit from 'lodash/omit';
import set from 'lodash/set';
import split from 'lodash/split';
import unset from 'lodash/unset';
import { Credentials } from 'n8n-core';
import type { WorkflowExecuteMode, INodeCredentialsDetails } from 'n8n-workflow';
import { jsonStringify } from 'n8n-workflow';
import { resolve as pathResolve } from 'path';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import type { ICredentialsDb } from '@/Interfaces';
import { RESPONSE_ERROR_MESSAGES, TEMPLATES_DIR } from '@/constants';
import {
CredentialsHelper,
getCredentialForUser,
getCredentialWithoutUser,
} from '@/CredentialsHelper';
import type { OAuthRequest } from '@/requests';
import { ExternalHooks } from '@/ExternalHooks';
import config from '@/config';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import { Container } from 'typedi';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { Logger } from '@/Logger';
export const oauth2CredentialController = express.Router();
const restEndpoint = config.getEnv('endpoints.rest');
/**
* GET /oauth2-credential/auth
*
* Authorize OAuth Data
*/
oauth2CredentialController.get(
'/auth',
ResponseHelper.send(async (req: OAuthRequest.OAuth1Credential.Auth): Promise<string> => {
const { id: credentialId } = req.query;
if (!credentialId) {
throw new ResponseHelper.BadRequestError('Required credential ID is missing');
}
const credential = await getCredentialForUser(credentialId, req.user);
if (!credential) {
Container.get(Logger).error('Failed to authorize OAuth2 due to lack of permissions', {
userId: req.user.id,
credentialId,
});
throw new ResponseHelper.NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
const credentialType = credential.type;
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = Container.get(CredentialsHelper);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
additionalData,
credential as INodeCredentialsDetails,
credentialType,
mode,
true,
);
// At some point in the past we saved hidden scopes to credentials (but shouldn't)
// Delete scope before applying defaults to make sure new scopes are present on reconnect
// Generic Oauth2 API is an exception because it needs to save the scope
const genericOAuth2 = ['oAuth2Api', 'googleOAuth2Api', 'microsoftOAuth2Api'];
if (
decryptedDataOriginal?.scope &&
credentialType.includes('OAuth2') &&
!genericOAuth2.includes(credentialType)
) {
delete decryptedDataOriginal.scope;
}
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
decryptedDataOriginal,
credentialType,
mode,
);
const token = new Csrf();
// Generate a CSRF prevention token and send it as an OAuth2 state string
const csrfSecret = token.secretSync();
const state = {
token: token.create(csrfSecret),
cid: req.query.id,
};
const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64');
const scopes = get(oauthCredentials, 'scope', 'openid') as string;
const oAuthOptions: ClientOAuth2Options = {
clientId: get(oauthCredentials, 'clientId') as string,
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
scopes: split(scopes, ','),
scopesSeparator: scopes.includes(',') ? ',' : ' ',
state: stateEncodedStr,
};
const authQueryParameters = get(oauthCredentials, 'authQueryParameters', '') as string;
if (authQueryParameters) {
oAuthOptions.query = qs.parse(authQueryParameters);
}
await Container.get(ExternalHooks).run('oauth2.authenticate', [oAuthOptions]);
const oAuthObj = new ClientOAuth2(oAuthOptions);
// Encrypt the data
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credentialType,
credential.nodesAccess,
);
decryptedDataOriginal.csrfSecret = csrfSecret;
if (oauthCredentials.grantType === 'pkce') {
const { code_verifier, code_challenge } = pkceChallenge();
oAuthOptions.query = {
...oAuthOptions.query,
code_challenge,
code_challenge_method: 'S256',
};
decryptedDataOriginal.codeVerifier = code_verifier;
}
credentials.setData(decryptedDataOriginal);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = new Date();
// Update the credentials in DB
await Db.collections.Credentials.update(req.query.id, newCredentialsData);
Container.get(Logger).verbose('OAuth2 authorization url created for credential', {
userId: req.user.id,
credentialId,
});
return oAuthObj.code.getUri();
}),
);
const renderCallbackError = (res: express.Response, message: string, reason?: string) =>
res.render('oauth-error-callback', { error: { message, reason } });
/**
* GET /oauth2-credential/callback
*
* Verify and store app code. Generate access tokens and store for respective credential.
*/
oauth2CredentialController.get(
'/callback',
async (req: OAuthRequest.OAuth2Credential.Callback, res: express.Response) => {
try {
// realmId it's currently just use for the quickbook OAuth2 flow
const { code, state: stateEncoded } = req.query;
if (!code || !stateEncoded) {
return renderCallbackError(
res,
'Insufficient parameters for OAuth2 callback.',
`Received following query parameters: ${JSON.stringify(req.query)}`,
);
}
let state;
try {
state = JSON.parse(Buffer.from(stateEncoded, 'base64').toString()) as {
cid: string;
token: string;
};
} catch (error) {
return renderCallbackError(res, 'Invalid state format returned');
}
const credential = await getCredentialWithoutUser(state.cid);
if (!credential) {
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
Container.get(Logger).error(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
return renderCallbackError(res, errorMessage);
}
const additionalData = await WorkflowExecuteAdditionalData.getBase(state.cid);
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = Container.get(CredentialsHelper);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
additionalData,
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
decryptedDataOriginal,
credential.type,
mode,
);
const token = new Csrf();
if (
decryptedDataOriginal.csrfSecret === undefined ||
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
) {
const errorMessage = 'The OAuth2 callback state is invalid!';
Container.get(Logger).debug(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
return renderCallbackError(res, errorMessage);
}
let options: Partial<ClientOAuth2Options> = {};
const scopes = get(oauthCredentials, 'scope', 'openid') as string;
const oAuth2Parameters: ClientOAuth2Options = {
clientId: get(oauthCredentials, 'clientId') as string,
clientSecret: get(oauthCredentials, 'clientSecret', '') as string,
accessTokenUri: get(oauthCredentials, 'accessTokenUrl', '') as string,
authorizationUri: get(oauthCredentials, 'authUrl', '') as string,
redirectUri: `${getInstanceBaseUrl()}/${restEndpoint}/oauth2-credential/callback`,
scopes: split(scopes, ','),
scopesSeparator: scopes.includes(',') ? ',' : ' ',
ignoreSSLIssues: get(oauthCredentials, 'ignoreSSLIssues') as boolean,
};
if (oauthCredentials.grantType === 'pkce') {
options = {
body: { code_verifier: decryptedDataOriginal.codeVerifier },
};
} else if ((get(oauthCredentials, 'authentication', 'header') as string) === 'body') {
options = {
body: {
client_id: get(oauthCredentials, 'clientId') as string,
client_secret: get(oauthCredentials, 'clientSecret', '') as string,
},
};
// @ts-ignore
delete oAuth2Parameters.clientSecret;
}
await Container.get(ExternalHooks).run('oauth2.callback', [oAuth2Parameters]);
const oAuthObj = new ClientOAuth2(oAuth2Parameters);
const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');
const oauthToken = await oAuthObj.code.getToken(
`${oAuth2Parameters.redirectUri as string}?${queryParameters}`,
// @ts-ignore
options,
);
if (Object.keys(req.query).length > 2) {
set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code'));
}
if (oauthToken === undefined) {
const errorMessage = 'Unable to get OAuth2 access tokens!';
Container.get(Logger).error(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
return renderCallbackError(res, errorMessage);
}
if (decryptedDataOriginal.oauthTokenData) {
// Only overwrite supplied data as some providers do for example just return the
// refresh_token on the very first request and not on subsequent ones.
Object.assign(decryptedDataOriginal.oauthTokenData, oauthToken.data);
} else {
// No data exists so simply set
decryptedDataOriginal.oauthTokenData = oauthToken.data;
}
unset(decryptedDataOriginal, 'csrfSecret');
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
credentials.setData(decryptedDataOriginal);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = new Date();
// Save the credentials in DB
await Db.collections.Credentials.update(state.cid, newCredentialsData);
Container.get(Logger).verbose('OAuth2 callback successful for new credential', {
userId: req.user?.id,
credentialId: state.cid,
});
return res.sendFile(pathResolve(TEMPLATES_DIR, 'oauth-callback.html'));
} catch (error) {
return renderCallbackError(
res,
(error as Error).message,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
'body' in error ? jsonStringify(error.body) : undefined,
);
}
},
);

View file

@ -1,10 +1,24 @@
import { Service } from 'typedi';
import { DataSource, Repository } from 'typeorm';
import { SharedCredentials } from '../entities/SharedCredentials';
import type { User } from '../entities/User';
@Service()
export class SharedCredentialsRepository extends Repository<SharedCredentials> {
constructor(dataSource: DataSource) {
super(SharedCredentials, dataSource.manager);
}
/** Get a credential if it has been shared with a user */
async findCredentialForUser(credentialsId: string, user: User) {
const sharedCredential = await this.findOne({
relations: ['credentials'],
where: {
credentialsId,
...(!user.isOwner ? { userId: user.id } : {}),
},
});
if (!sharedCredential) return null;
return sharedCredential.credentials;
}
}

View file

@ -4,6 +4,7 @@ import type { Method, RouteMetadata } from './types';
interface RouteOptions {
middlewares?: RequestHandler[];
usesTemplates?: boolean;
}
const RouteFactory =
@ -18,6 +19,7 @@ const RouteFactory =
path,
middlewares: options.middlewares ?? [],
handlerName: String(handlerName),
usesTemplates: options.usesTemplates ?? false,
});
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
};

View file

@ -36,7 +36,8 @@ const authFreeRoutes: string[] = [];
export const canSkipAuth = (method: string, path: string): boolean =>
authFreeRoutes.includes(`${method.toLowerCase()} ${path}`);
export const registerController = (app: Application, config: Config, controller: object) => {
export const registerController = (app: Application, config: Config, cObj: object) => {
const controller = cObj as Controller;
const controllerClass = controller.constructor;
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
| string
@ -57,24 +58,22 @@ export const registerController = (app: Application, config: Config, controller:
const controllerMiddlewares = (
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
).map(
({ handlerName }) =>
(controller as Controller)[handlerName].bind(controller) as RequestHandler,
);
).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler);
routes.forEach(({ method, path, middlewares: routeMiddlewares, handlerName }) => {
routes.forEach(
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => {
const authRole = authRoles && (authRoles[handlerName] ?? authRoles['*']);
const handler = async (req: Request, res: Response) => controller[handlerName](req, res);
router[method](
path,
...(authRole ? [createAuthMiddleware(authRole)] : []),
...controllerMiddlewares,
...routeMiddlewares,
send(async (req: Request, res: Response) =>
(controller as Controller)[handlerName](req, res),
),
usesTemplates ? handler : send(handler),
);
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
});
},
);
app.use(prefix, router);
}

View file

@ -15,6 +15,7 @@ export interface RouteMetadata {
path: string;
handlerName: string;
middlewares: RequestHandler[];
usesTemplates: boolean;
}
export type Controller = Record<

View file

@ -89,9 +89,7 @@ export const setupAuthMiddlewares = (
canSkipAuth(req.method, req.path) ||
isAuthExcluded(req.url, ignoredEndpoints) ||
req.url.startsWith(`/${restEndpoint}/settings`) ||
isPostUsersId(req, restEndpoint) ||
req.url.startsWith(`/${restEndpoint}/oauth2-credential/callback`) ||
req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`)
isPostUsersId(req, restEndpoint)
) {
return next();
}

View file

@ -392,7 +392,7 @@ export declare namespace OAuthRequest {
}
namespace OAuth2Credential {
type Auth = OAuth1Credential.Auth;
type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>;
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
}
}

View file

@ -0,0 +1,96 @@
import nock from 'nock';
import Container from 'typedi';
import { Cipher } from 'n8n-core';
import { mock } from 'jest-mock-extended';
import { OAuth1CredentialController } from '@/controllers/oauth/oAuth1Credential.controller';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { User } from '@db/entities/User';
import type { OAuthRequest } from '@/requests';
import { BadRequestError, NotFoundError } from '@/ResponseHelper';
import { CredentialsRepository, SharedCredentialsRepository } from '@/databases/repositories';
import { ExternalHooks } from '@/ExternalHooks';
import { Logger } from '@/Logger';
import { VariablesService } from '@/environments/variables/variables.service';
import { SecretsHelper } from '@/SecretsHelpers';
import { CredentialsHelper } from '@/CredentialsHelper';
import { mockInstance } from '../../integration/shared/utils';
describe('OAuth1CredentialController', () => {
mockInstance(Logger);
mockInstance(ExternalHooks);
mockInstance(SecretsHelper);
mockInstance(VariablesService, {
getAllCached: async () => [],
});
const cipher = mockInstance(Cipher);
const credentialsHelper = mockInstance(CredentialsHelper);
const credentialsRepository = mockInstance(CredentialsRepository);
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
const user = mock<User>({
id: '123',
password: 'password',
authIdentities: [],
globalRoleId: '1',
});
const credential = mock<CredentialsEntity>({
id: '1',
name: 'Test Credential',
nodesAccess: [],
type: 'oAuth1Api',
});
const controller = Container.get(OAuth1CredentialController);
beforeEach(() => {
jest.resetAllMocks();
});
describe('getAuthUri', () => {
it('should throw a BadRequestError when credentialId is missing in the query', async () => {
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ query: { id: '' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError(
new BadRequestError('Required credential ID is missing'),
);
});
it('should throw a NotFoundError when no matching credential is found for the user', async () => {
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(null);
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user, query: { id: '1' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError(
new NotFoundError('Credential not found'),
);
});
it('should return a valid auth URI', async () => {
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({
requestTokenUrl: 'https://example.domain/oauth/request_token',
authUrl: 'https://example.domain/oauth/authorize',
signatureMethod: 'HMAC-SHA1',
});
nock('https://example.domain')
.post('/oauth/request_token', {
oauth_callback: 'http://localhost:5678/rest/oauth1-credential/callback?cid=1',
})
.reply(200, { oauth_token: 'random-token' });
cipher.encrypt.mockReturnValue('encrypted');
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user, query: { id: '1' } });
const authUri = await controller.getAuthUri(req);
expect(authUri).toEqual('https://example.domain/oauth/authorize?oauth_token=random-token');
expect(credentialsRepository.update).toHaveBeenCalledWith(
'1',
expect.objectContaining({
data: 'encrypted',
id: '1',
name: 'Test Credential',
type: 'oAuth1Api',
}),
);
});
});
});

View file

@ -0,0 +1,219 @@
import nock from 'nock';
import Container from 'typedi';
import Csrf from 'csrf';
import { type Response } from 'express';
import { Cipher } from 'n8n-core';
import { mock } from 'jest-mock-extended';
import { OAuth2CredentialController } from '@/controllers/oauth/oAuth2Credential.controller';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { User } from '@db/entities/User';
import type { OAuthRequest } from '@/requests';
import { BadRequestError, NotFoundError } from '@/ResponseHelper';
import { CredentialsRepository, SharedCredentialsRepository } from '@/databases/repositories';
import { ExternalHooks } from '@/ExternalHooks';
import { Logger } from '@/Logger';
import { VariablesService } from '@/environments/variables/variables.service';
import { SecretsHelper } from '@/SecretsHelpers';
import { CredentialsHelper } from '@/CredentialsHelper';
import { mockInstance } from '../../integration/shared/utils';
describe('OAuth2CredentialController', () => {
mockInstance(Logger);
mockInstance(SecretsHelper);
mockInstance(VariablesService, {
getAllCached: async () => [],
});
const cipher = mockInstance(Cipher);
const externalHooks = mockInstance(ExternalHooks);
const credentialsHelper = mockInstance(CredentialsHelper);
const credentialsRepository = mockInstance(CredentialsRepository);
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
const csrfSecret = 'csrf-secret';
const user = mock<User>({
id: '123',
password: 'password',
authIdentities: [],
globalRoleId: '1',
});
const credential = mock<CredentialsEntity>({
id: '1',
name: 'Test Credential',
nodesAccess: [],
type: 'oAuth2Api',
});
const controller = Container.get(OAuth2CredentialController);
beforeEach(() => {
jest.resetAllMocks();
});
describe('getAuthUri', () => {
it('should throw a BadRequestError when credentialId is missing in the query', async () => {
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ query: { id: '' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError(
new BadRequestError('Required credential ID is missing'),
);
});
it('should throw a NotFoundError when no matching credential is found for the user', async () => {
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(null);
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError(
new NotFoundError('Credential not found'),
);
});
it('should return a valid auth URI', async () => {
jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret);
jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token');
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({
clientId: 'test-client-id',
authUrl: 'https://example.domain/o/oauth2/v2/auth',
});
cipher.encrypt.mockReturnValue('encrypted');
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
const authUri = await controller.getAuthUri(req);
expect(authUri).toEqual(
'https://example.domain/o/oauth2/v2/auth?client_id=test-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback&response_type=code&state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9&scope=openid',
);
expect(credentialsRepository.update).toHaveBeenCalledWith(
'1',
expect.objectContaining({
data: 'encrypted',
id: '1',
name: 'Test Credential',
type: 'oAuth2Api',
}),
);
});
});
describe('handleCallback', () => {
const validState = Buffer.from(
JSON.stringify({
token: 'token',
cid: '1',
}),
).toString('base64');
it('should render the error page when required query params are missing', async () => {
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: undefined, state: undefined },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'Insufficient parameters for OAuth2 callback.',
reason: 'Received following query parameters: undefined',
},
});
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
});
it('should render the error page when `state` query param is invalid', async () => {
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: 'invalid-state' },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'Invalid state format',
},
});
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
});
it('should render the error page when credential is not found in DB', async () => {
credentialsRepository.findOneBy.mockResolvedValueOnce(null);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'OAuth2 callback failed because of insufficient permissions',
},
});
expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1);
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' });
});
it('should render the error page when csrfSecret on the saved credential does not match the state', async () => {
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(false);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'The OAuth2 callback state is invalid!',
},
});
expect(externalHooks.run).not.toHaveBeenCalled();
});
it('should exchange the code for a valid token, and save it to DB', async () => {
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({
clientId: 'test-client-id',
clientSecret: 'oauth-secret',
accessTokenUrl: 'https://example.domain/token',
});
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
nock('https://example.domain')
.post(
'/token',
'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback',
)
.reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' });
cipher.encrypt.mockReturnValue('encrypted');
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
originalUrl: '?code=code',
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [
expect.objectContaining({
clientId: 'test-client-id',
redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback',
}),
]);
expect(cipher.encrypt).toHaveBeenCalledWith({
oauthTokenData: { access_token: 'access-token', refresh_token: 'refresh-token' },
});
expect(credentialsRepository.update).toHaveBeenCalledWith(
'1',
expect.objectContaining({
data: 'encrypted',
id: '1',
name: 'Test Credential',
type: 'oAuth2Api',
}),
);
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
});
});

View file

@ -0,0 +1,60 @@
import { Container } from 'typedi';
import { DataSource, EntityManager, type EntityMetadata } from 'typeorm';
import { mock } from 'jest-mock-extended';
import type { User } from '@db/entities/User';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { mockInstance } from '../../integration/shared/utils/';
describe('SharedCredentialsRepository', () => {
const entityManager = mockInstance(EntityManager);
const dataSource = mockInstance(DataSource, {
manager: entityManager,
getMetadata: () => mock<EntityMetadata>({ target: SharedCredentials }),
});
Object.assign(entityManager, { connection: dataSource });
const repository = Container.get(SharedCredentialsRepository);
describe('findCredentialForUser', () => {
const credentialsId = 'cred_123';
const sharedCredential = mock<SharedCredentials>();
sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId });
const owner = mock<User>({ isOwner: true });
const member = mock<User>({ isOwner: false, id: 'test' });
beforeEach(() => {
jest.resetAllMocks();
});
test('should allow instance owner access to all credentials', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await repository.findCredentialForUser(credentialsId, owner);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: ['credentials'],
where: { credentialsId },
});
expect(credential).toEqual(sharedCredential.credentials);
});
test('should allow members', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await repository.findCredentialForUser(credentialsId, member);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: ['credentials'],
where: { credentialsId, userId: member.id },
});
expect(credential).toEqual(sharedCredential.credentials);
});
test('should return null when no shared credential is found', async () => {
entityManager.findOne.mockResolvedValueOnce(null);
const credential = await repository.findCredentialForUser(credentialsId, member);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: ['credentials'],
where: { credentialsId, userId: member.id },
});
expect(credential).toEqual(null);
});
});
});