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 { 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,
|
||||
|
|
|
@ -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
|
||||
// ----------------------------------------
|
||||
|
|
|
@ -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';
|
||||
|
|
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 { 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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 }) => {
|
||||
const authRole = authRoles && (authRoles[handlerName] ?? authRoles['*']);
|
||||
router[method](
|
||||
path,
|
||||
...(authRole ? [createAuthMiddleware(authRole)] : []),
|
||||
...controllerMiddlewares,
|
||||
...routeMiddlewares,
|
||||
send(async (req: Request, res: Response) =>
|
||||
(controller as Controller)[handlerName](req, res),
|
||||
),
|
||||
);
|
||||
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
|
||||
});
|
||||
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,
|
||||
usesTemplates ? handler : send(handler),
|
||||
);
|
||||
if (!authRole || authRole === 'none') authFreeRoutes.push(`${method} ${prefix}${path}`);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(prefix, router);
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ export interface RouteMetadata {
|
|||
path: string;
|
||||
handlerName: string;
|
||||
middlewares: RequestHandler[];
|
||||
usesTemplates: boolean;
|
||||
}
|
||||
|
||||
export type Controller = Record<
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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 }>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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