mirror of
https://github.com/bitwarden/android.git
synced 2026-02-04 03:05:28 -06:00
PM-30522: Add support for processing app links for Duo, WebAuthn, and SSO (#6332)
This commit is contained in:
parent
c4a94cf5d1
commit
5d308aa95f
@ -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" />
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user