mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
fix(core): Show actionable message when OAuth2 token refresh fails (#30460)
This commit is contained in:
parent
cf68ef1b8b
commit
5e9a8a071f
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<T extends IDataObject | undefi
|
|||
return await proxy.decryptOAuth2TokenData(tokenData);
|
||||
}
|
||||
|
||||
function isRevokedOAuth2GrantError(error: unknown): boolean {
|
||||
if (!(error instanceof AuthError)) return false;
|
||||
const body = error.body as { error?: unknown } | undefined;
|
||||
return body?.error === 'invalid_grant';
|
||||
}
|
||||
|
||||
function buildOAuth2ReconnectError(node: INode, credentialsType: string): NodeOperationError {
|
||||
const credentialName = node.credentials?.[credentialsType]?.name;
|
||||
const credentialLabel = credentialName ? `"${credentialName}"` : `of type "${credentialsType}"`;
|
||||
return new NodeOperationError(
|
||||
node,
|
||||
`The credential ${credentialLabel} needs to be reconnected.`,
|
||||
{
|
||||
description:
|
||||
'Access could not be refreshed because the connected account has revoked access, the refresh token expired, or the account password or permissions changed. Open the credential and reconnect it to continue.',
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshOrFetchToken(ctx: RefreshOAuth2TokenContext): Promise<ClientOAuth2Token> {
|
||||
const {
|
||||
credentials,
|
||||
|
|
@ -800,10 +820,17 @@ async function refreshOrFetchToken(ctx: RefreshOAuth2TokenContext): Promise<Clie
|
|||
);
|
||||
|
||||
let newToken;
|
||||
if (credentials.grantType === 'clientCredentials') {
|
||||
newToken = await token.client.credentials.getToken();
|
||||
} else {
|
||||
newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
|
||||
try {
|
||||
if (credentials.grantType === 'clientCredentials') {
|
||||
newToken = await token.client.credentials.getToken();
|
||||
} else {
|
||||
newToken = await token.refresh(tokenRefreshOptions as unknown as ClientOAuth2Options);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRevokedOAuth2GrantError(error)) {
|
||||
throw buildOAuth2ReconnectError(node, credentialsType);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user