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
}
}
}