Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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<IdToken> {
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<AccessToken> {
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<RefreshToken> {
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"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<CognitoUserPoolTokens>(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<CognitoUserPoolTokens>(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<CognitoUserPoolTokens>(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<CognitoUserPoolTokens>(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<CognitoUserPoolTokens>(flatFormatJson)
val tokensFromNested = Json.decodeFromString<CognitoUserPoolTokens>(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
}
}
Loading