Skip to content

Commit d702bbf

Browse files
PM-26577: Support multiple schemes for Duo, WebAuthn, and SSO callbacks
1 parent 1d35004 commit d702bbf

File tree

12 files changed

+240
-105
lines changed

12 files changed

+240
-105
lines changed

app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtils.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
33
import android.content.Intent
44
import android.net.Uri
55
import androidx.browser.auth.AuthTabIntent
6+
import androidx.core.net.toUri
67
import com.bitwarden.annotation.OmitFromCoverage
78

89
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
@@ -78,6 +79,14 @@ private fun Uri?.getDuoCallbackTokenResult(): DuoCallbackTokenResult {
7879
}
7980
}
8081

82+
/**
83+
* Generates a [Uri] to display a duo challenge for Bitwarden authentication.
84+
*/
85+
fun generateUriForDuo(
86+
authUrl: String,
87+
appLinksScheme: String,
88+
): Uri = "$authUrl&deeplinkScheme=$appLinksScheme".toUri()
89+
8190
/**
8291
* Sealed class representing the result of Duo callback token extraction.
8392
*/

app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SsoUtils.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,26 @@ private const val APP_LINK_SCHEME: String = "https"
1717
private const val DEEPLINK_SCHEME: String = "bitwarden"
1818
private const val CALLBACK: String = "sso-callback"
1919

20-
const val SSO_URI: String = "bitwarden://$CALLBACK"
21-
2220
/**
2321
* Generates a URI for the SSO custom tab.
2422
*
2523
* @param identityBaseUrl The base URl for the identity service.
24+
* @param redirectUrl The redirect URI used in the SSO request.
2625
* @param organizationIdentifier The SSO organization identifier.
2726
* @param token The prevalidated SSO token.
2827
* @param state Random state used to verify the validity of the response.
2928
* @param codeVerifier A random string used to generate the code challenge.
3029
*/
30+
@Suppress("LongParameterList")
3131
fun generateUriForSso(
3232
identityBaseUrl: String,
33+
redirectUrl: String,
3334
organizationIdentifier: String,
3435
token: String,
3536
state: String,
3637
codeVerifier: String,
3738
): Uri {
38-
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
39+
val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8")
3940
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
4041
val encodedToken = URLEncoder.encode(token, "UTF-8")
4142

app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WebAuthUtils.kt

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import com.bitwarden.annotation.OmitFromCoverage
88
import kotlinx.serialization.json.JsonObject
99
import kotlinx.serialization.json.buildJsonObject
1010
import kotlinx.serialization.json.put
11-
import java.net.URLEncoder
1211
import java.util.Base64
1312

1413
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
@@ -17,8 +16,6 @@ private const val APP_LINK_SCHEME: String = "https"
1716
private const val DEEPLINK_SCHEME: String = "bitwarden"
1817
private const val CALLBACK: String = "webauthn-callback"
1918

20-
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
21-
2219
/**
2320
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
2421
*
@@ -79,29 +76,31 @@ private fun Uri?.getWebAuthResult(): WebAuthResult =
7976
/**
8077
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
8178
*/
79+
@Suppress("LongParameterList")
8280
fun generateUriForWebAuth(
8381
baseUrl: String,
82+
callbackScheme: String,
8483
data: JsonObject,
8584
headerText: String,
8685
buttonText: String,
8786
returnButtonText: String,
8887
): Uri {
8988
val json = buildJsonObject {
90-
put(key = "callbackUri", value = CALLBACK_URI)
9189
put(key = "data", value = data.toString())
9290
put(key = "headerText", value = headerText)
9391
put(key = "btnText", value = buttonText)
9492
put(key = "btnReturnText", value = returnButtonText)
93+
put(key = "mobile", value = true)
9594
}
9695
val base64Data = Base64
9796
.getEncoder()
9897
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
99-
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
10098
val url = baseUrl +
10199
"/webauthn-mobile-connector.html" +
102100
"?data=$base64Data" +
103-
"&parent=$parentParam" +
104-
"&v=2"
101+
"&client=mobile" +
102+
"&v=2" +
103+
"&deeplinkScheme=$callbackScheme"
105104
return url.toUri()
106105
}
107106

app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import android.net.Uri
44
import android.os.Parcelable
55
import androidx.lifecycle.SavedStateHandle
66
import androidx.lifecycle.viewModelScope
7+
import com.bitwarden.data.repository.util.appLinksScheme
78
import com.bitwarden.data.repository.util.baseIdentityUrl
89
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
10+
import com.bitwarden.data.repository.util.ssoAppLinksRedirectUrl
911
import com.bitwarden.ui.platform.base.BaseViewModel
1012
import com.bitwarden.ui.platform.resource.BitwardenString
1113
import com.bitwarden.ui.util.Text
@@ -14,7 +16,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
1416
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
1517
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
1618
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
17-
import com.x8bit.bitwarden.data.auth.repository.util.SSO_URI
1819
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
1920
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso
2021
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
@@ -342,14 +343,13 @@ class EnterpriseSignOnViewModel @Inject constructor(
342343
if (ssoCallbackResult.state == ssoData.state) {
343344
showLoading()
344345
viewModelScope.launch {
345-
val result = authRepository
346-
.login(
347-
email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
348-
ssoCode = ssoCallbackResult.code,
349-
ssoCodeVerifier = ssoData.codeVerifier,
350-
ssoRedirectUri = SSO_URI,
351-
organizationIdentifier = state.orgIdentifierInput,
352-
)
346+
val result = authRepository.login(
347+
email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
348+
ssoCode = ssoCallbackResult.code,
349+
ssoCodeVerifier = ssoData.codeVerifier,
350+
ssoRedirectUri = ssoData.redirectUri,
351+
organizationIdentifier = state.orgIdentifierInput,
352+
)
353353
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
354354
}
355355
} else {
@@ -385,18 +385,22 @@ class EnterpriseSignOnViewModel @Inject constructor(
385385
) {
386386
val codeVerifier = generatorRepository.generateRandomString(RANDOM_STRING_LENGTH)
387387

388+
val environmentData = environmentRepository.environment.environmentUrlData
389+
val redirectUrl = environmentData.ssoAppLinksRedirectUrl
388390
// Save this for later so that we can validate the SSO callback response
389391
val generatedSsoState = generatorRepository
390392
.generateRandomString(RANDOM_STRING_LENGTH)
391393
.also {
392394
ssoResponseData = SsoResponseData(
395+
redirectUri = redirectUrl,
393396
codeVerifier = codeVerifier,
394397
state = it,
395398
)
396399
}
397400

398401
val uri = generateUriForSso(
399-
identityBaseUrl = environmentRepository.environment.environmentUrlData.baseIdentityUrl,
402+
identityBaseUrl = environmentData.baseIdentityUrl,
403+
redirectUrl = redirectUrl,
400404
organizationIdentifier = organizationIdentifier,
401405
token = prevalidateSsoResult.token,
402406
state = generatedSsoState,
@@ -408,7 +412,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
408412
sendAction(
409413
EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(
410414
uri = uri,
411-
scheme = "bitwarden",
415+
scheme = environmentData.appLinksScheme,
412416
),
413417
)
414418
}
@@ -612,13 +616,15 @@ sealed class EnterpriseSignOnAction {
612616
/**
613617
* Data needed by the SSO flow to verify and continue the process after receiving a response.
614618
*
619+
* @property redirectUri The redirect URI used in the SSO request.
615620
* @property state A "state" maintained throughout the SSO process to verify that the response from
616621
* the server is valid and matches what was originally sent in the request.
617622
* @property codeVerifier A random string used to generate the code challenge for the initial SSO
618623
* request.
619624
*/
620625
@Parcelize
621626
data class SsoResponseData(
627+
val redirectUri: String,
622628
val state: String,
623629
val codeVerifier: String,
624630
) : Parcelable

app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt

Lines changed: 72 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.annotation.DrawableRes
66
import androidx.core.net.toUri
77
import androidx.lifecycle.SavedStateHandle
88
import androidx.lifecycle.viewModelScope
9+
import com.bitwarden.data.repository.util.appLinksScheme
910
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
1011
import com.bitwarden.network.model.TwoFactorAuthMethod
1112
import com.bitwarden.network.model.TwoFactorDataModel
@@ -23,6 +24,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
2324
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
2425
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
2526
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
27+
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForDuo
2628
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
2729
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
2830
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@@ -173,71 +175,15 @@ class TwoFactorLoginViewModel @Inject constructor(
173175
}
174176

175177
/**
176-
* Navigates to the Duo webpage if appropriate, else processes the login.
178+
* Navigates to the two-factor auth webpage if appropriate, else processes the login.
177179
*/
178-
@Suppress("LongMethod")
179180
private fun handleContinueButtonClick() {
180181
when (state.authMethod) {
181182
TwoFactorAuthMethod.DUO,
182183
TwoFactorAuthMethod.DUO_ORGANIZATION,
183-
-> {
184-
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
185-
// The url should not be empty unless the environment is somehow not supported.
186-
authUrl
187-
?.let {
188-
sendEvent(
189-
event = TwoFactorLoginEvent.NavigateToDuo(
190-
uri = it.toUri(),
191-
scheme = "bitwarden",
192-
),
193-
)
194-
}
195-
?: mutableStateFlow.update {
196-
@Suppress("MaxLineLength")
197-
it.copy(
198-
dialogState = TwoFactorLoginState.DialogState.Error(
199-
title = BitwardenString.an_error_has_occurred.asText(),
200-
message = BitwardenString
201-
.error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance
202-
.asText(),
203-
),
204-
)
205-
}
206-
}
207-
208-
TwoFactorAuthMethod.WEB_AUTH -> {
209-
sendEvent(
210-
event = authRepository
211-
.twoFactorResponse
212-
?.authMethodsData
213-
?.get(TwoFactorAuthMethod.WEB_AUTH)
214-
?.let {
215-
val uri = generateUriForWebAuth(
216-
baseUrl = environmentRepository
217-
.environment
218-
.environmentUrlData
219-
.baseWebVaultUrlOrDefault,
220-
data = it,
221-
headerText = resourceManager.getString(
222-
resId = BitwardenString.fido2_title,
223-
),
224-
buttonText = resourceManager.getString(
225-
resId = BitwardenString.fido2_authenticate_web_authn,
226-
),
227-
returnButtonText = resourceManager.getString(
228-
resId = BitwardenString.fido2_return_to_app,
229-
),
230-
)
231-
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, scheme = "bitwarden")
232-
}
233-
?: TwoFactorLoginEvent.ShowSnackbar(
234-
message = BitwardenString
235-
.there_was_an_error_starting_web_authn_two_factor_authentication
236-
.asText(),
237-
),
238-
)
239-
}
184+
-> handleDuoContinueButtonClick()
240185

186+
TwoFactorAuthMethod.WEB_AUTH -> handleWebAuthnContinueButtonClick()
241187
TwoFactorAuthMethod.AUTHENTICATOR_APP,
242188
TwoFactorAuthMethod.EMAIL,
243189
TwoFactorAuthMethod.YUBI_KEY,
@@ -248,6 +194,73 @@ class TwoFactorLoginViewModel @Inject constructor(
248194
}
249195
}
250196

197+
/**
198+
* Navigates to the Duo webpage if appropriate, or displays the error dialog.
199+
*/
200+
private fun handleDuoContinueButtonClick() {
201+
// The url should not be empty unless the environment is somehow not supported.
202+
authRepository
203+
.twoFactorResponse
204+
.twoFactorDuoAuthUrl
205+
?.let {
206+
val environmentData = environmentRepository.environment.environmentUrlData
207+
val appLinksScheme = environmentData.appLinksScheme
208+
sendEvent(
209+
event = TwoFactorLoginEvent.NavigateToDuo(
210+
uri = generateUriForDuo(authUrl = it, appLinksScheme = appLinksScheme),
211+
scheme = appLinksScheme,
212+
),
213+
)
214+
}
215+
?: mutableStateFlow.update {
216+
@Suppress("MaxLineLength")
217+
it.copy(
218+
dialogState = TwoFactorLoginState.DialogState.Error(
219+
title = BitwardenString.an_error_has_occurred.asText(),
220+
message = BitwardenString
221+
.error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance
222+
.asText(),
223+
),
224+
)
225+
}
226+
}
227+
228+
/**
229+
* Navigates to the Web Authn webpage if appropriate, or displays the error snackbar.
230+
*/
231+
private fun handleWebAuthnContinueButtonClick() {
232+
sendEvent(
233+
event = authRepository
234+
.twoFactorResponse
235+
?.authMethodsData
236+
?.get(TwoFactorAuthMethod.WEB_AUTH)
237+
?.let {
238+
val environmentData = environmentRepository.environment.environmentUrlData
239+
val appLinksScheme = environmentData.appLinksScheme
240+
val uri = generateUriForWebAuth(
241+
baseUrl = environmentData.baseWebVaultUrlOrDefault,
242+
callbackScheme = appLinksScheme,
243+
data = it,
244+
headerText = resourceManager.getString(
245+
resId = BitwardenString.fido2_title,
246+
),
247+
buttonText = resourceManager.getString(
248+
resId = BitwardenString.fido2_authenticate_web_authn,
249+
),
250+
returnButtonText = resourceManager.getString(
251+
resId = BitwardenString.fido2_return_to_app,
252+
),
253+
)
254+
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, scheme = appLinksScheme)
255+
}
256+
?: TwoFactorLoginEvent.ShowSnackbar(
257+
message = BitwardenString
258+
.there_was_an_error_starting_web_authn_two_factor_authentication
259+
.asText(),
260+
),
261+
)
262+
}
263+
251264
/**
252265
* Dismiss the view.
253266
*/

app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/DuoUtilsTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.x8bit.bitwarden.data.auth.repository.util
22

33
import android.content.Intent
4+
import android.net.Uri
45
import io.mockk.every
56
import io.mockk.mockk
67
import org.junit.jupiter.api.Assertions.assertEquals
@@ -9,6 +10,14 @@ import org.junit.jupiter.api.assertNull
910

1011
class DuoUtilsTest {
1112

13+
@Test
14+
fun `generateUriForDuo should return a valid URI`() {
15+
val authUrl = "https://vault.bitwarden.com"
16+
val appLinksScheme = "https"
17+
val actualUri = generateUriForDuo(authUrl = authUrl, appLinksScheme = appLinksScheme)
18+
assertEquals(Uri.parse("$authUrl&deeplinkScheme=$appLinksScheme"), actualUri)
19+
}
20+
1221
@Test
1322
fun `getDuoCallbackTokenResult should return null when action is not VIEW`() {
1423
val intent = mockk<Intent> {

0 commit comments

Comments
 (0)