fix(core): Improve the security on OAuth callback endpoints (#11593)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-11-19 16:17:20 +01:00 committed by GitHub
parent c265d44841
commit 274fcf45d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 285 additions and 173 deletions

View file

@ -44,6 +44,10 @@ const skipBrowserIdCheckEndpoints = [
// We need to exclude binary-data downloading endpoint because we can't send custom headers on `<embed>` tags
`/${restEndpoint}/binary-data/`,
// oAuth callback urls aren't called by the frontend. therefore we can't send custom header on these requests
`/${restEndpoint}/oauth1-credential/callback`,
`/${restEndpoint}/oauth2-credential/callback`,
];
@Service()

View file

@ -21,6 +21,7 @@ if (inE2ETests) {
process.env.N8N_PUBLIC_API_DISABLED = 'true';
process.env.SKIP_STATISTICS_EVENTS = 'true';
process.env.N8N_SECURE_COOKIE = 'false';
process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true';
}
// Load schema after process.env has been overwritten

View file

@ -5,9 +5,10 @@ import { Cipher } from 'n8n-core';
import nock from 'nock';
import Container from 'typedi';
import { Time } from '@/constants';
import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller';
import { CredentialsHelper } from '@/credentials-helper';
import { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
@ -47,8 +48,12 @@ describe('OAuth1CredentialController', () => {
const controller = Container.get(OAuth1CredentialController);
const timestamp = 1706750625678;
jest.useFakeTimers({ advanceTimers: true });
beforeEach(() => {
jest.resetAllMocks();
jest.setSystemTime(new Date(timestamp));
jest.clearAllMocks();
});
describe('getAuthUri', () => {
@ -76,13 +81,15 @@ describe('OAuth1CredentialController', () => {
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({
requestTokenUrl: 'https://example.domain/oauth/request_token',
authUrl: 'https://example.domain/oauth/authorize',
accessTokenUrl: 'https://example.domain/oauth/access_token',
signatureMethod: 'HMAC-SHA1',
});
nock('https://example.domain')
.post('/oauth/request_token', {
oauth_callback:
'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9',
'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSIsImNyZWF0ZWRBdCI6MTcwNjc1MDYyNTY3OCwidXNlcklkIjoiMTIzIn0=',
})
.once()
.reply(200, { oauth_token: 'random-token' });
cipher.encrypt.mockReturnValue('encrypted');
@ -107,14 +114,23 @@ describe('OAuth1CredentialController', () => {
JSON.stringify({
token: 'token',
cid: '1',
createdAt: timestamp,
}),
).toString('base64');
const res = mock<Response>();
const req = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: validState,
},
});
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);
const invalidReq = mock<OAuthRequest.OAuth1Credential.Callback>();
invalidReq.query = { state: 'test' } as OAuthRequest.OAuth1Credential.Callback['query'];
await controller.handleCallback(invalidReq, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
@ -126,14 +142,14 @@ describe('OAuth1CredentialController', () => {
});
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);
const invalidReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: 'test',
},
});
await controller.handleCallback(invalidReq, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
@ -146,18 +162,11 @@ describe('OAuth1CredentialController', () => {
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',
message: 'OAuth callback failed because of insufficient permissions',
},
});
expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1);
@ -165,24 +174,67 @@ describe('OAuth1CredentialController', () => {
});
it('should render the error page when state differs from the stored state in the credential', async () => {
credentialsRepository.findOneBy.mockResolvedValue(new CredentialsEntity());
credentialsRepository.findOneBy.mockResolvedValue(credential);
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!',
message: 'The OAuth callback state is invalid!',
},
});
});
it('should render the error page when state is older than 5 minutes', async () => {
credentialsRepository.findOneBy.mockResolvedValue(credential);
credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret });
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
jest.advanceTimersByTime(10 * Time.minutes.toMilliseconds);
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'The OAuth callback state is invalid!',
},
});
});
it('should exchange the code for a valid token, and save it to DB', async () => {
credentialsRepository.findOneBy.mockResolvedValue(credential);
credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret });
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({
requestTokenUrl: 'https://example.domain/oauth/request_token',
accessTokenUrl: 'https://example.domain/oauth/access_token',
signatureMethod: 'HMAC-SHA1',
});
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
nock('https://example.domain')
.post('/oauth/access_token', {
oauth_token: 'token',
oauth_verifier: 'verifier',
})
.once()
.reply(200, 'access_token=new_token');
cipher.encrypt.mockReturnValue('encrypted');
await controller.handleCallback(req, res);
expect(cipher.encrypt).toHaveBeenCalledWith({
oauthTokenData: { access_token: 'new_token' },
});
expect(credentialsRepository.update).toHaveBeenCalledWith(
'1',
expect.objectContaining({
data: 'encrypted',
id: '1',
name: 'Test Credential',
type: 'oAuth1Api',
}),
);
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
});
});

View file

@ -5,6 +5,7 @@ import { Cipher } from 'n8n-core';
import nock from 'nock';
import Container from 'typedi';
import { Time } from '@/constants';
import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller';
import { CredentialsHelper } from '@/credentials-helper';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
@ -47,8 +48,19 @@ describe('OAuth2CredentialController', () => {
const controller = Container.get(OAuth2CredentialController);
const timestamp = 1706750625678;
jest.useFakeTimers({ advanceTimers: true });
beforeEach(() => {
jest.resetAllMocks();
jest.setSystemTime(new Date(timestamp));
jest.clearAllMocks();
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({
clientId: 'test-client-id',
clientSecret: 'oauth-secret',
authUrl: 'https://example.domain/o/oauth2/v2/auth',
accessTokenUrl: 'https://example.domain/token',
});
});
describe('getAuthUri', () => {
@ -73,17 +85,20 @@ describe('OAuth2CredentialController', () => {
jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token');
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({
clientId: 'test-client-id',
authUrl: 'https://example.domain/o/oauth2/v2/auth',
});
cipher.encrypt.mockReturnValue('encrypted');
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
const authUri = await controller.getAuthUri(req);
expect(authUri).toEqual(
'https://example.domain/o/oauth2/v2/auth?client_id=test-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback&response_type=code&state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9&scope=openid',
'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=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSIsImNyZWF0ZWRBdCI6MTcwNjc1MDYyNTY3OCwidXNlcklkIjoiMTIzIn0%3D&scope=openid',
);
const state = new URL(authUri).searchParams.get('state');
expect(JSON.parse(Buffer.from(state!, 'base64').toString())).toEqual({
token: 'token',
cid: '1',
createdAt: timestamp,
userId: '123',
});
expect(credentialsRepository.update).toHaveBeenCalledWith(
'1',
expect.objectContaining({
@ -101,15 +116,21 @@ describe('OAuth2CredentialController', () => {
JSON.stringify({
token: 'token',
cid: '1',
createdAt: timestamp,
}),
).toString('base64');
const res = mock<Response>();
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
originalUrl: '?code=code',
});
it('should render the error page when required query params are missing', async () => {
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
const invalidReq = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: undefined, state: undefined },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
await controller.handleCallback(invalidReq, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
@ -121,11 +142,11 @@ describe('OAuth2CredentialController', () => {
});
it('should render the error page when `state` query param is invalid', async () => {
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
const invalidReq = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: 'invalid-state' },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
await controller.handleCallback(invalidReq, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
@ -138,15 +159,11 @@ describe('OAuth2CredentialController', () => {
it('should render the error page when credential is not found in DB', async () => {
credentialsRepository.findOneBy.mockResolvedValueOnce(null);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'OAuth2 callback failed because of insufficient permissions',
message: 'OAuth callback failed because of insufficient permissions',
},
});
expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1);
@ -158,27 +175,57 @@ describe('OAuth2CredentialController', () => {
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(false);
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'The OAuth2 callback state is invalid!',
message: 'The OAuth callback state is invalid!',
},
});
expect(externalHooks.run).not.toHaveBeenCalled();
});
it('should render the error page when state is older than 5 minutes', async () => {
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
jest.advanceTimersByTime(10 * Time.minutes.toMilliseconds);
await controller.handleCallback(req, res);
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'The OAuth callback state is invalid!',
},
});
expect(externalHooks.run).not.toHaveBeenCalled();
});
it('should render the error page when code exchange fails', async () => {
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
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(403, { error: 'Code could not be exchanged' });
await controller.handleCallback(req, res);
expect(externalHooks.run).toHaveBeenCalled();
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'Code could not be exchanged',
reason: '{"error":"Code could not be exchanged"}',
},
});
});
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(
@ -188,11 +235,6 @@ describe('OAuth2CredentialController', () => {
.reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' });
cipher.encrypt.mockReturnValue('encrypted');
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
query: { code: 'code', state: validState },
originalUrl: '?code=code',
});
const res = mock<Response>();
await controller.handleCallback(req, res);
expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [

View file

@ -6,24 +6,37 @@ import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } f
import { jsonParse, ApplicationError } from 'n8n-workflow';
import { Service } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import { CredentialsHelper } from '@/credentials-helper';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks';
import type { ICredentialsDb } from '@/interfaces';
import { Logger } from '@/logging/logger.service';
import type { OAuthRequest } from '@/requests';
import type { AuthenticatedRequest, OAuthRequest } from '@/requests';
import { UrlService } from '@/services/url.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
export interface CsrfStateParam {
type CsrfStateParam = {
/** Id of the oAuth credential in the DB */
cid: string;
/** Random CSRF token, used to verify the signature of the CSRF state */
token: string;
}
/** Creation timestamp of the CSRF state. Used for expiration. */
createdAt: number;
/** User who initiated OAuth flow, included to prevent cross-user credential hijacking. Optional only if `skipAuthOnOAuthCallback` is enabled. */
userId?: string;
};
const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds;
// TODO: Flip this flag in v2
// https://linear.app/n8n/issue/CAT-329
export const skipAuthOnOAuthCallback = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK !== 'true';
@Service()
export abstract class AbstractOAuthController {
@ -118,35 +131,73 @@ export abstract class AbstractOAuthController {
return await this.credentialsRepository.findOneBy({ id: credentialId });
}
createCsrfState(credentialsId: string): [string, string] {
createCsrfState(credentialsId: string, userId?: string): [string, string] {
const token = new Csrf();
const csrfSecret = token.secretSync();
const state: CsrfStateParam = {
token: token.create(csrfSecret),
cid: credentialsId,
createdAt: Date.now(),
userId,
};
return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')];
}
protected decodeCsrfState(encodedState: string): CsrfStateParam {
protected decodeCsrfState(encodedState: string, req: AuthenticatedRequest): 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);
}
if (decoded.userId !== req.user?.id) {
throw new AuthError('Unauthorized');
}
return decoded;
}
protected verifyCsrfState(decrypted: ICredentialDataDecryptedObject, state: CsrfStateParam) {
protected verifyCsrfState(
decrypted: ICredentialDataDecryptedObject & { csrfSecret?: string },
state: CsrfStateParam,
) {
const token = new Csrf();
return (
decrypted.csrfSecret === undefined ||
!token.verify(decrypted.csrfSecret as string, state.token)
Date.now() - state.createdAt <= MAX_CSRF_AGE &&
decrypted.csrfSecret !== undefined &&
token.verify(decrypted.csrfSecret, state.token)
);
}
protected async resolveCredential<T>(
req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback,
): Promise<[ICredentialsDb, ICredentialDataDecryptedObject, T]> {
const { state: encodedState } = req.query;
const state = this.decodeCsrfState(encodedState, req);
const credential = await this.getCredentialWithoutUser(state.cid);
if (!credential) {
throw new ApplicationError('OAuth callback failed because of insufficient permissions');
}
const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<T>(
credential,
decryptedDataOriginal,
additionalData,
);
if (!this.verifyCsrfState(decryptedDataOriginal, state)) {
throw new ApplicationError('The OAuth callback state is invalid!');
}
return [credential, decryptedDataOriginal, oauthCredentials];
}
protected renderCallbackError(res: Response, message: string, reason?: string) {
res.render('oauth-error-callback', { error: { message, reason } });
}

View file

@ -2,15 +2,14 @@ import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { createHmac } from 'crypto';
import { Response } from 'express';
import { ensureError, jsonStringify } from 'n8n-workflow';
import type { RequestOptions } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import { Get, RestController } from '@/decorators';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { OAuthRequest } from '@/requests';
import { sendErrorResponse } from '@/response-helper';
import { AbstractOAuthController, type CsrfStateParam } from './abstract-oauth.controller';
import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller';
interface OAuth1CredentialData {
signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1';
@ -42,7 +41,10 @@ export class OAuth1CredentialController extends AbstractOAuthController {
decryptedDataOriginal,
additionalData,
);
const [csrfSecret, state] = this.createCsrfState(credential.id);
const [csrfSecret, state] = this.createCsrfState(
credential.id,
skipAuthOnOAuthCallback ? undefined : req.user.id,
);
const signatureMethod = oauthCredentials.signatureMethod;
@ -101,7 +103,7 @@ export class OAuth1CredentialController extends AbstractOAuthController {
}
/** Verify and store app code. Generate access tokens and store for respective credential */
@Get('/callback', { usesTemplates: true, skipAuth: true })
@Get('/callback', { usesTemplates: true, skipAuth: skipAuthOnOAuthCallback })
async handleCallback(req: OAuthRequest.OAuth1Credential.Callback, res: Response) {
try {
const { oauth_verifier, oauth_token, state: encodedState } = req.query;
@ -114,71 +116,36 @@ export class OAuth1CredentialController extends AbstractOAuthController {
);
}
let state: CsrfStateParam;
try {
state = this.decodeCsrfState(encodedState);
} catch (error) {
return this.renderCallbackError(res, (error as Error).message);
}
const [credential, decryptedDataOriginal, oauthCredentials] =
await this.resolveCredential<OAuth1CredentialData>(req);
const credentialId = state.cid;
const credential = await this.getCredentialWithoutUser(credentialId);
if (!credential) {
const errorMessage = 'OAuth1 callback failed because of insufficient permissions';
this.logger.error(errorMessage, { credentialId });
return this.renderCallbackError(res, errorMessage);
}
const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth1CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
if (this.verifyCsrfState(decryptedDataOriginal, state)) {
const errorMessage = 'The OAuth1 callback state is invalid!';
this.logger.debug(errorMessage, { credentialId });
return this.renderCallbackError(res, errorMessage);
}
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', { credentialId });
const errorResponse = new NotFoundError('Unable to get access tokens!');
return sendErrorResponse(res, errorResponse);
}
const oauthToken = await axios.post<string>(oauthCredentials.accessTokenUrl, {
oauth_token,
oauth_verifier,
});
// Response comes as x-www-form-urlencoded string so convert it to JSON
const paramParser = new URLSearchParams(oauthToken.data as string);
const paramParser = new URLSearchParams(oauthToken.data);
const oauthTokenJson = Object.fromEntries(paramParser.entries());
delete decryptedDataOriginal.csrfSecret;
decryptedDataOriginal.oauthTokenData = oauthTokenJson;
await this.encryptAndSaveData(credential, decryptedDataOriginal);
this.logger.debug('OAuth1 callback successful for new credential', {
credentialId,
credentialId: credential.id,
});
return res.render('oauth-callback');
} catch (error) {
this.logger.error('OAuth1 callback failed because of insufficient user permissions');
return sendErrorResponse(res, error as Error);
} catch (e) {
const error = ensureError(e);
return this.renderCallbackError(
res,
error.message,
'body' in error ? jsonStringify(error.body) : undefined,
);
}
}
}

View file

@ -8,11 +8,11 @@ import { jsonStringify } from 'n8n-workflow';
import pkceChallenge from 'pkce-challenge';
import * as qs from 'querystring';
import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '@/constants';
import { Get, RestController } from '@/decorators';
import { OAuthRequest } from '@/requests';
import { AbstractOAuthController, type CsrfStateParam } from './abstract-oauth.controller';
import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '../../constants';
import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller';
@RestController('/oauth2-credential')
export class OAuth2CredentialController extends AbstractOAuthController {
@ -44,7 +44,10 @@ export class OAuth2CredentialController extends AbstractOAuthController {
);
// Generate a CSRF prevention token and send it as an OAuth2 state string
const [csrfSecret, state] = this.createCsrfState(credential.id);
const [csrfSecret, state] = this.createCsrfState(
credential.id,
skipAuthOnOAuthCallback ? undefined : req.user.id,
);
const oAuthOptions = {
...this.convertCredentialToOptions(oauthCredentials),
@ -82,7 +85,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
}
/** Verify and store app code. Generate access tokens and store for respective credential */
@Get('/callback', { usesTemplates: true, skipAuth: true })
@Get('/callback', { usesTemplates: true, skipAuth: skipAuthOnOAuthCallback })
async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) {
try {
const { code, state: encodedState } = req.query;
@ -94,34 +97,8 @@ export class OAuth2CredentialController extends AbstractOAuthController {
);
}
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) {
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
this.logger.error(errorMessage, { credentialId });
return this.renderCallbackError(res, errorMessage);
}
const additionalData = await this.getAdditionalData();
const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData);
const oauthCredentials = this.applyDefaultsAndOverwrites<OAuth2CredentialData>(
credential,
decryptedDataOriginal,
additionalData,
);
if (this.verifyCsrfState(decryptedDataOriginal, state)) {
const errorMessage = 'The OAuth2 callback state is invalid!';
this.logger.debug(errorMessage, { credentialId });
return this.renderCallbackError(res, errorMessage);
}
const [credential, decryptedDataOriginal, oauthCredentials] =
await this.resolveCredential<OAuth2CredentialData>(req);
let options: Partial<ClientOAuth2Options> = {};
@ -156,12 +133,6 @@ export class OAuth2CredentialController extends AbstractOAuthController {
set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code'));
}
if (oauthToken === undefined) {
const errorMessage = 'Unable to get OAuth2 access tokens!';
this.logger.error(errorMessage, { credentialId });
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.
@ -175,7 +146,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
await this.encryptAndSaveData(credential, decryptedDataOriginal);
this.logger.debug('OAuth2 callback successful for credential', {
credentialId,
credentialId: credential.id,
});
return res.render('oauth-callback');

View file

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

View file

@ -8,7 +8,7 @@ import { CredentialsHelper } from '@/credentials-helper';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { User } from '@/databases/entities/user';
import { saveCredential } from '@test-integration/db/credentials';
import { createOwner } from '@test-integration/db/users';
import { createMember, createOwner } from '@test-integration/db/users';
import * as testDb from '@test-integration/test-db';
import type { SuperAgentTest } from '@test-integration/types';
import { setupTestServer } from '@test-integration/utils';
@ -17,6 +17,7 @@ describe('OAuth2 API', () => {
const testServer = setupTestServer({ endpointGroups: ['oauth2'] });
let owner: User;
let anotherUser: User;
let ownerAgent: SuperAgentTest;
let credential: CredentialsEntity;
const credentialData = {
@ -32,6 +33,7 @@ describe('OAuth2 API', () => {
beforeAll(async () => {
owner = await createOwner();
anotherUser = await createMember();
ownerAgent = testServer.authAgentFor(owner);
});
@ -74,6 +76,28 @@ describe('OAuth2 API', () => {
});
});
it('should fail on auth when callback is called as another user', async () => {
const controller = Container.get(OAuth2CredentialController);
const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear();
const renderSpy = (Response.render = jest.fn(function () {
this.end();
}));
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
const [_, state] = csrfSpy.mock.results[0].value;
await testServer
.authAgentFor(anotherUser)
.get('/oauth2-credential/callback')
.query({ code: 'auth_code', state })
.expect(200);
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
error: { message: 'Unauthorized' },
});
});
it('should handle a valid callback without auth', async () => {
const controller = Container.get(OAuth2CredentialController);
const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear();
@ -87,7 +111,7 @@ describe('OAuth2 API', () => {
nock('https://test.domain').post('/oauth2/token').reply(200, { access_token: 'updated_token' });
await testServer.authlessAgent
await ownerAgent
.get('/oauth2-credential/callback')
.query({ code: 'auth_code', state })
.expect(200);