mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-21 02:56:40 -08:00
fix(core): Improve the security on OAuth callback endpoints (#11593)
This commit is contained in:
parent
c265d44841
commit
274fcf45d3
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', [
|
||||
|
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 }>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue