diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc9c2b7b..16a33cd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## [Release 2.30.3](https://github.com/aws-amplify/amplify-android/releases/tag/release_v2.30.3) +## 🚨 CRITICAL: Version Deprecated [Release 2.30.3](https://github.com/aws-amplify/amplify-android/releases/tag/release_v2.30.3) + +### Please follow issue [#3160](https://github.com/aws-amplify/amplify-android/issues/3160) for further instructions if you've already updated ### Bug Fixes - **storage:** Prevent missed transfer status updates ([#3154](https://github.com/aws-amplify/amplify-android/issues/3154)) @@ -7,7 +9,9 @@ [See all changes between 2.30.2 and 2.30.3](https://github.com/aws-amplify/amplify-android/compare/release_v2.30.2...release_v2.30.3) -## [Release 2.30.2](https://github.com/aws-amplify/amplify-android/releases/tag/release_v2.30.2) +## 🚨 CRITICAL: Version Deprecated [Release 2.30.2](https://github.com/aws-amplify/amplify-android/releases/tag/release_v2.30.2) + +### Please follow issue [#3160](https://github.com/aws-amplify/amplify-android/issues/3160) for further instructions if you've already updated ### Bug Fixes - **auth:** Fix losing session identifier when incorrect otp code is entered during confirm sign up ([#3136](https://github.com/aws-amplify/amplify-android/issues/3136)) diff --git a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/Tokens.kt b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/Tokens.kt index d9236374d..9d9553d6c 100644 --- a/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/Tokens.kt +++ b/aws-auth-cognito/src/main/java/com/amplifyframework/statemachine/codegen/data/Tokens.kt @@ -20,7 +20,16 @@ import com.amplifyframework.auth.exceptions.UnknownException import com.amplifyframework.statemachine.util.mask import java.time.Instant import kotlin.text.Charsets.UTF_8 +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive import org.json.JSONObject internal abstract class Jwt { @@ -72,7 +81,7 @@ internal abstract class Jwt { } // See https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-id-token.html -@Serializable +@Serializable(with = IdTokenAsStringSerializer::class) internal class IdToken(override val tokenValue: String) : Jwt() { val userSub: String? get() = getClaim(Claim.UserSub) @@ -84,7 +93,7 @@ internal class IdToken(override val tokenValue: String) : Jwt() { } // See https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html -@Serializable +@Serializable(with = AccessTokenAsStringSerializer::class) internal class AccessToken(override val tokenValue: String) : Jwt() { val tokenRevocationId: String? get() = getClaim(Claim.TokenRevocationId) @@ -98,7 +107,7 @@ internal class AccessToken(override val tokenValue: String) : Jwt() { } // Refresh token is just an opaque base64 string -@Serializable +@Serializable(with = RefreshTokenAsStringSerializer::class) @JvmInline internal value class RefreshToken(val tokenValue: String) { override fun toString() = tokenValue.mask() @@ -142,3 +151,46 @@ internal data class CognitoUserPoolTokens( idToken == other.idToken && accessToken == other.accessToken && refreshToken == other.refreshToken } } + +/** + * Helper function to extract token value from either flat or nested format + * commit 047483866231622a362f736350f600072affad86 unintentionally introduced a token serialization/deserialization + * change causing logouts. This method ensures tokens are readable in a flat string format, or nested object. + */ +private fun extractTokenValue(decoder: Decoder, tokenType: String): String = if (decoder is JsonDecoder) { + when (val element = decoder.decodeJsonElement()) { + is JsonPrimitive -> element.content // Flat format: "token": "value" + is JsonObject -> element["tokenValue"]?.jsonPrimitive?.content + ?: throw SerializationException("Missing tokenValue in nested $tokenType") + else -> throw SerializationException("Expected string or object for $tokenType") + } +} else { + decoder.decodeString() // Fallback for non-JSON decoders +} + +/** + * Serializer for IdToken that maintains string serialization format + */ +internal object IdTokenAsStringSerializer : KSerializer { + override val descriptor = String.serializer().descriptor + override fun serialize(encoder: Encoder, value: IdToken) = encoder.encodeString(value.tokenValue) + override fun deserialize(decoder: Decoder) = IdToken(extractTokenValue(decoder, "IdToken")) +} + +/** + * Serializer for AccessToken that maintains string serialization format + */ +internal object AccessTokenAsStringSerializer : KSerializer { + override val descriptor = String.serializer().descriptor + override fun serialize(encoder: Encoder, value: AccessToken) = encoder.encodeString(value.tokenValue) + override fun deserialize(decoder: Decoder) = AccessToken(extractTokenValue(decoder, "AccessToken")) +} + +/** + * Serializer for RefreshToken that maintains string serialization format + */ +internal object RefreshTokenAsStringSerializer : KSerializer { + override val descriptor = String.serializer().descriptor + override fun serialize(encoder: Encoder, value: RefreshToken) = encoder.encodeString(value.tokenValue) + override fun deserialize(decoder: Decoder) = RefreshToken(extractTokenValue(decoder, "RefreshToken")) +} diff --git a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/data/TokensTest.kt b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/data/TokensTest.kt index 01bca8a2d..2bd881d61 100644 --- a/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/data/TokensTest.kt +++ b/aws-auth-cognito/src/test/java/com/amplifyframework/auth/cognito/data/TokensTest.kt @@ -21,6 +21,8 @@ import com.amplifyframework.statemachine.codegen.data.asIdToken import com.amplifyframework.statemachine.codegen.data.asRefreshToken import io.kotest.matchers.shouldBe import java.time.Instant +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.junit.Test class TokensTest { @@ -89,4 +91,115 @@ class TokensTest { "CognitoUserPoolTokens(idToken=eyJh***, accessToken=eyJh***, " + "refreshToken=eyJh***, expiration=null)" } + + @Test + fun `non nested tokens are parsed correctly`() { + val flatFormatJson = """ + { + "idToken": "$tokenString", + "accessToken": "$tokenString", + "refreshToken": "refresh_token_value", + "expiration": 1756998578 + } + """.trimIndent() + + val tokens = Json.decodeFromString(flatFormatJson) + + tokens.idToken?.tokenValue shouldBe tokenString + tokens.accessToken?.tokenValue shouldBe tokenString + tokens.refreshToken?.tokenValue shouldBe "refresh_token_value" + tokens.expiration shouldBe 1756998578L + + // Verify JWT parsing still works + tokens.accessToken?.userSub shouldBe "1234567890" + tokens.accessToken?.username shouldBe "jdoe" + } + + @Test + fun `nested tokens are read correctly`() { + val nestedFormatJson = """ + { + "idToken": {"tokenValue": "$tokenString"}, + "accessToken": {"tokenValue": "$tokenString"}, + "refreshToken": {"tokenValue": "refresh_token_value"}, + "expiration": 1756998578 + } + """.trimIndent() + + val tokens = Json.decodeFromString(nestedFormatJson) + + tokens.idToken?.tokenValue shouldBe tokenString + tokens.accessToken?.tokenValue shouldBe tokenString + tokens.refreshToken?.tokenValue shouldBe "refresh_token_value" + tokens.expiration shouldBe 1756998578L + + // Verify JWT parsing still works after extracting from nested format + tokens.accessToken?.userSub shouldBe "1234567890" + tokens.accessToken?.username shouldBe "jdoe" + } + + @Test + fun `nested tokens are saved as non nested`() { + // Start with nested format + val nestedFormatJson = """ + { + "idToken": {"tokenValue": "$tokenString"}, + "accessToken": {"tokenValue": "$tokenString"}, + "refreshToken": {"tokenValue": "refresh_token_value"}, + "expiration": 1756998578 + } + """.trimIndent() + + // Deserialize nested format + val tokens = Json.decodeFromString(nestedFormatJson) + + // Serialize back to JSON + val serializedJson = Json.encodeToString(tokens) + + // Should now be in flat format + val expectedFlatJson = + """{"idToken":"$tokenString","accessToken":"$tokenString","refreshToken":"refresh_token_value","expiration":1756998578}""" + serializedJson shouldBe expectedFlatJson + + // Verify we can deserialize the flat format again + val tokensFromFlat = Json.decodeFromString(serializedJson) + tokensFromFlat.idToken?.tokenValue shouldBe tokenString + tokensFromFlat.accessToken?.tokenValue shouldBe tokenString + tokensFromFlat.refreshToken?.tokenValue shouldBe "refresh_token_value" + } + + @Test + fun `flat and nested formats produce identical results`() { + val flatFormatJson = """ + { + "idToken": "$tokenString", + "accessToken": "$tokenString", + "refreshToken": "refresh_token_value", + "expiration": 1756998578 + } + """.trimIndent() + + val nestedFormatJson = """ + { + "idToken": {"tokenValue": "$tokenString"}, + "accessToken": {"tokenValue": "$tokenString"}, + "refreshToken": {"tokenValue": "refresh_token_value"}, + "expiration": 1756998578 + } + """.trimIndent() + + val tokensFromFlat = Json.decodeFromString(flatFormatJson) + val tokensFromNested = Json.decodeFromString(nestedFormatJson) + + // Both should have identical token values + tokensFromFlat.idToken?.tokenValue shouldBe tokensFromNested.idToken?.tokenValue + tokensFromFlat.accessToken?.tokenValue shouldBe tokensFromNested.accessToken?.tokenValue + tokensFromFlat.refreshToken?.tokenValue shouldBe tokensFromNested.refreshToken?.tokenValue + tokensFromFlat.expiration shouldBe tokensFromNested.expiration + + // Both should serialize to the same flat format + val serializedFlat = Json.encodeToString(tokensFromFlat) + val serializedNested = Json.encodeToString(tokensFromNested) + serializedFlat shouldBe serializedNested + } }