mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-10 06:34:05 -08:00
refactor(core): Convert OAuth1/OAuth2 routes to decorated controller classes (no-changelog) (#5973)
This commit is contained in:
parent
c6049a2e97
commit
acec9bad71
|
@ -50,7 +50,6 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import { CredentialTypes } from '@/CredentialTypes';
|
import { CredentialTypes } from '@/CredentialTypes';
|
||||||
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
|
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
|
||||||
import { whereClause } from './UserManagement/UserManagementHelper';
|
|
||||||
import { RESPONSE_ERROR_MESSAGES } from './constants';
|
import { RESPONSE_ERROR_MESSAGES } from './constants';
|
||||||
import { isObjectLiteral } from './utils';
|
import { isObjectLiteral } from './utils';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
|
@ -213,7 +212,7 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
/**
|
/**
|
||||||
* Resolves the given value in case it is an expression
|
* Resolves the given value in case it is an expression
|
||||||
*/
|
*/
|
||||||
resolveValue(
|
private resolveValue(
|
||||||
parameterValue: string,
|
parameterValue: string,
|
||||||
additionalKeys: IWorkflowDataProxyAdditionalKeys,
|
additionalKeys: IWorkflowDataProxyAdditionalKeys,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
|
@ -248,9 +247,6 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the credentials instance
|
* 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(
|
async getCredentials(
|
||||||
nodeCredential: INodeCredentialsDetails,
|
nodeCredential: INodeCredentialsDetails,
|
||||||
|
@ -284,8 +280,6 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the properties of the credentials with the given name
|
* 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[] {
|
getCredentialsProperties(type: string): INodeProperties[] {
|
||||||
const credentialTypeData = this.credentialTypes.getByName(type);
|
const credentialTypeData = this.credentialTypes.getByName(type);
|
||||||
|
@ -327,10 +321,6 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the decrypted credential data with applied overwrites
|
* 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(
|
async getDecrypted(
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
@ -443,10 +433,6 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates credentials in the database
|
* 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(
|
async updateCredentials(
|
||||||
nodeCredentials: INodeCredentialsDetails,
|
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(
|
export function createCredentialsFromCredentialsEntity(
|
||||||
credential: CredentialsEntity,
|
credential: CredentialsEntity,
|
||||||
encrypt = false,
|
encrypt = false,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||||
/* eslint-disable prefer-const */
|
/* eslint-disable prefer-const */
|
||||||
/* eslint-disable @typescript-eslint/no-shadow */
|
/* eslint-disable @typescript-eslint/no-shadow */
|
||||||
|
@ -11,8 +10,7 @@ import assert from 'assert';
|
||||||
import { exec as callbackExec } from 'child_process';
|
import { exec as callbackExec } from 'child_process';
|
||||||
import { access as fsAccess } from 'fs/promises';
|
import { access as fsAccess } from 'fs/promises';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { join as pathJoin, resolve as pathResolve } from 'path';
|
import { join as pathJoin } from 'path';
|
||||||
import { createHmac } from 'crypto';
|
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
@ -20,26 +18,15 @@ import { engine as expressHandlebars } from 'express-handlebars';
|
||||||
import type { ServeStaticOptions } from 'serve-static';
|
import type { ServeStaticOptions } from 'serve-static';
|
||||||
import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
|
import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
|
||||||
import { Not, In } 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 {
|
import { LoadMappingOptions, LoadNodeParameterOptions, LoadNodeListSearch } from 'n8n-core';
|
||||||
Credentials,
|
|
||||||
LoadMappingOptions,
|
|
||||||
LoadNodeParameterOptions,
|
|
||||||
LoadNodeListSearch,
|
|
||||||
} from 'n8n-core';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
INodeCredentials,
|
INodeCredentials,
|
||||||
INodeCredentialsDetails,
|
|
||||||
INodeListSearchResult,
|
INodeListSearchResult,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodePropertyOptions,
|
INodePropertyOptions,
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
WorkflowExecuteMode,
|
|
||||||
ICredentialTypes,
|
ICredentialTypes,
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
IExecutionsSummary,
|
IExecutionsSummary,
|
||||||
|
@ -63,17 +50,14 @@ import {
|
||||||
inDevelopment,
|
inDevelopment,
|
||||||
inE2ETests,
|
inE2ETests,
|
||||||
N8N_VERSION,
|
N8N_VERSION,
|
||||||
RESPONSE_ERROR_MESSAGES,
|
|
||||||
TEMPLATES_DIR,
|
TEMPLATES_DIR,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { credentialsController } from '@/credentials/credentials.controller';
|
import { credentialsController } from '@/credentials/credentials.controller';
|
||||||
import { oauth2CredentialController } from '@/credentials/oauth2Credential.api';
|
|
||||||
import type {
|
import type {
|
||||||
CurlHelper,
|
CurlHelper,
|
||||||
ExecutionRequest,
|
ExecutionRequest,
|
||||||
NodeListSearchRequest,
|
NodeListSearchRequest,
|
||||||
NodeParameterOptionsRequest,
|
NodeParameterOptionsRequest,
|
||||||
OAuthRequest,
|
|
||||||
ResourceMapperRequest,
|
ResourceMapperRequest,
|
||||||
WorkflowRequest,
|
WorkflowRequest,
|
||||||
} from '@/requests';
|
} from '@/requests';
|
||||||
|
@ -84,6 +68,8 @@ import {
|
||||||
MeController,
|
MeController,
|
||||||
MFAController,
|
MFAController,
|
||||||
NodeTypesController,
|
NodeTypesController,
|
||||||
|
OAuth1CredentialController,
|
||||||
|
OAuth2CredentialController,
|
||||||
OwnerController,
|
OwnerController,
|
||||||
PasswordResetController,
|
PasswordResetController,
|
||||||
TagsController,
|
TagsController,
|
||||||
|
@ -99,25 +85,14 @@ import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
|
||||||
import { whereClause } from '@/UserManagement/UserManagementHelper';
|
import { whereClause } from '@/UserManagement/UserManagementHelper';
|
||||||
import { UserManagementMailer } from '@/UserManagement/email';
|
import { UserManagementMailer } from '@/UserManagement/email';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import type {
|
import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces';
|
||||||
ICredentialsDb,
|
|
||||||
ICredentialsOverwrite,
|
|
||||||
IDiagnosticInfo,
|
|
||||||
IExecutionsStopData,
|
|
||||||
} from '@/Interfaces';
|
|
||||||
import { ActiveExecutions } from '@/ActiveExecutions';
|
import { ActiveExecutions } from '@/ActiveExecutions';
|
||||||
import {
|
|
||||||
CredentialsHelper,
|
|
||||||
getCredentialForUser,
|
|
||||||
getCredentialWithoutUser,
|
|
||||||
} from '@/CredentialsHelper';
|
|
||||||
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
|
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
|
||||||
import { CredentialTypes } from '@/CredentialTypes';
|
import { CredentialTypes } from '@/CredentialTypes';
|
||||||
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
import { WaitTracker } from '@/WaitTracker';
|
import { WaitTracker } from '@/WaitTracker';
|
||||||
import * as WebhookHelpers from '@/WebhookHelpers';
|
|
||||||
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
|
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
|
||||||
import { toHttpNodeParameters } from '@/CurlConverterHelper';
|
import { toHttpNodeParameters } from '@/CurlConverterHelper';
|
||||||
import { EventBusController } from '@/eventbus/eventBus.controller';
|
import { EventBusController } from '@/eventbus/eventBus.controller';
|
||||||
|
@ -285,6 +260,8 @@ export class Server extends AbstractServer {
|
||||||
new EventBusController(),
|
new EventBusController(),
|
||||||
new EventBusControllerEE(),
|
new EventBusControllerEE(),
|
||||||
Container.get(AuthController),
|
Container.get(AuthController),
|
||||||
|
Container.get(OAuth1CredentialController),
|
||||||
|
Container.get(OAuth2CredentialController),
|
||||||
new OwnerController(
|
new OwnerController(
|
||||||
config,
|
config,
|
||||||
logger,
|
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
|
// Executions
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
|
|
|
@ -3,6 +3,8 @@ export { LdapController } from './ldap.controller';
|
||||||
export { MeController } from './me.controller';
|
export { MeController } from './me.controller';
|
||||||
export { MFAController } from './mfa.controller';
|
export { MFAController } from './mfa.controller';
|
||||||
export { NodeTypesController } from './nodeTypes.controller';
|
export { NodeTypesController } from './nodeTypes.controller';
|
||||||
|
export { OAuth1CredentialController } from './oauth/oAuth1Credential.controller';
|
||||||
|
export { OAuth2CredentialController } from './oauth/oAuth2Credential.controller';
|
||||||
export { OwnerController } from './owner.controller';
|
export { OwnerController } from './owner.controller';
|
||||||
export { PasswordResetController } from './passwordReset.controller';
|
export { PasswordResetController } from './passwordReset.controller';
|
||||||
export { TagsController } from './tags.controller';
|
export { TagsController } from './tags.controller';
|
||||||
|
|
106
packages/cli/src/controllers/oauth/abstractOAuth.controller.ts
Normal file
106
packages/cli/src/controllers/oauth/abstractOAuth.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
|
@ -1,10 +1,24 @@
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { SharedCredentials } from '../entities/SharedCredentials';
|
import { SharedCredentials } from '../entities/SharedCredentials';
|
||||||
|
import type { User } from '../entities/User';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
super(SharedCredentials, dataSource.manager);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { Method, RouteMetadata } from './types';
|
||||||
|
|
||||||
interface RouteOptions {
|
interface RouteOptions {
|
||||||
middlewares?: RequestHandler[];
|
middlewares?: RequestHandler[];
|
||||||
|
usesTemplates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RouteFactory =
|
const RouteFactory =
|
||||||
|
@ -18,6 +19,7 @@ const RouteFactory =
|
||||||
path,
|
path,
|
||||||
middlewares: options.middlewares ?? [],
|
middlewares: options.middlewares ?? [],
|
||||||
handlerName: String(handlerName),
|
handlerName: String(handlerName),
|
||||||
|
usesTemplates: options.usesTemplates ?? false,
|
||||||
});
|
});
|
||||||
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
|
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
|
||||||
};
|
};
|
||||||
|
|
|
@ -36,7 +36,8 @@ const authFreeRoutes: string[] = [];
|
||||||
export const canSkipAuth = (method: string, path: string): boolean =>
|
export const canSkipAuth = (method: string, path: string): boolean =>
|
||||||
authFreeRoutes.includes(`${method.toLowerCase()} ${path}`);
|
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 controllerClass = controller.constructor;
|
||||||
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
|
const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as
|
||||||
| string
|
| string
|
||||||
|
@ -57,24 +58,22 @@ export const registerController = (app: Application, config: Config, controller:
|
||||||
|
|
||||||
const controllerMiddlewares = (
|
const controllerMiddlewares = (
|
||||||
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
|
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
|
||||||
).map(
|
).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler);
|
||||||
({ handlerName }) =>
|
|
||||||
(controller as Controller)[handlerName].bind(controller) as RequestHandler,
|
|
||||||
);
|
|
||||||
|
|
||||||
routes.forEach(({ method, path, middlewares: routeMiddlewares, handlerName }) => {
|
routes.forEach(
|
||||||
const authRole = authRoles && (authRoles[handlerName] ?? authRoles['*']);
|
({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => {
|
||||||
router[method](
|
const authRole = authRoles && (authRoles[handlerName] ?? authRoles['*']);
|
||||||
path,
|
const handler = async (req: Request, res: Response) => controller[handlerName](req, res);
|
||||||
...(authRole ? [createAuthMiddleware(authRole)] : []),
|
router[method](
|
||||||
...controllerMiddlewares,
|
path,
|
||||||
...routeMiddlewares,
|
...(authRole ? [createAuthMiddleware(authRole)] : []),
|
||||||
send(async (req: Request, res: Response) =>
|
...controllerMiddlewares,
|
||||||
(controller as Controller)[handlerName](req, res),
|
...routeMiddlewares,
|
||||||
),
|
usesTemplates ? handler : send(handler),
|
||||||
);
|
);
|
||||||
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
|
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.use(prefix, router);
|
app.use(prefix, router);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ export interface RouteMetadata {
|
||||||
path: string;
|
path: string;
|
||||||
handlerName: string;
|
handlerName: string;
|
||||||
middlewares: RequestHandler[];
|
middlewares: RequestHandler[];
|
||||||
|
usesTemplates: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Controller = Record<
|
export type Controller = Record<
|
||||||
|
|
|
@ -89,9 +89,7 @@ export const setupAuthMiddlewares = (
|
||||||
canSkipAuth(req.method, req.path) ||
|
canSkipAuth(req.method, req.path) ||
|
||||||
isAuthExcluded(req.url, ignoredEndpoints) ||
|
isAuthExcluded(req.url, ignoredEndpoints) ||
|
||||||
req.url.startsWith(`/${restEndpoint}/settings`) ||
|
req.url.startsWith(`/${restEndpoint}/settings`) ||
|
||||||
isPostUsersId(req, restEndpoint) ||
|
isPostUsersId(req, restEndpoint)
|
||||||
req.url.startsWith(`/${restEndpoint}/oauth2-credential/callback`) ||
|
|
||||||
req.url.startsWith(`/${restEndpoint}/oauth1-credential/callback`)
|
|
||||||
) {
|
) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -392,7 +392,7 @@ export declare namespace OAuthRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace OAuth2Credential {
|
namespace OAuth2Credential {
|
||||||
type Auth = OAuth1Credential.Auth;
|
type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>;
|
||||||
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
|
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue