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 efb1639921a..1f60ad1943f 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 @@ -1428,6 +1428,50 @@ describe('Request Helper Functions', () => { expect(result).toEqual({ success: true }); expect(mockThis.helpers.httpRequest).toHaveBeenCalledTimes(2); }); + + test('should NOT retry on token-expired status when oAuth2Options.skipTokenRefresh is true (isN8nRequest path)', async () => { + mockThis.getCredentials.mockResolvedValue(makeCredentialData()); + const error401 = Object.assign(new Error('401'), { response: { status: 401 } }); + mockThis.helpers.httpRequest.mockRejectedValueOnce(error401); + + await expect( + requestOAuth2.call( + mockThis, + 'testOAuth2', + { method: 'GET', url: `${baseUrl}/data` }, + mockNode, + mockAdditionalData, + { skipTokenRefresh: true }, + true, + ), + ).rejects.toThrow('401'); + expect(mockThis.helpers.httpRequest).toHaveBeenCalledTimes(1); + expect( + mockAdditionalData.credentialsHelper.updateCredentialsOauthTokenData, + ).not.toHaveBeenCalled(); + }); + + test('should NOT retry on token-expired status when oAuth2Options.skipTokenRefresh is true (legacy request path)', async () => { + mockThis.getCredentials.mockResolvedValue(makeCredentialData()); + const error401 = Object.assign(new Error('401'), { statusCode: 401 }); + mockThis.helpers.request.mockRejectedValueOnce(error401); + + await expect( + requestOAuth2.call( + mockThis, + 'testOAuth2', + { method: 'GET', url: `${baseUrl}/data` }, + mockNode, + mockAdditionalData, + { skipTokenRefresh: true }, + false, + ), + ).rejects.toThrow('401'); + expect(mockThis.helpers.request).toHaveBeenCalledTimes(1); + expect( + mockAdditionalData.credentialsHelper.updateCredentialsOauthTokenData, + ).not.toHaveBeenCalled(); + }); }); describe('requestOAuth2 - client credentials initial token fetch', () => { 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 622a0d87fff..2f33ffada21 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 @@ -889,6 +889,7 @@ export async function requestOAuth2( }); } const tokenExpiredStatusCode = resolveTokenExpiredStatusCode(oAuth2Options, credentials); + const shouldSkipTokenRefresh = oAuth2Options?.skipTokenRefresh === true; const refreshCtx: RefreshOAuth2TokenContext = { credentials, @@ -916,7 +917,7 @@ export async function requestOAuth2( if (isN8nRequest) { return await this.helpers.httpRequest(newRequestOptions).catch(async (error: AxiosError) => { - if (error.response?.status === tokenExpiredStatusCode) { + if (!shouldSkipTokenRefresh && error.response?.status === tokenExpiredStatusCode) { return await retryWithNewToken(async (opts) => await this.helpers.httpRequest(opts)); } throw error; @@ -928,6 +929,7 @@ export async function requestOAuth2( .then((response) => { const requestOptions = newRequestOptions as any; if ( + !shouldSkipTokenRefresh && requestOptions.resolveWithFullResponse === true && requestOptions.simple === false && response.statusCode === tokenExpiredStatusCode @@ -937,7 +939,7 @@ export async function requestOAuth2( return response; }) .catch(async (error: IResponseError) => { - if (error.statusCode === tokenExpiredStatusCode) { + if (!shouldSkipTokenRefresh && error.statusCode === tokenExpiredStatusCode) { return await retryWithNewToken( async (opts) => await this.helpers.request(opts as IRequestOptions), ); diff --git a/packages/nodes-base/nodes/ClickUp/GenericFunctions.ts b/packages/nodes-base/nodes/ClickUp/GenericFunctions.ts index f816ff18437..88f6a646dc7 100644 --- a/packages/nodes-base/nodes/ClickUp/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ClickUp/GenericFunctions.ts @@ -41,6 +41,9 @@ export async function clickupApiRequest( const oAuth2Options: IOAuth2Options = { keepBearer: false, tokenType: 'Bearer', + // ClickUp's access token doesn't expire and + // ClickUp does not return refresh tokens + skipTokenRefresh: true, }; return await this.helpers.requestOAuth2.call( this, diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index d76ba3b2a5a..c7e8c83db0f 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -79,6 +79,7 @@ export interface IBinaryData { // credentials file. export interface IOAuth2Options { includeCredentialsOnRefreshOnBody?: boolean; + skipTokenRefresh?: boolean; property?: string; tokenType?: string; keepBearer?: boolean;