diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 7423944d5e..4b3d39e0eb 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -781,12 +781,21 @@ class AuthRepositoryImpl( when (refreshTokenResponse) { is RefreshTokenResponseJson.Error -> { if (refreshTokenResponse.isInvalidGrant) { - // We only logout for an invalid grant logout(userId = userId, reason = LogoutReason.InvalidGrant) } IllegalStateException(refreshTokenResponse.error).asFailure() } + is RefreshTokenResponseJson.Forbidden -> { + logout(userId = userId, reason = LogoutReason.RefreshForbidden) + refreshTokenResponse.error.asFailure() + } + + is RefreshTokenResponseJson.Unauthorized -> { + logout(userId = userId, reason = LogoutReason.RefreshUnauthorized) + refreshTokenResponse.error.asFailure() + } + is RefreshTokenResponseJson.Success -> { // Store the new token information authDiskSource.storeAccountTokens( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt index 67af8d83bf..473fed3144 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt @@ -35,6 +35,18 @@ sealed class LogoutReason { */ data object InvalidGrant : LogoutReason() + /** + * Indicates that the logout is happening because the there was a "Forbidden" response from + * token refresh API. + */ + data object RefreshForbidden : LogoutReason() + + /** + * Indicates that the logout is happening because the there was a "Unauthorized" response from + * token refresh API. + */ + data object RefreshUnauthorized : LogoutReason() + /** * Indicates that the logout is happening because of an invalid state. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 4dcf8b75ad..91d589e0fa 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -882,21 +882,88 @@ class AuthRepositoryTest { } @Test - fun `refreshAccessTokenSynchronously returns failure and logs out on failure`() = runTest { - fakeAuthDiskSource.storeAccountTokens( - userId = USER_ID_1, - accountTokens = ACCOUNT_TOKENS_1, - ) - coEvery { - identityService.refreshTokenSynchronously(REFRESH_TOKEN) - } returns Throwable("Fail").asFailure() + fun `refreshAccessTokenSynchronously returns failure if refreshTokenSynchronously fails`() = + runTest { + fakeAuthDiskSource.storeAccountTokens( + userId = USER_ID_1, + accountTokens = ACCOUNT_TOKENS_1, + ) + coEvery { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + } returns Throwable("Fail").asFailure() - assertTrue(repository.refreshAccessTokenSynchronously(USER_ID_1).isFailure) + assertTrue(repository.refreshAccessTokenSynchronously(USER_ID_1).isFailure) - coVerify(exactly = 1) { - identityService.refreshTokenSynchronously(REFRESH_TOKEN) + coVerify(exactly = 1) { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + } + } + + @Suppress("MaxLineLength") + @Test + fun `refreshAccessTokenSynchronously returns logs out and returns failure if refreshTokenSynchronously returns invalid_grant`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeAccountTokens( + userId = USER_ID_1, + accountTokens = ACCOUNT_TOKENS_1, + ) + coEvery { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + } returns RefreshTokenResponseJson.Error(error = "invalid_grant").asSuccess() + + assertTrue(repository.refreshAccessTokenSynchronously(USER_ID_1).isFailure) + + coVerify(exactly = 1) { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + userLogoutManager.logout(userId = USER_ID_1, reason = LogoutReason.InvalidGrant) + } + } + + @Suppress("MaxLineLength") + @Test + fun `refreshAccessTokenSynchronously returns logs out and returns failure if refreshTokenSynchronously returns Forbidden`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeAccountTokens( + userId = USER_ID_1, + accountTokens = ACCOUNT_TOKENS_1, + ) + coEvery { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + } returns RefreshTokenResponseJson.Forbidden(error = Throwable("Fail!")).asSuccess() + + assertTrue(repository.refreshAccessTokenSynchronously(USER_ID_1).isFailure) + + coVerify(exactly = 1) { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + userLogoutManager.logout(userId = USER_ID_1, reason = LogoutReason.RefreshForbidden) + } + } + + @Suppress("MaxLineLength") + @Test + fun `refreshAccessTokenSynchronously returns logs out and returns failure if refreshTokenSynchronously returns Unauthorized`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeAccountTokens( + userId = USER_ID_1, + accountTokens = ACCOUNT_TOKENS_1, + ) + coEvery { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + } returns RefreshTokenResponseJson.Unauthorized(error = Throwable("Fail!")).asSuccess() + + assertTrue(repository.refreshAccessTokenSynchronously(USER_ID_1).isFailure) + + coVerify(exactly = 1) { + identityService.refreshTokenSynchronously(REFRESH_TOKEN) + userLogoutManager.logout( + userId = USER_ID_1, + reason = LogoutReason.RefreshUnauthorized, + ) + } } - } @Test fun `refreshAccessTokenSynchronously returns success and sets account tokens`() = runTest { diff --git a/network/src/main/kotlin/com/bitwarden/network/model/RefreshTokenResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/RefreshTokenResponseJson.kt index fc093ade14..0125d034d7 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/RefreshTokenResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/RefreshTokenResponseJson.kt @@ -40,4 +40,18 @@ sealed class RefreshTokenResponseJson { ) : RefreshTokenResponseJson() { val isInvalidGrant: Boolean get() = error == "invalid_grant" } + + /** + * Models a failure response with a 403 "Forbidden" response code. + */ + data class Forbidden( + val error: Throwable, + ) : RefreshTokenResponseJson() + + /** + * Models a failure response with a 401 "Unauthorized" response code. + */ + data class Unauthorized( + val error: Throwable, + ) : RefreshTokenResponseJson() } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt index a3194fd79f..fa5599ba85 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/IdentityServiceImpl.kt @@ -20,6 +20,7 @@ import com.bitwarden.network.util.DeviceModelProvider import com.bitwarden.network.util.NetworkErrorCode import com.bitwarden.network.util.base64UrlEncode import com.bitwarden.network.util.executeForNetworkResult +import com.bitwarden.network.util.getNetworkErrorCodeOrNull import com.bitwarden.network.util.parseErrorBodyOrNull import com.bitwarden.network.util.toResult import kotlinx.serialization.json.Json @@ -131,13 +132,28 @@ internal class IdentityServiceImpl( .executeForNetworkResult() .toResult() .recoverCatching { throwable -> - throwable - .toBitwardenError() + val bitwardenError = throwable.toBitwardenError() + bitwardenError .parseErrorBodyOrNull( code = NetworkErrorCode.BAD_REQUEST, json = json, ) - ?: throw throwable + ?: run { + when (bitwardenError.getNetworkErrorCodeOrNull()) { + NetworkErrorCode.UNAUTHORIZED -> { + RefreshTokenResponseJson.Unauthorized(throwable) + } + + NetworkErrorCode.FORBIDDEN -> { + RefreshTokenResponseJson.Forbidden(throwable) + } + + NetworkErrorCode.BAD_REQUEST, + NetworkErrorCode.TOO_MANY_REQUESTS, + null, + -> throw throwable + } + } } override suspend fun registerFinish( diff --git a/network/src/main/kotlin/com/bitwarden/network/util/ExceptionExtensions.kt b/network/src/main/kotlin/com/bitwarden/network/util/ExceptionExtensions.kt index c8a7e960cf..6cda1f1d60 100644 --- a/network/src/main/kotlin/com/bitwarden/network/util/ExceptionExtensions.kt +++ b/network/src/main/kotlin/com/bitwarden/network/util/ExceptionExtensions.kt @@ -5,6 +5,14 @@ import com.bitwarden.network.model.BitwardenError import kotlinx.serialization.json.Json import retrofit2.HttpException +/** + * Returns the [NetworkErrorCode] for the given error if it is available. + */ +internal fun BitwardenError.getNetworkErrorCodeOrNull(): NetworkErrorCode? = + (this as? BitwardenError.Http)?.let { httpError -> + NetworkErrorCode.entries.firstOrNull { httpError.code == it.code } + } + /** * Attempt to parse the error body to serializable type [T]. * diff --git a/network/src/main/kotlin/com/bitwarden/network/util/NetworkErrorCode.kt b/network/src/main/kotlin/com/bitwarden/network/util/NetworkErrorCode.kt index 8935459746..e9064ea3ad 100644 --- a/network/src/main/kotlin/com/bitwarden/network/util/NetworkErrorCode.kt +++ b/network/src/main/kotlin/com/bitwarden/network/util/NetworkErrorCode.kt @@ -7,5 +7,7 @@ internal enum class NetworkErrorCode( val code: Int, ) { BAD_REQUEST(code = 400), + UNAUTHORIZED(code = 401), + FORBIDDEN(code = 403), TOO_MANY_REQUESTS(code = 429), } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt index 70a077ddb4..4a0540dab0 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/IdentityServiceTest.kt @@ -333,6 +333,20 @@ class IdentityServiceTest : BaseServiceTest() { assertTrue(result.isFailure) } + @Test + fun `refreshTokenSynchronously when response is a 403 error should return an Forbidden`() { + server.enqueue(MockResponse().setResponseCode(403)) + val result = identityService.refreshTokenSynchronously(refreshToken = REFRESH_TOKEN) + assertTrue(result.getOrThrow() is RefreshTokenResponseJson.Forbidden) + } + + @Test + fun `refreshTokenSynchronously when response is a 401 error should return an Unauthorized`() { + server.enqueue(MockResponse().setResponseCode(401)) + val result = identityService.refreshTokenSynchronously(refreshToken = REFRESH_TOKEN) + assertTrue(result.getOrThrow() is RefreshTokenResponseJson.Unauthorized) + } + @Test fun `registerFinish success json should be Success`() = runTest { val expectedResponse = RegisterResponseJson.Success(