Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ describe('tokenOrchestrator', () => {
});

describe('handleErrors method', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('does not call clearTokens() if the error is a network error thrown from fetch handler', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
Expand All @@ -109,5 +113,116 @@ describe('tokenOrchestrator', () => {

expect(clearTokensSpy).not.toHaveBeenCalled();
});

it('calls clearTokens() for NotAuthorizedException', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'NotAuthorizedException',
message: 'Not authorized',
});

const result = (tokenOrchestrator as any).handleErrors(error);

expect(clearTokensSpy).toHaveBeenCalled();
expect(result).toBeNull();
});

it('calls clearTokens() for TokenRevokedException', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'TokenRevokedException',
message: 'Token revoked',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).toHaveBeenCalled();
});

it('calls clearTokens() for RefreshTokenReuseException', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'RefreshTokenReuseException',
message: 'Refresh token has been invalidated by rotation',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).toHaveBeenCalled();
});

it('calls clearTokens() for UserNotFoundException', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'UserNotFoundException',
message: 'User not found',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).toHaveBeenCalled();
});

it('does not call clearTokens() for service errors (500)', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'InternalServerError',
message: 'Internal server error',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).not.toHaveBeenCalled();
});

it('does not call clearTokens() for rate limit errors', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'TooManyRequestsException',
message: 'Too many requests',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).not.toHaveBeenCalled();
});

it('does not call clearTokens() for throttling errors', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'ThrottlingException',
message: 'Request throttled',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).not.toHaveBeenCalled();
});

it('does not call clearTokens() for temporary service issues', () => {
const clearTokensSpy = jest.spyOn(tokenOrchestrator, 'clearTokens');
const error = new AmplifyError({
name: 'ServiceUnavailable',
message: 'Service temporarily unavailable',
});

expect(() => {
(tokenOrchestrator as any).handleErrors(error);
}).toThrow(error);

expect(clearTokensSpy).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
} from '@aws-amplify/core';
import {
AMPLIFY_SYMBOL,
AmplifyErrorCode,
assertTokenProviderConfig,
isBrowser,
isTokenExpired,
Expand Down Expand Up @@ -183,10 +182,15 @@ export class TokenOrchestrator implements AuthTokenOrchestrator {

private handleErrors(err: unknown) {
assertServiceError(err);
if (err.name !== AmplifyErrorCode.NetworkError) {
// TODO(v6): Check errors on client

// Only clear tokens for definitive authentication failures
// Do NOT clear tokens for transient errors like service issues, rate limits, etc.
const shouldClearTokens = this.isAuthenticationError(err);

if (shouldClearTokens) {
this.clearTokens();
}

Hub.dispatch(
'auth',
{
Expand All @@ -203,6 +207,23 @@ export class TokenOrchestrator implements AuthTokenOrchestrator {
throw err;
}

private isAuthenticationError(err: any): boolean {
// Only clear tokens for errors that definitively indicate the tokens are invalid
// and re-authentication is required. All other errors (service errors, rate limits, etc.)
// should preserve the tokens to allow for retry.
// See: https://github.com/aws-amplify/amplify-js/issues/14534
const authErrorNames = [
'NotAuthorizedException', // Refresh token is expired or invalid
'TokenRevokedException', // Token was revoked by admin
'UserNotFoundException', // User no longer exists
'PasswordResetRequiredException', // User must reset password
'UserNotConfirmedException', // User account is not confirmed
'RefreshTokenReuseException', // Refresh token invalidated by rotation
];

return authErrorNames.some(errorName => err?.name?.startsWith?.(errorName));
}

async setTokens({ tokens }: { tokens: CognitoAuthTokens }) {
return this.getTokenStore().storeTokens(tokens);
}
Expand Down
Loading