From 77fa591d5522f4461ad997f01820b5deb2b2feff Mon Sep 17 00:00:00 2001 From: Eugene Molodkin Date: Fri, 20 Mar 2026 19:19:18 +0100 Subject: [PATCH] fix(core): Send client_id and client_secret in body for OAuth2 PKCE flow When OAuth2 PKCE grant type was used with body authentication, client_id and client_secret were not included in the token exchange request. This caused failures for APIs (e.g. Mercado Livre, SoundCloud) that require these parameters even during PKCE flow. Ref: https://linear.app/n8n/issue/CAT-981 Co-Authored-By: Claude Opus 4.6 --- .../oauth2-credential.controller.test.ts | 63 +++++++++++++++++++ .../oauth/oauth2-credential.controller.ts | 35 ++++++----- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 3277350b150a..f204ae7942c6 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -529,6 +529,69 @@ describe('OAuth2CredentialController', () => { expect(oauthService.encryptAndSaveData).toHaveBeenCalled(); }); + it('should include client_id and client_secret in body for PKCE flow with body authentication', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetToken = jest.fn().mockResolvedValue({ + data: { access_token: 'new_token' }, + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getToken: mockGetToken, + }, + }) as any, + ); + + const mockResolvedCredential = mock({ id: '1' }); + const mockState = { + token: 'token', + cid: '1', + userId: '123', + origin: 'static-credential' as const, + createdAt: timestamp, + data: 'encrypted-data', + }; + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'pkce', + authentication: 'body', + }, + mockState, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + + const req = mock({ + query: { + code: 'auth_code', + state: validState, + }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state', + }); + + await controller.handleCallback(req, res); + + expect(mockGetToken).toHaveBeenCalledWith( + expect.stringContaining('code=auth_code'), + expect.objectContaining({ + body: expect.objectContaining({ + code_verifier: 'code_verifier', + client_id: 'client_id', + client_secret: 'client_secret', + }), + }), + ); + expect(oauthService.encryptAndSaveData).toHaveBeenCalled(); + }); + it('should handle body authentication method', async () => { const { ClientOAuth2 } = await import('@n8n/client-oauth2'); const mockGetToken = jest.fn().mockResolvedValue({ diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index fd0b061095a3..06d3bb63acb8 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -50,25 +50,32 @@ export class OAuth2CredentialController { const [credential, decryptedDataOriginal, oauthCredentials, state] = await this.oauthService.resolveCredential(req); - let options: Partial = {}; - const oAuthOptions = this.convertCredentialToOptions(oauthCredentials); - if (oauthCredentials.grantType === 'pkce') { - options = { - body: { code_verifier: decryptedDataOriginal.codeVerifier }, - }; - } else if (oauthCredentials.authentication === 'body') { - options = { - body: { - ...(oAuthOptions.body ?? {}), - client_id: oAuthOptions.clientId, - client_secret: oAuthOptions.clientSecret, - }, - }; + const isPkce = oauthCredentials.grantType === 'pkce'; + const isBodyAuth = oauthCredentials.authentication === 'body'; + + const body: Record = { ...(oAuthOptions.body ?? {}) }; + + if (isPkce) { + body.code_verifier = decryptedDataOriginal.codeVerifier as string; + } + + if (isBodyAuth) { + body.client_id = oAuthOptions.clientId; + if (oAuthOptions.clientSecret) { + body.client_secret = oAuthOptions.clientSecret; + } + // Remove clientSecret so code-flow.ts won't also send it + // via the Authorization header delete oAuthOptions.clientSecret; } + let options: Partial = {}; + if (isPkce || isBodyAuth) { + options = { body }; + } + await this.externalHooks.run('oauth2.callback', [oAuthOptions]); const oAuthObj = new ClientOAuth2(oAuthOptions);