diff --git a/packages/@n8n/client-oauth2/src/index.ts b/packages/@n8n/client-oauth2/src/index.ts index 02e7161ad68..feed92f42b3 100644 --- a/packages/@n8n/client-oauth2/src/index.ts +++ b/packages/@n8n/client-oauth2/src/index.ts @@ -2,4 +2,5 @@ export type { ClientOAuth2Options, ClientOAuth2RequestObject } from './client-oa export { ClientOAuth2 } from './client-oauth2'; export type { ClientOAuth2TokenData } from './client-oauth2-token'; export { ClientOAuth2Token } from './client-oauth2-token'; +export { AuthError } from './utils'; export type * from './types'; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts index d3e18fc4d25..4aa6aead037 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts @@ -1340,6 +1340,84 @@ describe('Request Helper Functions', () => { ).not.toHaveBeenCalled(); }); + test('should surface an actionable reconnect message when refresh returns invalid_grant', async () => { + mockThis.getCredentials.mockResolvedValue(mockCredentialData); + nock(baseUrl).post('/token').reply(400, { + error: 'invalid_grant', + error_description: 'Token has been expired or revoked.', + }); + + const promise = refreshOAuth2Token.call( + mockThis, + 'test-credentials-type', + mockNode, + mockAdditionalData, + ); + + await expect(promise).rejects.toThrow( + 'The credential "test-credentials-name" needs to be reconnected.', + ); + await expect(promise).rejects.toMatchObject({ + description: expect.stringContaining('reconnect'), + level: 'warning', + }); + expect( + mockAdditionalData.credentialsHelper.updateCredentialsOauthTokenData, + ).not.toHaveBeenCalled(); + }); + + test('should surface an actionable reconnect message when client credentials token fetch returns invalid_grant', async () => { + mockThis.getCredentials.mockResolvedValue({ + ...mockCredentialData, + grantType: 'clientCredentials', + }); + nock(baseUrl).post('/token').reply(400, { + error: 'invalid_grant', + }); + + await expect( + refreshOAuth2Token.call(mockThis, 'test-credentials-type', mockNode, mockAdditionalData), + ).rejects.toThrow('The credential "test-credentials-name" needs to be reconnected.'); + }); + + test('should rethrow non-invalid_grant token errors unchanged', async () => { + mockThis.getCredentials.mockResolvedValue(mockCredentialData); + nock(baseUrl).post('/token').reply(400, { + error: 'invalid_client', + }); + + await expect( + refreshOAuth2Token.call(mockThis, 'test-credentials-type', mockNode, mockAdditionalData), + ).rejects.not.toThrow('needs to be reconnected'); + }); + + test('should rethrow non-AuthError refresh failures unchanged', async () => { + mockThis.getCredentials.mockResolvedValue(mockCredentialData); + nock(baseUrl).post('/token').replyWithError(new Error('network exploded')); + + const promise = refreshOAuth2Token.call( + mockThis, + 'test-credentials-type', + mockNode, + mockAdditionalData, + ); + + await expect(promise).rejects.toThrow('network exploded'); + await expect(promise).rejects.not.toThrow('needs to be reconnected'); + }); + + test('should fall back to credential type when no credential name is set', async () => { + mockNode.credentials = { + 'test-credentials-type': { id: 'test-credentials-id', name: '' }, + }; + mockThis.getCredentials.mockResolvedValue(mockCredentialData); + nock(baseUrl).post('/token').reply(400, { error: 'invalid_grant' }); + + await expect( + refreshOAuth2Token.call(mockThis, 'test-credentials-type', mockNode, mockAdditionalData), + ).rejects.toThrow('The credential of type "test-credentials-type" needs to be reconnected.'); + }); + describe('JWE decryption via oauth-jwe proxy', () => { beforeEach(() => { nock.cleanAll(); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts index a2d41f005e9..ff2fd21dd27 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts @@ -14,7 +14,7 @@ import type { ClientOAuth2TokenData, OAuth2CredentialData, } from '@n8n/client-oauth2'; -import { ClientOAuth2 } from '@n8n/client-oauth2'; +import { AuthError, ClientOAuth2 } from '@n8n/client-oauth2'; import { Container } from '@n8n/di'; import type { AxiosError, AxiosHeaders, AxiosRequestConfig } from 'axios'; import axios from 'axios'; @@ -772,6 +772,26 @@ async function decryptOAuth2TokenDataIfConfigured { const { credentials, @@ -800,10 +820,17 @@ async function refreshOrFetchToken(ctx: RefreshOAuth2TokenContext): Promise