From 5d84df9d315c05be7f24f92f68cf2a6c528f07e9 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:17:54 -0500 Subject: [PATCH] [PM-31993] Add deep link utilities for cookie vendor callbacks (#6506) Co-authored-by: Claude --- .../data/auth/repository/util/CookieUtils.kt | 60 +++++++ .../auth/repository/util/CookieUtilsTest.kt | 170 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtils.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtilsTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtils.kt new file mode 100644 index 0000000000..6919609c2d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtils.kt @@ -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) : CookieCallbackResult() +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtilsTest.kt new file mode 100644 index 0000000000..eee7bcaf08 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/CookieUtilsTest.kt @@ -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, + ) + } +}