From 577d9214458b4e3a2a9a1758838635a4d79f4177 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 16:49:39 +0800 Subject: [PATCH] fix(security): make JwtRoundTripTest 'tampered token' deterministic --- platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt b/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt index ee27bbb..fc189b9 100644 --- a/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt +++ b/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt @@ -84,8 +84,29 @@ class JwtRoundTripTest { @Test fun `tampered token is rejected`() { val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol") - // Flip the last character of the signature segment. - val tampered = issued.value.dropLast(1) + if (issued.value.last() == 'a') 'b' else 'a' + // Tamper with the PAYLOAD (middle JWT segment) by flipping one + // character to a definitely-different valid base64url character. + // Flipping the signature was flaky: the JWT spec uses unpadded + // base64url, and the last character of the signature can encode + // partial bits — flipping it sometimes produces a byte the + // decoder lenient-parses to the same value, leaving the + // signature still valid. Mutating the payload sidesteps the + // edge case entirely: any change to the payload changes the + // bytes the signature is computed over, which always fails + // verification. + val parts = issued.value.split(".") + require(parts.size == 3) { "expected a 3-part JWT, got ${parts.size}" } + val payload = parts[1] + // Flip the first character of the payload to a different + // base64url character that is guaranteed to be different from + // whatever was there. We pick 'A' if the first char isn't 'A', + // else 'B'. Both are valid base64url so the format stays + // intact and the failure mode is "signature does not verify", + // not "malformed token" — exactly what the test should assert. + val firstChar = payload[0] + val replacement = if (firstChar == 'A') 'B' else 'A' + val tamperedPayload = replacement + payload.substring(1) + val tampered = "${parts[0]}.$tamperedPayload.${parts[2]}" assertFailure { verifier.verify(tampered) } .isInstanceOf(AuthenticationFailedException::class) -- libgit2 0.22.2