mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-816: Add handling for SSO intents (#724)
This commit is contained in:
parent
6a49a37ef0
commit
112d181394
8 changed files with 201 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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`() {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue