BIT-816: Add handling for SSO intents (#724)

This commit is contained in:
Sean Weiser 2024-01-22 21:29:03 -06:00 committed by Álison Fernandes
parent 6a49a37ef0
commit 112d181394
8 changed files with 201 additions and 0 deletions

View file

@ -61,6 +61,16 @@
android:host="captcha-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="sso-callback"
android:scheme="bitwarden" />
</intent-filter>
</activity>
<provider

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -22,6 +23,7 @@ class WebAuthCallbackViewModel @Inject constructor(
private fun handleIntentReceived(action: WebAuthCallbackAction.IntentReceive) {
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
when {
captchaCallbackTokenResult != null -> {
authRepository.setCaptchaCallbackTokenResult(
@ -29,6 +31,12 @@ class WebAuthCallbackViewModel @Inject constructor(
)
}
ssoCallbackResult != null -> {
authRepository.setSsoCallbackResult(
result = ssoCallbackResult,
)
}
else -> Unit
}
}

View file

@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.AuthenticatorProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing an modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository : AuthenticatorProvider {
/**
* Models the current auth state.
@ -34,6 +36,12 @@ interface AuthRepository : AuthenticatorProvider {
*/
val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult>
/**
* Flow of the current [SsoCallbackResult]. Subscribers should listen to the flow in order to
* receive updates whenever [setSsoCallbackResult] is called.
*/
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
/**
* The currently persisted saved email address (or `null` if not set).
*/
@ -94,6 +102,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
/**
* Set the value of [ssoCallbackResultFlow].
*/
fun setSsoCallbackResult(result: SsoCallbackResult)
/**
* Get a [Boolean] indicating whether this is a known device.
*/

View file

@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
@ -139,6 +140,11 @@ class AuthRepositoryImpl(
override val captchaTokenResultFlow: Flow<CaptchaCallbackTokenResult> =
mutableCaptchaTokenFlow.asSharedFlow()
private val mutableSsoCallbackResultFlow =
bufferedMutableSharedFlow<SsoCallbackResult>()
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
mutableSsoCallbackResultFlow.asSharedFlow()
override var rememberedEmailAddress: String? by authDiskSource::rememberedEmailAddress
override var hasPendingAccountAddition: Boolean
@ -382,6 +388,10 @@ class AuthRepositoryImpl(
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}
override fun setSsoCallbackResult(result: SsoCallbackResult) {
mutableSsoCallbackResultFlow.tryEmit(result)
}
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(

View file

@ -0,0 +1,52 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
private const val SSO_HOST: String = "sso-callback"
/**
* Retrieves an [SsoCallbackResult] from an Intent. There are three possible cases.
*
* - `null`: Intent is not an SSO callback, or data is null.
*
* - [SsoCallbackResult.MissingCode]: Intent is the SSO callback, but it's missing the needed code.
*
* - [SsoCallbackResult.Success]: Intent is the SSO callback with required data.
*/
fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
val localData = data
return if (action == Intent.ACTION_VIEW && localData?.host == SSO_HOST) {
val state = localData.getQueryParameter("state")
val code = localData.getQueryParameter("code")
if (code != null) {
SsoCallbackResult.Success(
state = state,
code = code,
)
} else {
SsoCallbackResult.MissingCode
}
} else {
null
}
}
/**
* Sealed class representing the result of an SSO callback data extraction.
*/
sealed class SsoCallbackResult {
/**
* Represents an SSO callback object with a missing code value.
*/
data object MissingCode : SsoCallbackResult()
/**
* Represents an SSO callback object with the necessary [state] and [code]. `state` being
* present doesn't guarantee it is correct, and should be checked against the known state before
* being used.
*/
data class Success(
val state: String?,
val code: String,
) : SsoCallbackResult()
}

View file

@ -3,7 +3,9 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.just
@ -19,16 +21,19 @@ import org.junit.jupiter.api.Test
class WebAuthCallbackViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
every { setCaptchaCallbackTokenResult(any()) } just runs
every { setSsoCallbackResult(any()) } just runs
}
@BeforeEach
fun setUp() {
mockkStatic(Intent::getCaptchaCallbackTokenResult)
mockkStatic(Intent::getSsoCallbackResult)
}
@AfterEach
fun tearDown() {
unmockkStatic(Intent::getCaptchaCallbackTokenResult)
unmockkStatic(Intent::getSsoCallbackResult)
}
@Test
@ -40,6 +45,9 @@ class WebAuthCallbackViewModelTest : BaseViewModelTest() {
} returns CaptchaCallbackTokenResult.Success(
token = "mockk_token",
)
every {
mockIntent.getSsoCallbackResult()
} returns null
viewModel.trySendAction(
WebAuthCallbackAction.IntentReceive(
intent = mockIntent,
@ -54,6 +62,34 @@ class WebAuthCallbackViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on ReceiveNewIntent with sso host should call setSsoCallbackResult`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
every {
mockIntent.getSsoCallbackResult()
} returns SsoCallbackResult.Success(
state = "mockk_state",
code = "mockk_code",
)
every {
mockIntent.getCaptchaCallbackTokenResult()
} returns null
viewModel.trySendAction(
WebAuthCallbackAction.IntentReceive(
intent = mockIntent,
),
)
verify {
authRepository.setSsoCallbackResult(
result = SsoCallbackResult.Success(
state = "mockk_state",
code = "mockk_code",
),
)
}
}
private fun createViewModel() = WebAuthCallbackViewModel(
authRepository = authRepository,
)

View file

@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -1006,6 +1007,19 @@ class AuthRepositoryTest {
}
}
@Test
fun `setSsoCallbackResult should change the value of ssoCallbackResultFlow`() = runTest {
repository.ssoCallbackResultFlow.test {
repository.setSsoCallbackResult(
SsoCallbackResult.Success(state = "mockk_state", code = "mockk_code"),
)
assertEquals(
SsoCallbackResult.Success(state = "mockk_state", code = "mockk_code"),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `logout for the active account should call logout on the UserLogoutManager and clear the user's in memory vault data`() {

View file

@ -0,0 +1,58 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
class SsoUtilsTest {
@Test
fun `getSsoCallbackResult should return null when data is null`() {
val intent = mockk<Intent> {
every { data } returns null
every { action } returns Intent.ACTION_VIEW
}
val result = intent.getSsoCallbackResult()
assertNull(result)
}
@Test
fun `getSsoCallbackResult should return null when action is not Intent ACTION_VIEW`() {
val intent = mockk<Intent> {
every { data } returns null
every { action } returns Intent.ACTION_ANSWER
}
val result = intent.getSsoCallbackResult()
assertNull(result)
}
@Test
fun `getSsoCallbackResult should return MissingCode with missing state code`() {
val intent = mockk<Intent> {
every { data?.getQueryParameter("state") } returns "myState"
every { data?.getQueryParameter("code") } returns null
every { action } returns Intent.ACTION_VIEW
every { data?.host } returns "sso-callback"
}
val result = intent.getSsoCallbackResult()
assertEquals(SsoCallbackResult.MissingCode, result)
}
@Test
fun `getSsoCallbackResult should return Success when code query parameter is present`() {
val intent = mockk<Intent> {
every { data?.getQueryParameter("code") } returns "myCode"
every { data?.getQueryParameter("state") } returns "myState"
every { action } returns Intent.ACTION_VIEW
every { data?.host } returns "sso-callback"
}
val result = intent.getSsoCallbackResult()
assertEquals(
SsoCallbackResult.Success(state = "myState", code = "myCode"),
result,
)
}
}