mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(core): Use consistent CSRF state validation across oAuth controllers (#9104)
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
committed by
GitHub
parent
3b93aae6dc
commit
b585777c79
@@ -1,6 +1,10 @@
|
|||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
import Csrf from 'csrf';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { Credentials } from 'n8n-core';
|
import { Credentials } from 'n8n-core';
|
||||||
import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||||
|
import { jsonParse, ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
@@ -17,6 +21,11 @@ import { UrlService } from '@/services/url.service';
|
|||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
|
||||||
|
export interface CsrfStateParam {
|
||||||
|
cid: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export abstract class AbstractOAuthController {
|
export abstract class AbstractOAuthController {
|
||||||
abstract oauthVersion: number;
|
abstract oauthVersion: number;
|
||||||
@@ -108,4 +117,37 @@ export abstract class AbstractOAuthController {
|
|||||||
protected async getCredentialWithoutUser(credentialId: string): Promise<ICredentialsDb | null> {
|
protected async getCredentialWithoutUser(credentialId: string): Promise<ICredentialsDb | null> {
|
||||||
return await this.credentialsRepository.findOneBy({ id: credentialId });
|
return await this.credentialsRepository.findOneBy({ id: credentialId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected 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')];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected 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 ApplicationError(errorMessage);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected verifyCsrfState(decrypted: ICredentialDataDecryptedObject, state: CsrfStateParam) {
|
||||||
|
const token = new Csrf();
|
||||||
|
return (
|
||||||
|
decrypted.csrfSecret === undefined ||
|
||||||
|
!token.verify(decrypted.csrfSecret as string, state.token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderCallbackError(res: Response, message: string, reason?: string) {
|
||||||
|
res.render('oauth-error-callback', { error: { message, reason } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import axios from 'axios';
|
|||||||
import type { RequestOptions } from 'oauth-1.0a';
|
import type { RequestOptions } from 'oauth-1.0a';
|
||||||
import clientOAuth1 from 'oauth-1.0a';
|
import clientOAuth1 from 'oauth-1.0a';
|
||||||
import { createHmac } from 'crypto';
|
import { createHmac } from 'crypto';
|
||||||
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
|
||||||
import { Get, RestController } from '@/decorators';
|
import { Get, RestController } from '@/decorators';
|
||||||
import { OAuthRequest } from '@/requests';
|
import { OAuthRequest } from '@/requests';
|
||||||
import { sendErrorResponse } from '@/ResponseHelper';
|
import { sendErrorResponse } from '@/ResponseHelper';
|
||||||
import { AbstractOAuthController } from './abstractOAuth.controller';
|
import { AbstractOAuthController, type CsrfStateParam } from './abstractOAuth.controller';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error';
|
|
||||||
|
|
||||||
interface OAuth1CredentialData {
|
interface OAuth1CredentialData {
|
||||||
signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1';
|
signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1';
|
||||||
@@ -44,6 +42,7 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
decryptedDataOriginal,
|
decryptedDataOriginal,
|
||||||
additionalData,
|
additionalData,
|
||||||
);
|
);
|
||||||
|
const [csrfSecret, state] = this.createCsrfState(credential.id);
|
||||||
|
|
||||||
const signatureMethod = oauthCredentials.signatureMethod;
|
const signatureMethod = oauthCredentials.signatureMethod;
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const oauthRequestData = {
|
const oauthRequestData = {
|
||||||
oauth_callback: `${this.baseUrl}/callback?cid=${credential.id}`,
|
oauth_callback: `${this.baseUrl}/callback?state=${state}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
|
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
|
||||||
@@ -90,6 +89,7 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
|
|
||||||
const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`;
|
const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`;
|
||||||
|
|
||||||
|
decryptedDataOriginal.csrfSecret = csrfSecret;
|
||||||
await this.encryptAndSaveData(credential, decryptedDataOriginal);
|
await this.encryptAndSaveData(credential, decryptedDataOriginal);
|
||||||
|
|
||||||
this.logger.verbose('OAuth1 authorization successful for new credential', {
|
this.logger.verbose('OAuth1 authorization successful for new credential', {
|
||||||
@@ -103,31 +103,31 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
/** Verify and store app code. Generate access tokens and store for respective credential */
|
/** Verify and store app code. Generate access tokens and store for respective credential */
|
||||||
@Get('/callback', { usesTemplates: true })
|
@Get('/callback', { usesTemplates: true })
|
||||||
async handleCallback(req: OAuthRequest.OAuth1Credential.Callback, res: Response) {
|
async handleCallback(req: OAuthRequest.OAuth1Credential.Callback, res: Response) {
|
||||||
|
const userId = req.user?.id;
|
||||||
try {
|
try {
|
||||||
const { oauth_verifier, oauth_token, cid: credentialId } = req.query;
|
const { oauth_verifier, oauth_token, state: encodedState } = req.query;
|
||||||
|
|
||||||
if (!oauth_verifier || !oauth_token) {
|
if (!oauth_verifier || !oauth_token || !encodedState) {
|
||||||
const errorResponse = new ServiceUnavailableError(
|
return this.renderCallbackError(
|
||||||
`Insufficient parameters for OAuth1 callback. Received following query parameters: ${JSON.stringify(
|
res,
|
||||||
req.query,
|
'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);
|
let state: CsrfStateParam;
|
||||||
|
try {
|
||||||
|
state = this.decodeCsrfState(encodedState);
|
||||||
|
} catch (error) {
|
||||||
|
return this.renderCallbackError(res, (error as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialId = state.cid;
|
||||||
|
const credential = await this.getCredentialWithoutUser(credentialId);
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
|
const errorMessage = 'OAuth1 callback failed because of insufficient permissions';
|
||||||
userId: req.user?.id,
|
this.logger.error(errorMessage, { userId, credentialId });
|
||||||
credentialId,
|
return this.renderCallbackError(res, errorMessage);
|
||||||
});
|
|
||||||
const errorResponse = new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL);
|
|
||||||
return sendErrorResponse(res, errorResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalData = await this.getAdditionalData(req.user);
|
const additionalData = await this.getAdditionalData(req.user);
|
||||||
@@ -138,6 +138,12 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
additionalData,
|
additionalData,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.verifyCsrfState(decryptedDataOriginal, state)) {
|
||||||
|
const errorMessage = 'The OAuth1 callback state is invalid!';
|
||||||
|
this.logger.debug(errorMessage, { userId, credentialId });
|
||||||
|
return this.renderCallbackError(res, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
const options: AxiosRequestConfig = {
|
const options: AxiosRequestConfig = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: oauthCredentials.accessTokenUrl,
|
url: oauthCredentials.accessTokenUrl,
|
||||||
@@ -152,10 +158,7 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
try {
|
try {
|
||||||
oauthToken = await axios.request(options);
|
oauthToken = await axios.request(options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Unable to fetch tokens for OAuth1 callback', {
|
this.logger.error('Unable to fetch tokens for OAuth1 callback', { userId, credentialId });
|
||||||
userId: req.user?.id,
|
|
||||||
credentialId,
|
|
||||||
});
|
|
||||||
const errorResponse = new NotFoundError('Unable to get access tokens!');
|
const errorResponse = new NotFoundError('Unable to get access tokens!');
|
||||||
return sendErrorResponse(res, errorResponse);
|
return sendErrorResponse(res, errorResponse);
|
||||||
}
|
}
|
||||||
@@ -171,14 +174,13 @@ export class OAuth1CredentialController extends AbstractOAuthController {
|
|||||||
await this.encryptAndSaveData(credential, decryptedDataOriginal);
|
await this.encryptAndSaveData(credential, decryptedDataOriginal);
|
||||||
|
|
||||||
this.logger.verbose('OAuth1 callback successful for new credential', {
|
this.logger.verbose('OAuth1 callback successful for new credential', {
|
||||||
userId: req.user?.id,
|
userId,
|
||||||
credentialId,
|
credentialId,
|
||||||
});
|
});
|
||||||
return res.render('oauth-callback');
|
return res.render('oauth-callback');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
|
this.logger.error('OAuth1 callback failed because of insufficient user permissions', {
|
||||||
userId: req.user?.id,
|
userId,
|
||||||
credentialId: req.query.cid,
|
|
||||||
});
|
});
|
||||||
// Error response
|
// Error response
|
||||||
return sendErrorResponse(res, error as Error);
|
return sendErrorResponse(res, error as Error);
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2';
|
import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2';
|
||||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||||
import Csrf from 'csrf';
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import pkceChallenge from 'pkce-challenge';
|
import pkceChallenge from 'pkce-challenge';
|
||||||
import * as qs from 'querystring';
|
import * as qs from 'querystring';
|
||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
import set from 'lodash/set';
|
import set from 'lodash/set';
|
||||||
import split from 'lodash/split';
|
import split from 'lodash/split';
|
||||||
import { ApplicationError, jsonParse, jsonStringify } from 'n8n-workflow';
|
|
||||||
import { Get, RestController } from '@/decorators';
|
import { Get, RestController } from '@/decorators';
|
||||||
|
import { jsonStringify } from 'n8n-workflow';
|
||||||
import { OAuthRequest } from '@/requests';
|
import { OAuthRequest } from '@/requests';
|
||||||
import { AbstractOAuthController } from './abstractOAuth.controller';
|
import { AbstractOAuthController, type CsrfStateParam } from './abstractOAuth.controller';
|
||||||
|
|
||||||
interface CsrfStateParam {
|
|
||||||
cid: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@RestController('/oauth2-credential')
|
@RestController('/oauth2-credential')
|
||||||
export class OAuth2CredentialController extends AbstractOAuthController {
|
export class OAuth2CredentialController extends AbstractOAuthController {
|
||||||
@@ -87,8 +81,8 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||||||
/** Verify and store app code. Generate access tokens and store for respective credential */
|
/** Verify and store app code. Generate access tokens and store for respective credential */
|
||||||
@Get('/callback', { usesTemplates: true })
|
@Get('/callback', { usesTemplates: true })
|
||||||
async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) {
|
async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) {
|
||||||
|
const userId = req.user?.id;
|
||||||
try {
|
try {
|
||||||
// realmId it's currently just use for the quickbook OAuth2 flow
|
|
||||||
const { code, state: encodedState } = req.query;
|
const { code, state: encodedState } = req.query;
|
||||||
if (!code || !encodedState) {
|
if (!code || !encodedState) {
|
||||||
return this.renderCallbackError(
|
return this.renderCallbackError(
|
||||||
@@ -105,13 +99,11 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||||||
return this.renderCallbackError(res, (error as Error).message);
|
return this.renderCallbackError(res, (error as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const credential = await this.getCredentialWithoutUser(state.cid);
|
const credentialId = state.cid;
|
||||||
|
const credential = await this.getCredentialWithoutUser(credentialId);
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
|
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
|
||||||
this.logger.error(errorMessage, {
|
this.logger.error(errorMessage, { userId, credentialId });
|
||||||
userId: req.user?.id,
|
|
||||||
credentialId: state.cid,
|
|
||||||
});
|
|
||||||
return this.renderCallbackError(res, errorMessage);
|
return this.renderCallbackError(res, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,16 +115,9 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||||||
additionalData,
|
additionalData,
|
||||||
);
|
);
|
||||||
|
|
||||||
const token = new Csrf();
|
if (this.verifyCsrfState(decryptedDataOriginal, state)) {
|
||||||
if (
|
|
||||||
decryptedDataOriginal.csrfSecret === undefined ||
|
|
||||||
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
|
|
||||||
) {
|
|
||||||
const errorMessage = 'The OAuth2 callback state is invalid!';
|
const errorMessage = 'The OAuth2 callback state is invalid!';
|
||||||
this.logger.debug(errorMessage, {
|
this.logger.debug(errorMessage, { userId, credentialId });
|
||||||
userId: req.user?.id,
|
|
||||||
credentialId: credential.id,
|
|
||||||
});
|
|
||||||
return this.renderCallbackError(res, errorMessage);
|
return this.renderCallbackError(res, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +156,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||||||
|
|
||||||
if (oauthToken === undefined) {
|
if (oauthToken === undefined) {
|
||||||
const errorMessage = 'Unable to get OAuth2 access tokens!';
|
const errorMessage = 'Unable to get OAuth2 access tokens!';
|
||||||
this.logger.error(errorMessage, {
|
this.logger.error(errorMessage, { userId, credentialId });
|
||||||
userId: req.user?.id,
|
|
||||||
credentialId: credential.id,
|
|
||||||
});
|
|
||||||
return this.renderCallbackError(res, errorMessage);
|
return this.renderCallbackError(res, errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +173,8 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||||||
await this.encryptAndSaveData(credential, decryptedDataOriginal);
|
await this.encryptAndSaveData(credential, decryptedDataOriginal);
|
||||||
|
|
||||||
this.logger.verbose('OAuth2 callback successful for credential', {
|
this.logger.verbose('OAuth2 callback successful for credential', {
|
||||||
userId: req.user?.id,
|
userId,
|
||||||
credentialId: credential.id,
|
credentialId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.render('oauth-callback');
|
return res.render('oauth-callback');
|
||||||
@@ -219,29 +201,4 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||||||
ignoreSSLIssues: credential.ignoreSSLIssues ?? false,
|
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 ApplicationError(errorMessage);
|
|
||||||
}
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export declare namespace OAuthRequest {
|
|||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{ oauth_verifier: string; oauth_token: string; cid: string }
|
{ oauth_verifier: string; oauth_token: string; state: string }
|
||||||
> & {
|
> & {
|
||||||
user?: User;
|
user?: User;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import nock from 'nock';
|
import nock from 'nock';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import Csrf from 'csrf';
|
||||||
import { Cipher } from 'n8n-core';
|
import { Cipher } from 'n8n-core';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
import { OAuth1CredentialController } from '@/controllers/oauth/oAuth1Credential.controller';
|
import { OAuth1CredentialController } from '@/controllers/oauth/oAuth1Credential.controller';
|
||||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import type { OAuthRequest } from '@/requests';
|
import type { OAuthRequest } from '@/requests';
|
||||||
import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
||||||
@@ -14,11 +16,11 @@ import { Logger } from '@/Logger';
|
|||||||
import { VariablesService } from '@/environments/variables/variables.service.ee';
|
import { VariablesService } from '@/environments/variables/variables.service.ee';
|
||||||
import { SecretsHelper } from '@/SecretsHelpers';
|
import { SecretsHelper } from '@/SecretsHelpers';
|
||||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||||
|
|
||||||
import { mockInstance } from '../../shared/mocking';
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
|
||||||
|
import { mockInstance } from '../../../shared/mocking';
|
||||||
|
|
||||||
describe('OAuth1CredentialController', () => {
|
describe('OAuth1CredentialController', () => {
|
||||||
mockInstance(Logger);
|
mockInstance(Logger);
|
||||||
mockInstance(ExternalHooks);
|
mockInstance(ExternalHooks);
|
||||||
@@ -30,6 +32,8 @@ describe('OAuth1CredentialController', () => {
|
|||||||
const credentialsHelper = mockInstance(CredentialsHelper);
|
const credentialsHelper = mockInstance(CredentialsHelper);
|
||||||
const credentialsRepository = mockInstance(CredentialsRepository);
|
const credentialsRepository = mockInstance(CredentialsRepository);
|
||||||
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
|
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
|
||||||
|
|
||||||
|
const csrfSecret = 'csrf-secret';
|
||||||
const user = mock<User>({
|
const user = mock<User>({
|
||||||
id: '123',
|
id: '123',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@@ -66,6 +70,8 @@ describe('OAuth1CredentialController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return a valid auth URI', async () => {
|
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);
|
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential);
|
||||||
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
|
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
|
||||||
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({
|
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({
|
||||||
@@ -75,7 +81,8 @@ describe('OAuth1CredentialController', () => {
|
|||||||
});
|
});
|
||||||
nock('https://example.domain')
|
nock('https://example.domain')
|
||||||
.post('/oauth/request_token', {
|
.post('/oauth/request_token', {
|
||||||
oauth_callback: 'http://localhost:5678/rest/oauth1-credential/callback?cid=1',
|
oauth_callback:
|
||||||
|
'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9',
|
||||||
})
|
})
|
||||||
.reply(200, { oauth_token: 'random-token' });
|
.reply(200, { oauth_token: 'random-token' });
|
||||||
cipher.encrypt.mockReturnValue('encrypted');
|
cipher.encrypt.mockReturnValue('encrypted');
|
||||||
@@ -92,6 +99,91 @@ describe('OAuth1CredentialController', () => {
|
|||||||
type: 'oAuth1Api',
|
type: 'oAuth1Api',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(cipher.encrypt).toHaveBeenCalledWith({ csrfSecret });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.OAuth1Credential.Callback>();
|
||||||
|
const res = mock<Response>();
|
||||||
|
req.query = { state: 'test' } as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||||
|
await controller.handleCallback(req, res);
|
||||||
|
|
||||||
|
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||||
|
error: {
|
||||||
|
message: 'Insufficient parameters for OAuth1 callback.',
|
||||||
|
reason: 'Received following query parameters: {"state":"test"}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the error page when `state` query param is invalid', async () => {
|
||||||
|
const req = mock<OAuthRequest.OAuth1Credential.Callback>();
|
||||||
|
const res = mock<Response>();
|
||||||
|
req.query = {
|
||||||
|
oauth_verifier: 'verifier',
|
||||||
|
oauth_token: 'token',
|
||||||
|
state: 'test',
|
||||||
|
} as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||||
|
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.OAuth1Credential.Callback>();
|
||||||
|
const res = mock<Response>();
|
||||||
|
req.query = {
|
||||||
|
oauth_verifier: 'verifier',
|
||||||
|
oauth_token: 'token',
|
||||||
|
state: validState,
|
||||||
|
} as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||||
|
await controller.handleCallback(req, res);
|
||||||
|
|
||||||
|
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||||
|
error: {
|
||||||
|
message: 'OAuth1 callback failed because of insufficient permissions',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the error page when state differs from the stored state in the credential', async () => {
|
||||||
|
credentialsRepository.findOneBy.mockResolvedValue(new CredentialsEntity());
|
||||||
|
credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret: 'invalid' });
|
||||||
|
|
||||||
|
const req = mock<OAuthRequest.OAuth1Credential.Callback>();
|
||||||
|
const res = mock<Response>();
|
||||||
|
req.query = {
|
||||||
|
oauth_verifier: 'verifier',
|
||||||
|
oauth_token: 'token',
|
||||||
|
state: validState,
|
||||||
|
} as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||||
|
|
||||||
|
await controller.handleCallback(req, res);
|
||||||
|
|
||||||
|
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||||
|
error: {
|
||||||
|
message: 'The OAuth1 callback state is invalid!',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -16,11 +16,11 @@ import { Logger } from '@/Logger';
|
|||||||
import { VariablesService } from '@/environments/variables/variables.service.ee';
|
import { VariablesService } from '@/environments/variables/variables.service.ee';
|
||||||
import { SecretsHelper } from '@/SecretsHelpers';
|
import { SecretsHelper } from '@/SecretsHelpers';
|
||||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||||
|
|
||||||
import { mockInstance } from '../../shared/mocking';
|
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
|
||||||
|
import { mockInstance } from '../../../shared/mocking';
|
||||||
|
|
||||||
describe('OAuth2CredentialController', () => {
|
describe('OAuth2CredentialController', () => {
|
||||||
mockInstance(Logger);
|
mockInstance(Logger);
|
||||||
mockInstance(SecretsHelper);
|
mockInstance(SecretsHelper);
|
||||||
Reference in New Issue
Block a user