Skip to content

Commit 73897c7

Browse files
authored
fix: Don't break oauth credentials when updating them and allow fixing broken oauth credentials by repeating the authorization flow (#12563)
1 parent bee7267 commit 73897c7

File tree

5 files changed

+152
-4
lines changed

5 files changed

+152
-4
lines changed

Diff for: packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts

+81-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Cipher } from 'n8n-core';
66
import { Logger } from 'n8n-core';
77
import nock from 'nock';
88

9-
import { Time } from '@/constants';
9+
import { CREDENTIAL_BLANKING_VALUE, Time } from '@/constants';
1010
import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller';
1111
import { CredentialsHelper } from '@/credentials-helper';
1212
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
@@ -257,5 +257,85 @@ describe('OAuth2CredentialController', () => {
257257
);
258258
expect(res.render).toHaveBeenCalledWith('oauth-callback');
259259
});
260+
261+
it('merges oauthTokenData if it already exists', async () => {
262+
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
263+
credentialsHelper.getDecrypted.mockResolvedValueOnce({
264+
csrfSecret,
265+
oauthTokenData: { token: true },
266+
});
267+
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
268+
nock('https://example.domain')
269+
.post(
270+
'/token',
271+
'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback',
272+
)
273+
.reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' });
274+
cipher.encrypt.mockReturnValue('encrypted');
275+
276+
await controller.handleCallback(req, res);
277+
278+
expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [
279+
expect.objectContaining({
280+
clientId: 'test-client-id',
281+
redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback',
282+
}),
283+
]);
284+
expect(cipher.encrypt).toHaveBeenCalledWith({
285+
oauthTokenData: {
286+
token: true,
287+
access_token: 'access-token',
288+
refresh_token: 'refresh-token',
289+
},
290+
});
291+
expect(credentialsRepository.update).toHaveBeenCalledWith(
292+
'1',
293+
expect.objectContaining({
294+
data: 'encrypted',
295+
id: '1',
296+
name: 'Test Credential',
297+
type: 'oAuth2Api',
298+
}),
299+
);
300+
expect(res.render).toHaveBeenCalledWith('oauth-callback');
301+
});
302+
303+
it('overwrites oauthTokenData if it is a string', async () => {
304+
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
305+
credentialsHelper.getDecrypted.mockResolvedValueOnce({
306+
csrfSecret,
307+
oauthTokenData: CREDENTIAL_BLANKING_VALUE,
308+
});
309+
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
310+
nock('https://example.domain')
311+
.post(
312+
'/token',
313+
'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback',
314+
)
315+
.reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' });
316+
cipher.encrypt.mockReturnValue('encrypted');
317+
318+
await controller.handleCallback(req, res);
319+
320+
expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [
321+
expect.objectContaining({
322+
clientId: 'test-client-id',
323+
redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback',
324+
}),
325+
]);
326+
expect(cipher.encrypt).toHaveBeenCalledWith({
327+
oauthTokenData: { access_token: 'access-token', refresh_token: 'refresh-token' },
328+
});
329+
expect(credentialsRepository.update).toHaveBeenCalledWith(
330+
'1',
331+
expect.objectContaining({
332+
data: 'encrypted',
333+
id: '1',
334+
name: 'Test Credential',
335+
type: 'oAuth2Api',
336+
}),
337+
);
338+
expect(res.render).toHaveBeenCalledWith('oauth-callback');
339+
});
260340
});
261341
});

Diff for: packages/cli/src/controllers/oauth/oauth2-credential.controller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
133133
set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code'));
134134
}
135135

136-
if (decryptedDataOriginal.oauthTokenData) {
136+
if (typeof decryptedDataOriginal.oauthTokenData === 'object') {
137137
// Only overwrite supplied data as some providers do for example just return the
138138
// refresh_token on the very first request and not on subsequent ones.
139139
Object.assign(decryptedDataOriginal.oauthTokenData, oauthToken.data);

Diff for: packages/cli/src/credentials/__tests__/credentials.controller.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('CredentialsController', () => {
3333
});
3434

3535
describe('createCredentials', () => {
36-
it('it should create new credentials and emit "credentials-created"', async () => {
36+
it('should create new credentials and emit "credentials-created"', async () => {
3737
// Arrange
3838

3939
const newCredentialsPayload = createNewCredentialsPayload();

Diff for: packages/cli/src/credentials/credentials.controller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export class CredentialsController {
198198
throw new BadRequestError('Managed credentials cannot be updated');
199199
}
200200

201-
const decryptedData = this.credentialsService.decrypt(credential);
201+
const decryptedData = this.credentialsService.decrypt(credential, true);
202202
const preparedCredentialData = await this.credentialsService.prepareUpdateData(
203203
req.body,
204204
decryptedData,

Diff for: packages/cli/test/integration/credentials/credentials.api.test.ts

+68
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Scope } from '@sentry/node';
44
import { Credentials } from 'n8n-core';
55
import { randomString } from 'n8n-workflow';
66

7+
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
78
import { CredentialsService } from '@/credentials/credentials.service';
89
import type { Project } from '@/databases/entities/project';
910
import type { User } from '@/databases/entities/user';
@@ -1164,6 +1165,73 @@ describe('PATCH /credentials/:id', () => {
11641165
expect(shellCredential.name).toBe(patchPayload.name); // updated
11651166
});
11661167

1168+
test('should not store redacted value in the db for oauthTokenData', async () => {
1169+
// ARRANGE
1170+
const credentialService = Container.get(CredentialsService);
1171+
const redactSpy = jest.spyOn(credentialService, 'redact').mockReturnValueOnce({
1172+
accessToken: CREDENTIAL_BLANKING_VALUE,
1173+
oauthTokenData: CREDENTIAL_BLANKING_VALUE,
1174+
});
1175+
1176+
const payload = randomCredentialPayload();
1177+
payload.data.oauthTokenData = { tokenData: true };
1178+
const savedCredential = await saveCredential(payload, {
1179+
user: owner,
1180+
role: 'credential:owner',
1181+
});
1182+
1183+
// ACT
1184+
const patchPayload = { ...payload, data: { foo: 'bar' } };
1185+
await authOwnerAgent.patch(`/credentials/${savedCredential.id}`).send(patchPayload).expect(200);
1186+
1187+
// ASSERT
1188+
const response = await authOwnerAgent
1189+
.get(`/credentials/${savedCredential.id}`)
1190+
.query({ includeData: true })
1191+
.expect(200);
1192+
1193+
const { id, data } = response.body.data;
1194+
1195+
expect(id).toBe(savedCredential.id);
1196+
expect(data).toEqual({
1197+
...patchPayload.data,
1198+
// should be the original
1199+
oauthTokenData: payload.data.oauthTokenData,
1200+
});
1201+
expect(redactSpy).not.toHaveBeenCalled();
1202+
});
1203+
1204+
test('should not allow to overwrite oauthTokenData', async () => {
1205+
// ARRANGE
1206+
const payload = randomCredentialPayload();
1207+
payload.data.oauthTokenData = { tokenData: true };
1208+
const savedCredential = await saveCredential(payload, {
1209+
user: owner,
1210+
role: 'credential:owner',
1211+
});
1212+
1213+
// ACT
1214+
const patchPayload = {
1215+
...payload,
1216+
data: { accessToken: 'new', oauthTokenData: { tokenData: false } },
1217+
};
1218+
await authOwnerAgent.patch(`/credentials/${savedCredential.id}`).send(patchPayload).expect(200);
1219+
1220+
// ASSERT
1221+
const response = await authOwnerAgent
1222+
.get(`/credentials/${savedCredential.id}`)
1223+
.query({ includeData: true })
1224+
.expect(200);
1225+
1226+
const { id, data } = response.body.data;
1227+
1228+
expect(id).toBe(savedCredential.id);
1229+
// was overwritten
1230+
expect(data.accessToken).toBe(patchPayload.data.accessToken);
1231+
// was not overwritten
1232+
expect(data.oauthTokenData).toEqual(payload.data.oauthTokenData);
1233+
});
1234+
11671235
test('should fail with invalid inputs', async () => {
11681236
const savedCredential = await saveCredential(randomCredentialPayload(), {
11691237
user: owner,

0 commit comments

Comments
 (0)