Add synchronous refresh token API (#271)

This commit is contained in:
David Perez 2023-11-22 15:52:25 -06:00 committed by Álison Fernandes
parent 8c0c606d72
commit 3a07bbd3da
8 changed files with 200 additions and 3 deletions

View file

@ -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<GetTokenResponseJson.Success>
/**
* 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<RefreshTokenResponseJson>
}

View file

@ -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,
)

View file

@ -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<GetTokenResponseJson>
/**
* 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<RefreshTokenResponseJson>
}

View file

@ -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<RefreshTokenResponseJson> = api
.refreshTokenCall(
clientId = "mobile",
grantType = "refresh_token",
refreshToken = refreshToken,
)
.executeForResult()
}

View file

@ -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<T>(
},
)
override fun execute(): Response<Result<T>> = throw UnsupportedOperationException(
"This call can't be executed synchronously",
)
/**
* Synchronously send the request and return its response as a [Result].
*/
fun executeForResult(): Result<T> = requireNotNull(execute().body())
@Suppress("ReturnCount", "TooGenericExceptionCaught")
override fun execute(): Response<Result<T>> {
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

View file

@ -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 <reified T : Any> Call<T>.executeForResult(): Result<T> =
this
.toResultCall()
.executeForResult()
/**
* Wraps the existing [Call] in a [ResultCall].
*/
inline fun <reified T : Any> Call<T>.toResultCall(): ResultCall<T> =
ResultCall(
backingCall = this,
successType = T::class.java,
)

View file

@ -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"

View file

@ -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<Call<Unit>> {
every { execute() } throws IOException("Fail")
}
val result = call.executeForResult()
assertTrue(result.isFailure)
}
@Test
fun `executeForResult returns failure when execute throws RuntimeException`() {
val call = mockk<Call<Unit>> {
every { execute() } throws RuntimeException("Fail")
}
val result = call.executeForResult()
assertTrue(result.isFailure)
}
@Test
fun `executeForResult returns failure when response is failure`() {
val call = mockk<Call<Unit>> {
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<Call<Unit>> {
every { execute() } returns Response.success(Unit)
}
val result = call.executeForResult()
assertEquals(Unit.asSuccess(), result)
}
}