From 1966645444cc4145963a3b312410b2fe56863efe Mon Sep 17 00:00:00 2001 From: Cesare Date: Wed, 22 Jul 2026 10:10:40 +0000 Subject: [PATCH] Preserve credentials on retryable token refresh failures --- src/services/auth.service.ts | 36 +++++++++++++++++++++++++++++- test/services/auth.service.test.ts | 28 +++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 4e449a26..c3f1307f 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -107,7 +107,9 @@ export class AuthService { ); credentialsChanged = true; } catch (error) { - await ConfigService.instance.clearUser(); + if (this.shouldClearUserAfterRefreshError(error)) { + await ConfigService.instance.clearUser(); + } throw error; } } @@ -126,6 +128,38 @@ export class AuthService { return loginCreds; }; + private readonly shouldClearUserAfterRefreshError = (error: unknown): boolean => { + if (error instanceof InvalidCredentialsError || error instanceof ExpiredCredentialsError) { + return true; + } + + const status = this.getHttpStatusFromError(error); + if (status === undefined) { + return false; + } + + return status >= 400 && status < 500 && status !== 408 && status !== 429; + }; + + private readonly getHttpStatusFromError = (error: unknown): number | undefined => { + if (typeof error !== 'object' || error === null) { + return undefined; + } + + const response = 'response' in error ? (error as { response?: { status?: unknown } }).response : undefined; + if (typeof response?.status === 'number') { + return response.status; + } + + const status = 'status' in error ? (error as { status?: unknown }).status : undefined; + if (typeof status === 'number') { + return status; + } + + const statusCode = 'statusCode' in error ? (error as { statusCode?: unknown }).statusCode : undefined; + return typeof statusCode === 'number' ? statusCode : undefined; + }; + /** * Refreshes the user tokens * diff --git a/test/services/auth.service.test.ts b/test/services/auth.service.test.ts index bea76496..f998331c 100644 --- a/test/services/auth.service.test.ts +++ b/test/services/auth.service.test.ts @@ -217,12 +217,14 @@ describe('Auth service', () => { expect(refreshTokensStub).toHaveBeenCalledOnce(); }); - test('when the token refresh fails, then stored credentials are cleared and an error is thrown', async () => { + test('when the token refresh fails with an auth error, then stored credentials are cleared and an error is thrown', async () => { const sut = AuthService.instance; const mockTokenStatus = TokenStatus.REFRESH_REQUIRED; - const oldTokenError = new Error('Old token version detected'); + const oldTokenError = Object.assign(new Error('Old token version detected'), { + response: { status: 401 }, + }); vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockImplementationOnce( @@ -236,4 +238,26 @@ describe('Auth service', () => { expect(refreshTokenStub).toHaveBeenCalledOnce(); expect(clearUserStub).toHaveBeenCalledOnce(); }); + + test('when the token refresh fails with a retryable server error, then stored credentials are preserved', async () => { + const sut = AuthService.instance; + + const mockTokenStatus = TokenStatus.REFRESH_REQUIRED; + + const retryableError = Object.assign(new Error('Service unavailable'), { + response: { status: 503 }, + }); + + vi.spyOn(ConfigService.instance, 'readUser').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(ValidationService.instance, 'validateTokenAndCheckExpiration').mockImplementationOnce( + () => mockTokenStatus, + ); + vi.spyOn(ValidationService.instance, 'validateMnemonic').mockReturnValue(true); + const refreshTokenStub = vi.spyOn(sut, 'refreshUserToken').mockRejectedValue(retryableError); + const clearUserStub = vi.spyOn(ConfigService.instance, 'clearUser').mockResolvedValue(); + + await expect(() => sut.getAuthDetails()).rejects.toThrow(retryableError); + expect(refreshTokenStub).toHaveBeenCalledOnce(); + expect(clearUserStub).not.toHaveBeenCalled(); + }); });