diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index b7db5a419d..a8fea0f4e9 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -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 { - 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 { - return Db.collections.Credentials.findOneBy({ id: credentialId }); -} - export function createCredentialsFromCredentialsEntity( credential: CredentialsEntity, encrypt = false, diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 7ce3a476e2..f94e5e088b 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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 => { - 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); - - // 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 // ---------------------------------------- diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts index 4ccee47717..9351eff35f 100644 --- a/packages/cli/src/controllers/index.ts +++ b/packages/cli/src/controllers/index.ts @@ -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'; diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts new file mode 100644 index 0000000000..274a2186f0 --- /dev/null +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -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 { + 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( + 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 { + return this.credentialsRepository.findOneBy({ id: credentialId }); + } +} diff --git a/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts b/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts new file mode 100644 index 0000000000..c9d3d31e00 --- /dev/null +++ b/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts @@ -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 { + const credential = await this.getCredential(req); + const additionalData = await this.getAdditionalData(req.user); + const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); + const oauthCredentials = this.applyDefaultsAndOverwrites( + 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); + + // 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( + 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); + } + } +} diff --git a/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts b/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts new file mode 100644 index 0000000000..219ef2f844 --- /dev/null +++ b/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts @@ -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 { + 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( + 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( + 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 = {}; + + 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(Buffer.from(encodedState, 'base64').toString(), { + errorMessage, + }); + if (typeof decoded.cid !== 'string' || typeof decoded.token !== 'string') { + throw new Error(errorMessage); + } + return decoded; + } +} diff --git a/packages/cli/src/credentials/oauth2Credential.api.ts b/packages/cli/src/credentials/oauth2Credential.api.ts deleted file mode 100644 index 8eda71a4ce..0000000000 --- a/packages/cli/src/credentials/oauth2Credential.api.ts +++ /dev/null @@ -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 => { - 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 = {}; - - 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, - ); - } - }, -); diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 29e473b885..d2a7388863 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -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 { 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; + } } diff --git a/packages/cli/src/decorators/Route.ts b/packages/cli/src/decorators/Route.ts index 38f0c8ab51..64dd96d293 100644 --- a/packages/cli/src/decorators/Route.ts +++ b/packages/cli/src/decorators/Route.ts @@ -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); }; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index 80e5ff7235..8a21580758 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -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); } diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index d118e6e6c9..4d0f7d43b8 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -15,6 +15,7 @@ export interface RouteMetadata { path: string; handlerName: string; middlewares: RequestHandler[]; + usesTemplates: boolean; } export type Controller = Record< diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index e02383d6fe..89a37d50fb 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -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(); } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index f50cdfda8c..de675a4a14 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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 }>; } } diff --git a/packages/cli/templates/oauth-callback.html b/packages/cli/templates/oauth-callback.handlebars similarity index 100% rename from packages/cli/templates/oauth-callback.html rename to packages/cli/templates/oauth-callback.handlebars diff --git a/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts b/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts new file mode 100644 index 0000000000..92d06080a9 --- /dev/null +++ b/packages/cli/test/unit/controllers/oAuth1Credential.controller.test.ts @@ -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({ + id: '123', + password: 'password', + authIdentities: [], + globalRoleId: '1', + }); + const credential = mock({ + 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({ 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({ 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({ 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', + }), + ); + }); + }); +}); diff --git a/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts b/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts new file mode 100644 index 0000000000..46c7bd6d05 --- /dev/null +++ b/packages/cli/test/unit/controllers/oAuth2Credential.controller.test.ts @@ -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({ + id: '123', + password: 'password', + authIdentities: [], + globalRoleId: '1', + }); + const credential = mock({ + 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({ 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({ 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({ 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({ + query: { code: undefined, state: undefined }, + }); + const res = mock(); + 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({ + query: { code: 'code', state: 'invalid-state' }, + }); + const res = mock(); + 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({ + query: { code: 'code', state: validState }, + }); + const res = mock(); + 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({ + query: { code: 'code', state: validState }, + }); + const res = mock(); + 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({ + query: { code: 'code', state: validState }, + originalUrl: '?code=code', + }); + const res = mock(); + 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'); + }); + }); +}); diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts new file mode 100644 index 0000000000..d04b6974a6 --- /dev/null +++ b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts @@ -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({ target: SharedCredentials }), + }); + Object.assign(entityManager, { connection: dataSource }); + const repository = Container.get(SharedCredentialsRepository); + + describe('findCredentialForUser', () => { + const credentialsId = 'cred_123'; + const sharedCredential = mock(); + sharedCredential.credentials = mock({ id: credentialsId }); + const owner = mock({ isOwner: true }); + const member = mock({ 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); + }); + }); +});