PM-30522: Add support for processing app links for Duo, WebAuthn, and SSO (#6332)

This commit is contained in:
David Perez 2026-01-07 13:45:04 -06:00 committed by GitHub
parent c4a94cf5d1
commit 5d308aa95f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 203 additions and 29 deletions

View File

@ -140,6 +140,19 @@
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.eu" />
<data android:pathPattern="/duo-callback" />
<data android:pathPattern="/sso-callback" />
<data android:pathPattern="/webauthn-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View File

@ -5,7 +5,11 @@ import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import com.bitwarden.annotation.OmitFromCoverage
private const val DUO_HOST: String = "duo-callback"
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "duo-callback"
/**
* Retrieves a [DuoCallbackTokenResult] from an Intent. There are three possible cases.
@ -18,11 +22,28 @@ private const val DUO_HOST: String = "duo-callback"
* - [DuoCallbackTokenResult.Success]: Intent is the Duo callback, and it has a token.
*/
fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
val localData = data
return if (action == Intent.ACTION_VIEW && localData != null && localData.host == DUO_HOST) {
localData.getDuoCallbackTokenResult()
} else {
null
if (action != Intent.ACTION_VIEW) return null
val localData = data ?: return null
return when (localData.scheme) {
DEEPLINK_SCHEME -> {
if (localData.host == CALLBACK) {
localData.getDuoCallbackTokenResult()
} else {
null
}
}
APP_LINK_SCHEME -> {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getDuoCallbackTokenResult()
} else {
null
}
}
else -> null
}
}

View File

@ -11,8 +11,13 @@ import java.net.URLEncoder
import java.security.MessageDigest
import java.util.Base64
private const val SSO_HOST: String = "sso-callback"
const val SSO_URI: String = "bitwarden://$SSO_HOST"
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "sso-callback"
const val SSO_URI: String = "bitwarden://$CALLBACK"
/**
* Generates a URI for the SSO custom tab.
@ -64,11 +69,28 @@ fun generateUriForSso(
* - [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) {
localData.getSsoCallbackResult()
} else {
null
if (action != Intent.ACTION_VIEW) return null
val localData = data ?: return null
return when (localData.scheme) {
DEEPLINK_SCHEME -> {
if (localData.host == CALLBACK) {
localData.getSsoCallbackResult()
} else {
null
}
}
APP_LINK_SCHEME -> {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getSsoCallbackResult()
} else {
null
}
}
else -> null
}
}

View File

@ -11,8 +11,13 @@ import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
private const val WEB_AUTH_HOST: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
/**
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
@ -22,14 +27,28 @@ private const val CALLBACK_URI = "bitwarden://$WEB_AUTH_HOST"
* - [WebAuthResult.Failure]: Intent is the web auth key callback with incorrect data.
*/
fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
val localData = data
return if (action == Intent.ACTION_VIEW &&
localData != null &&
localData.host == WEB_AUTH_HOST
) {
localData.getWebAuthResult()
} else {
null
if (action != Intent.ACTION_VIEW) return null
val localData = data ?: return null
return when (localData.scheme) {
DEEPLINK_SCHEME -> {
if (localData.host == CALLBACK) {
localData.getWebAuthResult()
} else {
null
}
}
APP_LINK_SCHEME -> {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getWebAuthResult()
} else {
null
}
}
else -> null
}
}

View File

@ -33,6 +33,7 @@ class DuoUtilsTest {
fun `getDuoCallbackTokenResult should return null when host is not the duo callback`() {
val intent = mockk<Intent> {
every { data?.host } returns "wrongHost"
every { data?.scheme } returns "bitwarden"
every { action } returns Intent.ACTION_VIEW
}
val result = intent.getDuoCallbackTokenResult()
@ -43,6 +44,7 @@ class DuoUtilsTest {
fun `getDuoCallbackTokenResult should return MissingToken code is null`() {
val intent = mockk<Intent> {
every { data?.host } returns "duo-callback"
every { data?.scheme } returns "bitwarden"
every { data?.getQueryParameter("code") } returns null
every { data?.getQueryParameter("state") } returns "state"
every { action } returns Intent.ACTION_VIEW
@ -52,9 +54,10 @@ class DuoUtilsTest {
}
@Test
fun `getDuoCallbackTokenResult should return MissingToken state is null`() {
fun `getDuoCallbackTokenResult for deeplink should return MissingToken when state is null`() {
val intent = mockk<Intent> {
every { data?.host } returns "duo-callback"
every { data?.scheme } returns "bitwarden"
every { data?.getQueryParameter("code") } returns "code"
every { data?.getQueryParameter("state") } returns null
every { action } returns Intent.ACTION_VIEW
@ -64,9 +67,38 @@ class DuoUtilsTest {
}
@Test
fun `getDuoCallbackTokenResult should return Success when all data is present`() {
fun `getDuoCallbackTokenResult for deeplink should return Success when all data is present`() {
val intent = mockk<Intent> {
every { data?.host } returns "duo-callback"
every { data?.scheme } returns "bitwarden"
every { data?.getQueryParameter("code") } returns "code"
every { data?.getQueryParameter("state") } returns "state"
every { action } returns Intent.ACTION_VIEW
}
val result = intent.getDuoCallbackTokenResult()
assertEquals(DuoCallbackTokenResult.Success(token = "code|state"), result)
}
@Test
fun `getDuoCallbackTokenResult for app link should return MissingToken when state is null`() {
val intent = mockk<Intent> {
every { data?.host } returns "bitwarden.com"
every { data?.scheme } returns "https"
every { data?.path } returns "/duo-callback"
every { data?.getQueryParameter("code") } returns "code"
every { data?.getQueryParameter("state") } returns null
every { action } returns Intent.ACTION_VIEW
}
val result = intent.getDuoCallbackTokenResult()
assertEquals(DuoCallbackTokenResult.MissingToken, result)
}
@Test
fun `getDuoCallbackTokenResult for app link should return Success when all data is present`() {
val intent = mockk<Intent> {
every { data?.host } returns "bitwarden.eu"
every { data?.scheme } returns "https"
every { data?.path } returns "/duo-callback"
every { data?.getQueryParameter("code") } returns "code"
every { data?.getQueryParameter("state") } returns "state"
every { action } returns Intent.ACTION_VIEW

View File

@ -61,24 +61,59 @@ class SsoUtilsTest {
}
@Test
fun `getSsoCallbackResult should return MissingCode with missing state code`() {
fun `getSsoCallbackResult for deeplink 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"
every { data?.scheme } returns "bitwarden"
}
val result = intent.getSsoCallbackResult()
assertEquals(SsoCallbackResult.MissingCode, result)
}
@Suppress("MaxLineLength")
@Test
fun `getSsoCallbackResult should return Success when code query parameter is present`() {
fun `getSsoCallbackResult for deeplink 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"
every { data?.scheme } returns "bitwarden"
}
val result = intent.getSsoCallbackResult()
assertEquals(
SsoCallbackResult.Success(state = "myState", code = "myCode"),
result,
)
}
@Test
fun `getSsoCallbackResult for app link 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?.scheme } returns "https"
every { data?.host } returns "bitwarden.eu"
every { data?.path } returns "/sso-callback"
}
val result = intent.getSsoCallbackResult()
assertEquals(SsoCallbackResult.MissingCode, result)
}
@Suppress("MaxLineLength")
@Test
fun `getSsoCallbackResult for app link 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?.scheme } returns "https"
every { data?.host } returns "bitwarden.com"
every { data?.path } returns "/sso-callback"
}
val result = intent.getSsoCallbackResult()
assertEquals(

View File

@ -53,24 +53,56 @@ class WebAuthUtilsTest : BitwardenComposeTest() {
}
@Test
fun `getWebAuthResultOrNull should return Failure with missing data parameter`() {
fun `getWebAuthResultOrNull for deeplink should return Failure with missing data parameter`() {
val message = "An Error!"
val intent = mockk<Intent> {
every { data?.getQueryParameter("data") } returns null
every { data?.getQueryParameter("error") } returns message
every { action } returns Intent.ACTION_VIEW
every { data?.host } returns "webauthn-callback"
every { data?.scheme } returns "bitwarden"
}
val result = intent.getWebAuthResultOrNull()
assertEquals(WebAuthResult.Failure(message = message), result)
}
@Suppress("MaxLineLength")
@Test
fun `getWebAuthResultOrNull should return Success when data query parameter is present`() {
fun `getWebAuthResultOrNull for deeplink should return Success when data query parameter is present`() {
val intent = mockk<Intent> {
every { data?.getQueryParameter("data") } returns "myToken"
every { action } returns Intent.ACTION_VIEW
every { data?.host } returns "webauthn-callback"
every { data?.scheme } returns "bitwarden"
}
val result = intent.getWebAuthResultOrNull()
assertEquals(WebAuthResult.Success("myToken"), result)
}
@Test
fun `getWebAuthResultOrNull for app link should return Failure with missing data parameter`() {
val message = "An Error!"
val intent = mockk<Intent> {
every { data?.getQueryParameter("data") } returns null
every { data?.getQueryParameter("error") } returns message
every { action } returns Intent.ACTION_VIEW
every { data?.scheme } returns "https"
every { data?.host } returns "bitwarden.com"
every { data?.path } returns "/webauthn-callback"
}
val result = intent.getWebAuthResultOrNull()
assertEquals(WebAuthResult.Failure(message = message), result)
}
@Suppress("MaxLineLength")
@Test
fun `getWebAuthResultOrNull for app link should return Success when data query parameter is present`() {
val intent = mockk<Intent> {
every { data?.getQueryParameter("data") } returns "myToken"
every { action } returns Intent.ACTION_VIEW
every { data?.scheme } returns "https"
every { data?.host } returns "bitwarden.eu"
every { data?.path } returns "/webauthn-callback"
}
val result = intent.getWebAuthResultOrNull()
assertEquals(WebAuthResult.Success("myToken"), result)