diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt new file mode 100644 index 000000000..470c66fc3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Contains data that can be parsed from a valid JWT token. + * + * @property userId The ID of the user. + * @property email The user's email address. + * @property isEmailVerified Whether or not the user's email is verified. + * @property name The user's name. + * @property expirationAsEpochTime The expiration time measured as an epoch time in seconds. + * @property hasPremium True if the user has a premium account. + * @property authenticationMethodsReference A list of the authentication methods used during + * authentication. + */ +@Serializable +data class JwtTokenDataJson( + @SerialName("sub") + val userId: String, + @SerialName("email") + val email: String, + @SerialName("email_verified") + val isEmailVerified: Boolean, + @SerialName("name") + val name: String?, + @SerialName("exp") + val expirationAsEpochTime: Int, + @SerialName("premium") + val hasPremium: Boolean, + @SerialName("amr") + val authenticationMethodsReference: List, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/JwtTokenUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/JwtTokenUtils.kt new file mode 100644 index 000000000..5b57472b3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/JwtTokenUtils.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson +import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlDecodeOrNull +import kotlinx.serialization.json.Json + +/** + * Internal, generally basic [Json] instance for JWT parsing purposes. + */ +private val json by lazy { Json { ignoreUnknownKeys = true } } + +/** + * Parses a [JwtTokenDataJson] from the given [jwtToken], or `null` if this parsing is not possible. + */ +@Suppress("MagicNumber", "ReturnCount") +fun parseJwtTokenDataOrNull(jwtToken: String): JwtTokenDataJson? { + val parts = jwtToken.split(".") + if (parts.size != 3) return null + + val dataJson = parts[1] + val decodedDataJson = dataJson.base64UrlDecodeOrNull() ?: return null + + return try { + json.decodeFromString(decodedDataJson) + } catch (_: Throwable) { + null + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtils.kt index 3fe054456..6962bd22e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtils.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtils.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.util +import okio.ByteString.Companion.decodeBase64 +import java.nio.charset.Charset import java.util.Base64 /** @@ -15,3 +17,18 @@ fun String.base64UrlEncode(): String = .replace("+", "-") .replace("/", "_") .replace("=", "") + +/** + * Base 64 decode the given string after making the following replacements: + * + * - replace all "-" with "+" + * - replace all "_" with "/" + * + * A value of `null` will be returned if the decoding fails. + */ +fun String.base64UrlDecodeOrNull(): String? = + this + .replace("-", "+") + .replace("_", "/") + .decodeBase64() + ?.string(Charset.defaultCharset()) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/JwtTokenUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/JwtTokenUtilsTest.kt new file mode 100644 index 000000000..0c94f2cf1 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/JwtTokenUtilsTest.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class JwtTokenUtilsTest { + @Test + fun `parseJwtTokenDataOrNull for a valid token input should return a JwtTokenData`() { + val testJwtToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE2OTc0OTIxMTQsImV4cCI6MTY5NzQ5NTcxN" + + "CwiaXNzIjoiaHR0cHM6Ly9pZGVudGl0eS5iaXR3YXJkZW4uY29tIiwiY2xpZW50X2lkIjoibW9iaWxl" + + "Iiwic3ViIjoiMmExMzViMjMtZTFmYi00MmM5LWJlYzMtNTczODU3YmM4MTgxIiwiYXV0aF90aW1lIjo" + + "xNjk3NDkyMTE0LCJpZHAiOiJiaXR3YXJkZW4iLCJwcmVtaXVtIjpmYWxzZSwiZW1haWwiOiJ0ZXN0QG" + + "JpdHdhcmRlbi5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwic3N0YW1wIjoiSkRIUzRSTUxFNEtGV" + + "EI0TFRIMjVTNkVLRktGTlhOQ0IiLCJuYW1lIjoiQml0d2FyZGVuIFRlc3RlciIsImRldmljZSI6IjNk" + + "ODYxNTU3LWI0Y2MtNDQxZi05YjE4LWM0NTAyYTcxN2UwYiIsImp0aSI6IjA1M0U5NUEzNjFBNEI4QUY" + + "yREEyRDIyNzNDREUxRDVFIiwiaWF0IjoxNjk3NDkyMTE0LCJzY29wZSI6WyJhcGkiLCJvZmZsaW5lX2" + + "FjY2VzcyJdLCJhbXIiOlsiQXBwbGljYXRpb24iXX0.RP2-wABK63Osu-tJY6KJjqVRSJ3-JR_OOdc3N" + + "nm4C5U" + + assertEquals( + JwtTokenDataJson( + userId = "2a135b23-e1fb-42c9-bec3-573857bc8181", + email = "test@bitwarden.com", + isEmailVerified = true, + name = "Bitwarden Tester", + expirationAsEpochTime = 1697495714, + hasPremium = false, + authenticationMethodsReference = listOf("Application"), + ), + parseJwtTokenDataOrNull(jwtToken = testJwtToken), + ) + } + + @Test + fun `parseJwtTokenDataOrNull for an invalid token input should return null`() { + assertNull( + parseJwtTokenDataOrNull(jwtToken = "invalid JWT token"), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtilsTest.kt new file mode 100644 index 000000000..48f2b8d4c --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/util/NetworkUtilsTest.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class NetworkUtilsTest { + @Test + fun `base64UrlEncode should Base64 encode the string and make the relevant replacements`() { + // Checks replacement of + to - and removal of = + assertEquals( + "dis-ZA", + "v+>d".base64UrlEncode(), + ) + // Checks replacement of \ to _ + assertEquals( + "NmI_ImE4", + "6b?\"a8".base64UrlEncode(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `base64UrlDecodeOrNull should Base64 decode the string and make the relevant replacements`() { + // Checks replacement of - to + + assertEquals( + "v+>d", + "dis-ZA==".base64UrlDecodeOrNull(), + ) + // Checks replacement of _ to \ + assertEquals( + "6b?\"a8", + "NmI_ImE4".base64UrlDecodeOrNull(), + ) + } + + @Test + fun `base64UrlDecodeOrNull should return null value a non-encoded String`() { + assertNull( + "*.*".base64UrlDecodeOrNull(), + ) + } +}