[PM-31993] Add deep link utilities for cookie vendor callbacks (#6506)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen
2026-02-10 11:17:54 -05:00
committed by GitHub
parent d8c69a3243
commit 5d84df9d31
2 changed files with 230 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/** URI scheme for cookie vendor callback. */
private const val COOKIE_CALLBACK_SCHEME: String = "bitwarden"
/** URI host for cookie vendor callback. */
private const val COOKIE_CALLBACK_HOST: String = "sso_cookie_vendor"
/** Completeness marker parameter name (filtered from cookie extraction). */
private const val COMPLETENESS_MARKER_PARAM = "d"
/**
* Extracts cookie callback result from Intent.
* Handles both single and sharded cookie formats.
* Filters out the 'd' completeness marker parameter.
*
* @return [CookieCallbackResult] if this is a cookie callback, null otherwise.
*/
fun Intent.getCookieCallbackResultOrNull(): CookieCallbackResult? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.scheme != COOKIE_CALLBACK_SCHEME) return null
if (uri.host != COOKIE_CALLBACK_HOST) return null
val cookies = uri.queryParameterNames
.asSequence()
.filter { it != COMPLETENESS_MARKER_PARAM }
.mapNotNull { name ->
uri.getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
}
.toMap()
return if (cookies.isEmpty()) {
CookieCallbackResult.MissingCookie
} else {
CookieCallbackResult.Success(cookies)
}
}
/**
* Represents the result of a cookie callback from a deep link.
*/
sealed class CookieCallbackResult : Parcelable {
/**
* The callback did not contain any cookies.
*/
@Parcelize
data object MissingCookie : CookieCallbackResult()
/**
* Successfully extracted cookies from the callback.
* @param cookies Map of cookie name to cookie value. Supports sharded cookies.
*/
@Parcelize
data class Success(val cookies: Map<String, String>) : CookieCallbackResult()
}

View File

@@ -0,0 +1,170 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNull
class CookieUtilsTest {
private lateinit var mockUri: Uri
private lateinit var mockIntent: Intent
@BeforeEach
fun setUp() {
mockUri = mockk(relaxed = true) {
every { scheme } returns "bitwarden"
every { host } returns "sso_cookie_vendor"
}
mockIntent = mockk(relaxed = true) {
every { action } returns Intent.ACTION_VIEW
every { data } returns mockUri
}
}
@Test
fun `getCookieCallbackResultOrNull should return null when action is not ACTION_VIEW`() {
every { mockIntent.action } returns Intent.ACTION_MAIN
val result = mockIntent.getCookieCallbackResultOrNull()
assertNull(result)
}
@Test
fun `getCookieCallbackResultOrNull should return null when data is null`() {
every { mockIntent.data } returns null
val result = mockIntent.getCookieCallbackResultOrNull()
assertNull(result)
}
@Test
fun `getCookieCallbackResultOrNull should return null when scheme is wrong`() {
every { mockUri.scheme } returns "https"
val result = mockIntent.getCookieCallbackResultOrNull()
assertNull(result)
}
@Test
fun `getCookieCallbackResultOrNull should return null when host is wrong`() {
every { mockUri.host } returns "sso-callback"
val result = mockIntent.getCookieCallbackResultOrNull()
assertNull(result)
}
@Test
fun `getCookieCallbackResultOrNull should return MissingCookie when no query parameters`() {
every { mockUri.queryParameterNames } returns emptySet()
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(CookieCallbackResult.MissingCookie, result)
}
@Test
fun `getCookieCallbackResultOrNull should return MissingCookie with only d parameter`() {
every { mockUri.queryParameterNames } returns setOf("d")
every { mockUri.getQueryParameter("d") } returns "1"
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(CookieCallbackResult.MissingCookie, result)
}
@Test
fun `getCookieCallbackResultOrNull should parse single cookie correctly`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB")
every { mockUri.getQueryParameter("AWSELB") } returns "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(
CookieCallbackResult.Success(
cookies = mapOf("AWSELB" to "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
),
result,
)
}
@Test
fun `getCookieCallbackResultOrNull should parse sharded cookies correctly`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB-0", "AWSELB-1", "AWSELB-2")
every { mockUri.getQueryParameter("AWSELB-0") } returns "part0"
every { mockUri.getQueryParameter("AWSELB-1") } returns "part1"
every { mockUri.getQueryParameter("AWSELB-2") } returns "part2"
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(
CookieCallbackResult.Success(
cookies = mapOf(
"AWSELB-0" to "part0",
"AWSELB-1" to "part1",
"AWSELB-2" to "part2",
),
),
result,
)
}
@Test
fun `getCookieCallbackResultOrNull should filter out d parameter from sharded cookies`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB-0", "AWSELB-1", "d")
every { mockUri.getQueryParameter("AWSELB-0") } returns "part0"
every { mockUri.getQueryParameter("AWSELB-1") } returns "part1"
every { mockUri.getQueryParameter("d") } returns "1"
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(
CookieCallbackResult.Success(
cookies = mapOf(
"AWSELB-0" to "part0",
"AWSELB-1" to "part1",
),
),
result,
)
}
@Test
fun `getCookieCallbackResultOrNull should handle empty cookie value as missing`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB")
every { mockUri.getQueryParameter("AWSELB") } returns ""
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(CookieCallbackResult.MissingCookie, result)
}
@Test
fun `getCookieCallbackResultOrNull should handle null cookie value as missing`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB")
every { mockUri.getQueryParameter("AWSELB") } returns null
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(CookieCallbackResult.MissingCookie, result)
}
@Test
fun `getCookieCallbackResultOrNull should handle multiple different cookies`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB", "SESSION_ID", "XSRF_TOKEN")
every { mockUri.getQueryParameter("AWSELB") } returns "cookie1"
every { mockUri.getQueryParameter("SESSION_ID") } returns "cookie2"
every { mockUri.getQueryParameter("XSRF_TOKEN") } returns "cookie3"
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(
CookieCallbackResult.Success(
cookies = mapOf(
"AWSELB" to "cookie1",
"SESSION_ID" to "cookie2",
"XSRF_TOKEN" to "cookie3",
),
),
result,
)
}
@Test
fun `getCookieCallbackResultOrNull should handle mixed valid and empty cookies`() {
every { mockUri.queryParameterNames } returns setOf("AWSELB", "EMPTY_COOKIE")
every { mockUri.getQueryParameter("AWSELB") } returns "validValue"
every { mockUri.getQueryParameter("EMPTY_COOKIE") } returns ""
val result = mockIntent.getCookieCallbackResultOrNull()
assertEquals(
CookieCallbackResult.Success(
cookies = mapOf("AWSELB" to "validValue"),
),
result,
)
}
}