BIT-394 Setup service layer to accommodate get token error parsing (#61)

This commit is contained in:
Andrew Haisting 2023-09-21 16:03:54 -05:00 committed by Álison Fernandes
parent 9d7990026c
commit e69049d597
17 changed files with 477 additions and 50 deletions

View file

@ -6,7 +6,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Defines calls under the /accounts API.
* Defines raw calls under the /accounts API.
*/
interface AccountsApi {

View file

@ -1,16 +1,14 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Url
import java.util.UUID
/**
* Defines calls under the /identity API.
* Defines raw calls under the /identity API.
*/
interface IdentityApi {
@ -18,18 +16,15 @@ interface IdentityApi {
@Suppress("LongParameterList")
@FormUrlEncoded
suspend fun getToken(
// TODO: use correct base URL here BIT-328
@Url url: String = "https://vault.bitwarden.com/identity/connect/token",
@Field(value = "scope", encoded = true) scope: String = "api+offline_access",
@Field(value = "client_id") clientId: String = "mobile",
@Url url: String,
@Field(value = "scope", encoded = true) scope: String,
@Field(value = "client_id") clientId: String,
@Field(value = "username") email: String,
@Header(value = "auth-email") authEmail: String = email.base64UrlEncode(),
@Header(value = "auth-email") authEmail: String,
@Field(value = "password") passwordHash: String,
// TODO: use correct device identifier here BIT-325
@Field(value = "deviceIdentifier") deviceIdentifier: String = UUID.randomUUID().toString(),
// TODO: use correct values for deviceName and deviceType BIT-326
@Field(value = "deviceName") deviceName: String = "Pixel 6",
@Field(value = "deviceType") deviceType: String = "1",
@Field(value = "grant_type") grantType: String = "password",
): Result<GetTokenResponseJson>
@Field(value = "deviceIdentifier") deviceIdentifier: String,
@Field(value = "deviceName") deviceName: String,
@Field(value = "deviceType") deviceType: String,
@Field(value = "grant_type") grantType: String,
): Result<GetTokenResponseJson.Success>
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.auth.datasource.network.di
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Named
import javax.inject.Singleton
/**
* Provides network dependencies in the auth package.
*/
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun providesAccountService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
): AccountsService = AccountsServiceImpl(retrofit.create())
@Provides
@Singleton
fun providesIdentityService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
json: Json,
): IdentityService = IdentityServiceImpl(retrofit.create(), json)
}

View file

@ -4,12 +4,26 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models json response of the get token request.
*
* @param accessToken the access token.
* Models response bodies from the get token request.
*/
@Serializable
data class GetTokenResponseJson(
@SerialName("access_token")
val accessToken: String,
)
sealed class GetTokenResponseJson {
/**
* Models json response of the get token request.
*
* @param accessToken the access token.
*/
@Serializable
data class Success(
@SerialName("access_token")
val accessToken: String,
) : GetTokenResponseJson()
/**
* Models json body of a captcha error.
*/
@Serializable
data class CaptchaRequired(
@SerialName("HCaptcha_SiteKey")
val captchaKey: String,
) : GetTokenResponseJson()
}

View file

@ -12,6 +12,11 @@ sealed class LoginResult {
*/
data object Success : LoginResult()
/**
* Captcha verification is required.
*/
data class CaptchaRequired(val captchaId: String) : LoginResult()
/**
* There was an error logging in.
*/

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
/**
* Wraps raw retrofit accounts API in a cleaner interface.
*/
interface AccountsService {
/**
* Make pre login request to get KDF params.
*/
suspend fun preLogin(email: String): Result<PreLoginResponseJson>
}

View file

@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
class AccountsServiceImpl constructor(
private val accountsApi: AccountsApi,
) : AccountsService {
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
accountsApi.preLogin(PreLoginRequestJson(email = email))
}

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
/**
* Wraps raw retrofit identity API in a cleaner interface.
*/
interface IdentityService {
/**
* Make request to get an access token.
*
* @param email user's email address.
* @param passwordHash password hashed with the Bitwarden SDK.
*/
suspend fun getToken(
email: String,
passwordHash: String,
): Result<GetTokenResponseJson>
}

View file

@ -0,0 +1,46 @@
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.platform.datasource.network.util.base64UrlEncode
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyAsResult
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection.HTTP_BAD_REQUEST
import java.util.UUID
class IdentityServiceImpl constructor(
private val api: IdentityApi,
private val json: Json,
// TODO: use correct base URL here BIT-328
private val baseUrl: String = "https://vault.bitwarden.com",
) : IdentityService {
override suspend fun getToken(
email: String,
passwordHash: String,
): Result<GetTokenResponseJson> = api
.getToken(
// TODO: use correct base URL here BIT-328
url = "$baseUrl/identity/connect/token",
scope = "api+offline_access",
clientId = "mobile",
authEmail = email.base64UrlEncode(),
// TODO: use correct device identifier here BIT-325
deviceIdentifier = UUID.randomUUID().toString(),
// TODO: use correct values for deviceName and deviceType BIT-326
deviceName = "Pixel 6",
deviceType = "1",
grantType = "password",
passwordHash = passwordHash,
email = email,
)
.fold(
onSuccess = { Result.success(it) },
onFailure = {
it.parseErrorBodyAsResult<GetTokenResponseJson.CaptchaRequired>(
code = HTTP_BAD_REQUEST,
json = json,
)
},
)
}

View file

@ -2,11 +2,12 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.Kdf
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.util.flatMap
import kotlinx.coroutines.flow.MutableStateFlow
@ -20,8 +21,8 @@ import javax.inject.Singleton
*/
@Singleton
class AuthRepositoryImpl @Inject constructor(
private val accountsApi: AccountsApi,
private val identityApi: IdentityApi,
private val accountsService: AccountsService,
private val identityService: IdentityService,
private val bitwardenSdkClient: Client,
private val authTokenInterceptor: AuthTokenInterceptor,
) : AuthRepository {
@ -35,8 +36,8 @@ class AuthRepositoryImpl @Inject constructor(
override suspend fun login(
email: String,
password: String,
): LoginResult = accountsApi
.preLogin(PreLoginRequestJson(email))
): LoginResult = accountsService
.preLogin(email = email)
.flatMap {
// TODO: Use KDF enum from pre login correctly (BIT-329)
val passwordHash = bitwardenSdkClient
@ -46,21 +47,27 @@ class AuthRepositoryImpl @Inject constructor(
password = password,
kdfParams = Kdf.Pbkdf2(it.kdfIterations),
)
identityApi.getToken(
identityService.getToken(
email = email,
passwordHash = passwordHash,
)
}
.fold(
onFailure = {
// TODO: Add more detail to these cases to expose server error messages (BIT-320)
// TODO: Add more detail to error case to expose server error messages (BIT-320)
LoginResult.Error
},
onSuccess = {
// TODO: Create intermediate class for providing auth token to interceptor (BIT-411)
authTokenInterceptor.authToken = it.accessToken
mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken)
LoginResult.Success
when (it) {
is CaptchaRequired -> LoginResult.CaptchaRequired(it.captchaKey)
is Success -> {
// TODO: Create intermediate class for providing auth token
// to interceptor (BIT-411)
authTokenInterceptor.authToken = it.accessToken
mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken)
LoginResult.Success
}
}
},
)
}

View file

@ -1,8 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.network.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
@ -27,23 +25,23 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val AUTHORIZED = "authorized"
private const val UNAUTHORIZED = "unauthorized"
@Provides
@Singleton
fun providesAccountsApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): AccountsApi =
retrofit.create()
const val AUTHORIZED: String = "authorized"
const val UNAUTHORIZED: String = "unauthorized"
@Provides
@Singleton
fun providesConfigApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): ConfigApi =
retrofit.create()
@Provides
@Singleton
fun providesIdentityApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): IdentityApi =
retrofit.create()
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply {
setLevel(HttpLoggingInterceptor.Level.BODY)
},
)
.build()
}
@Provides
@Singleton

View file

@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.platform.datasource.network.util
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import retrofit2.HttpException
/**
* Attempt to parse the error body to serializable type [T].
*
* Useful in service layer for parsing non-200 response bodies.
*
* If the receiver is not an [HttpException] or the error body cannot be parsed, the original
* Throwable will be returned as a Result.failure.
*
* @param code HTTP code associated with the error. Only responses with this code will be attempted
* to be parsed.
* @param json [Json] serializer to use.
*/
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> Throwable.parseErrorBodyAsResult(code: Int, json: Json): Result<T> =
(this as? HttpException)
?.response()
?.takeIf { it.code() == code }
?.errorBody()
?.let { errorBody ->
try {
Result.success(json.decodeFromStream(errorBody.byteStream()))
} catch (_: Exception) {
Result.failure(this)
}
} ?: Result.failure(this)

View file

@ -60,6 +60,8 @@ class LoginViewModel @Inject constructor(
LoginResult.Error -> Unit
// No action required on success, root nav will navigate to logged in state
LoginResult.Success -> Unit
// TODO: launch intent with captcha URL BIT-399
is LoginResult.CaptchaRequired -> Unit
}
}
}

View file

@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import retrofit2.create
class AccountsServiceTest : BaseServiceTest() {
private val accountsApi: AccountsApi = retrofit.create()
private val service = AccountsServiceImpl(accountsApi)
@Test
fun `preLogin should call API`() = runTest {
val response = MockResponse().setBody(PRE_LOGIN_RESPONSE_JSON)
server.enqueue(response)
assertEquals(Result.success(PRE_LOGIN_RESPONSE), service.preLogin(EMAIL))
}
companion object {
private const val EMAIL = "email"
}
}
private const val PRE_LOGIN_RESPONSE_JSON = """
{
"kdf": 1,
"kdfIterations": 1,
"kdfMemory": 1,
"kdfParallelism": 1
}
"""
private val PRE_LOGIN_RESPONSE = PreLoginResponseJson(
kdf = 1,
kdfIterations = 1u,
kdfMemory = 1,
kdfParallelism = 1,
)

View file

@ -0,0 +1,63 @@
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.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class IdentityServiceTest : BaseServiceTest() {
private val identityApi: IdentityApi = retrofit.create()
private val identityService = IdentityServiceImpl(
api = identityApi,
json = Json,
baseUrl = server.url("/").toString(),
)
@Test
fun `getToken when request response is Success should return Success`() = runTest {
server.enqueue(MockResponse().setBody(LOGIN_SUCCESS_JSON))
val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH)
assertEquals(Result.success(LOGIN_SUCCESS), result)
}
@Test
fun `getToken when request is error should return error`() = runTest {
server.enqueue(MockResponse().setResponseCode(500))
val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH)
assertTrue(result.isFailure)
}
@Test
fun `getToken when response is CaptchaRequired should return CaptchaRequired`() = runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody(CAPTCHA_BODY_JSON))
val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH)
assertEquals(Result.success(CAPTCHA_BODY), result)
}
companion object {
private const val EMAIL = "email"
private const val PASSWORD_HASH = "passwordHash"
}
}
private const val CAPTCHA_BODY_JSON = """
{
"HCaptcha_SiteKey": "123"
}
"""
private val CAPTCHA_BODY = GetTokenResponseJson.CaptchaRequired("123")
private const val LOGIN_SUCCESS_JSON = """
{
"access_token": "123"
}
"""
private val LOGIN_SUCCESS = GetTokenResponseJson.Success("123")

View file

@ -0,0 +1,110 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.Kdf
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AuthRepositoryTest {
private val accountsService: AccountsService = mockk()
private val identityService: IdentityService = mockk()
private val authInterceptor = mockk<AuthTokenInterceptor>()
private val mockBitwardenSdk = mockk<Client> {
coEvery {
auth().hashPassword(
email = EMAIL,
password = PASSWORD,
kdfParams = Kdf.Pbkdf2(iterations = PRE_LOGIN_SUCCESS.kdfIterations),
)
} returns PASSWORD_HASH
}
private val repository = AuthRepositoryImpl(
accountsService = accountsService,
identityService = identityService,
bitwardenSdkClient = mockBitwardenSdk,
authTokenInterceptor = authInterceptor,
)
@BeforeEach
fun beforeEach() {
clearMocks(identityService, accountsService, authInterceptor)
}
@Test
fun `login when pre login fails should return Error`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns (Result.failure(RuntimeException()))
val result = repository.login(EMAIL, PASSWORD)
assertEquals(LoginResult.Error, result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify { accountsService.preLogin(EMAIL) }
}
@Test
fun `login get token fails should return Error`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
coEvery { identityService.getToken(EMAIL, PASSWORD_HASH) }
.returns(Result.failure(RuntimeException()))
val result = repository.login(EMAIL, PASSWORD)
assertEquals(LoginResult.Error, result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify { accountsService.preLogin(EMAIL) }
coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) }
}
@Test
fun `login get token succeeds should return Success and update AuthState`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
coEvery { identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) }
.returns(Result.success(GetTokenResponseJson.Success(accessToken = ACCESS_TOKEN)))
every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit
val result = repository.login(EMAIL, PASSWORD)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
verify { authInterceptor.authToken = ACCESS_TOKEN }
coVerify { accountsService.preLogin(EMAIL) }
coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) }
}
@Test
fun `login get token returns captcha request should return CaptchaRequired`() = runTest {
coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS)
coEvery { identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) }
.returns(Result.success(GetTokenResponseJson.CaptchaRequired(CAPTCHA_KEY)))
val result = repository.login(EMAIL, PASSWORD)
assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result)
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
coVerify { accountsService.preLogin(EMAIL) }
coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) }
}
companion object {
private const val EMAIL = "test@test.com"
private const val PASSWORD = "password"
private const val PASSWORD_HASH = "passwordHash"
private const val ACCESS_TOKEN = "accessToken"
private const val CAPTCHA_KEY = "captcha"
private val PRE_LOGIN_SUCCESS = PreLoginResponseJson(
kdf = 1,
kdfIterations = 1u,
kdfMemory = null,
kdfParallelism = null,
)
}
}

View file

@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.platform.base
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import retrofit2.Retrofit
/**
* Base class for service tests. Provides common mock web server and retrofit setup.
*/
abstract class BaseServiceTest {
protected val server = MockWebServer().apply { start() }
protected val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(server.url("/").toString())
.addCallAdapterFactory(ResultCallAdapterFactory())
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
@After
fun after() {
server.shutdown()
}
}