diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt index 2210f002b..5c53b1ce3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson +import retrofit2.Call import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.Header @@ -26,4 +28,16 @@ interface IdentityApi { @Field(value = "grant_type") grantType: String, @Field(value = "captchaResponse") captchaResponse: String?, ): Result + + /** + * This call needs to be synchronous so we need it to return a [Call] directly. The identity + * service will wrap it up for us. + */ + @POST("/connect/token") + @FormUrlEncoded + fun refreshTokenCall( + @Field(value = "client_id") clientId: String, + @Field(value = "refresh_token") refreshToken: String, + @Field(value = "grant_type") grantType: String, + ): Call } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RefreshTokenResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RefreshTokenResponseJson.kt new file mode 100644 index 000000000..df96edb20 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/RefreshTokenResponseJson.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models the response body from the refresh token request. + * + * @property accessToken The new access token. + * @property expiresIn When the new [accessToken] expires. + * @property refreshToken The new refresh token. + * @property tokenType The type of token the new [accessToken] is. + */ +@Serializable +data class RefreshTokenResponseJson( + @SerialName("access_token") + val accessToken: String, + + @SerialName("expires_in") + val expiresIn: Int, + + @SerialName("refresh_token") + val refreshToken: String, + + @SerialName("token_type") + val tokenType: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt index 1bb185476..226f3d006 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson /** * Provides an API for querying identity endpoints. @@ -19,4 +20,11 @@ interface IdentityService { passwordHash: String, captchaToken: String?, ): Result + + /** + * Synchronously makes a request to get refresh the access token. + * + * @param refreshToken The refresh token needed to obtain a new token. + */ + fun refreshTokenSynchronously(refreshToken: String): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt index b64b606d5..6a25036b4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt @@ -2,8 +2,10 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode +import com.x8bit.bitwarden.data.platform.datasource.network.util.executeForResult import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider import kotlinx.serialization.json.Json @@ -44,4 +46,14 @@ class IdentityServiceImpl constructor( json = json, ) ?: throw throwable } + + override fun refreshTokenSynchronously( + refreshToken: String, + ): Result = api + .refreshTokenCall( + clientId = "mobile", + grantType = "refresh_token", + refreshToken = refreshToken, + ) + .executeForResult() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/core/ResultCall.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/core/ResultCall.kt index 9031a077a..14a558bef 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/core/ResultCall.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/core/ResultCall.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.data.platform.datasource.network.core +import com.x8bit.bitwarden.data.platform.util.asFailure import okhttp3.Request +import okio.IOException import okio.Timeout import retrofit2.Call import retrofit2.Callback @@ -48,9 +50,29 @@ class ResultCall( }, ) - override fun execute(): Response> = throw UnsupportedOperationException( - "This call can't be executed synchronously", - ) + /** + * Synchronously send the request and return its response as a [Result]. + */ + fun executeForResult(): Result = requireNotNull(execute().body()) + + @Suppress("ReturnCount", "TooGenericExceptionCaught") + override fun execute(): Response> { + val response = try { + backingCall.execute() + } catch (ioException: IOException) { + return success(ioException.asFailure()) + } catch (runtimeException: RuntimeException) { + return success(runtimeException.asFailure()) + } + + return success( + if (!response.isSuccessful) { + HttpException(response).asFailure() + } else { + createResult(response.body()) + }, + ) + } override fun isCanceled(): Boolean = backingCall.isCanceled diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/CallExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/CallExtensions.kt new file mode 100644 index 000000000..d7a061f3d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/CallExtensions.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.util + +import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCall +import retrofit2.Call + +/** + * Synchronously executes the [Call] and returns the [Result]. + */ +inline fun Call.executeForResult(): Result = + this + .toResultCall() + .executeForResult() + +/** + * Wraps the existing [Call] in a [ResultCall]. + */ +inline fun Call.toResultCall(): ResultCall = + ResultCall( + backingCall = this, + successType = T::class.java, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt index f8754cc5f..63f2754d2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt @@ -5,10 +5,12 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.MasterPasswordPolicyOptionsJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider +import com.x8bit.bitwarden.data.platform.util.asSuccess import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -76,12 +78,44 @@ class IdentityServiceTest : BaseServiceTest() { assertEquals(Result.success(INVALID_LOGIN), result) } + @Suppress("MaxLineLength") + @Test + fun `refreshTokenSynchronously when response is success should return RefreshTokenResponseJson`() { + server.enqueue(MockResponse().setResponseCode(200).setBody(REFRESH_TOKEN_JSON)) + val result = identityService.refreshTokenSynchronously(refreshToken = REFRESH_TOKEN) + assertEquals(REFRESH_TOKEN_BODY.asSuccess(), result) + } + + @Test + fun `refreshTokenSynchronously when response is an error should return an error`() { + server.enqueue(MockResponse().setResponseCode(400)) + val result = identityService.refreshTokenSynchronously(refreshToken = REFRESH_TOKEN) + assertTrue(result.isFailure) + } + companion object { + private const val REFRESH_TOKEN = "refreshToken" private const val EMAIL = "email" private const val PASSWORD_HASH = "passwordHash" } } +private const val REFRESH_TOKEN_JSON = """ +{ + "access_token": "accessToken", + "expires_in": 3600, + "refresh_token": "refreshToken", + "token_type": "Bearer" +} +""" + +private val REFRESH_TOKEN_BODY = RefreshTokenResponseJson( + accessToken = "accessToken", + expiresIn = 3600, + refreshToken = "refreshToken", + tokenType = "Bearer", +) + private const val CAPTCHA_BODY_JSON = """ { "HCaptcha_SiteKey": "123" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/util/CallExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/util/CallExtensionsTest.kt new file mode 100644 index 000000000..b123f05b9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/util/CallExtensionsTest.kt @@ -0,0 +1,59 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.util + +import com.x8bit.bitwarden.data.platform.util.asSuccess +import io.mockk.every +import io.mockk.mockk +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import retrofit2.Call +import retrofit2.Response +import java.io.IOException + +class CallExtensionsTest { + + @Test + fun `executeForResult returns failure when execute throws IOException`() { + val call = mockk> { + every { execute() } throws IOException("Fail") + } + + val result = call.executeForResult() + + assertTrue(result.isFailure) + } + + @Test + fun `executeForResult returns failure when execute throws RuntimeException`() { + val call = mockk> { + every { execute() } throws RuntimeException("Fail") + } + + val result = call.executeForResult() + + assertTrue(result.isFailure) + } + + @Test + fun `executeForResult returns failure when response is failure`() { + val call = mockk> { + every { execute() } returns Response.error(400, "".toResponseBody()) + } + + val result = call.executeForResult() + + assertTrue(result.isFailure) + } + + @Test + fun `executeForResult returns success when response is failure`() { + val call = mockk> { + every { execute() } returns Response.success(Unit) + } + + val result = call.executeForResult() + + assertEquals(Unit.asSuccess(), result) + } +}