Commit 577d9214458b4e3a2a9a1758838635a4d79f4177

Authored by zichun
1 parent a9d4726e

fix(security): make JwtRoundTripTest 'tampered token' deterministic

The original test flipped the LAST character of the JWT signature
segment from 'a' to 'b' (or vice versa). This was flaky in CI: with a
random UUID subject, the issued token's signature segment can end on
a base64url character whose flipped value, when decoded, lenient-
parses to bits that the JWT decoder accepts as valid — leaving the
signature still verifiable. The flake was reproducible locally with
`./gradlew test --rerun-tasks` and was the cause of CI failures on
the P5.5 and P4.3 docs-pin commits (and would have caught the
underlying chunks too if the docs commit hadn't pushed first).

Fix: tamper with the PAYLOAD (middle JWT segment) instead. Any
change to the payload changes the bytes the signature is computed
over, which ALWAYS fails HMAC verification — no edge cases. The
test now flips the first character of the payload to a definitely-
different valid base64url character, leaving the format intact so
the failure mode is "signature does not verify" rather than
"malformed token". Verified 10 consecutive `--rerun-tasks` runs
all green locally.
platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt
@@ -84,8 +84,29 @@ class JwtRoundTripTest { @@ -84,8 +84,29 @@ class JwtRoundTripTest {
84 @Test 84 @Test
85 fun `tampered token is rejected`() { 85 fun `tampered token is rejected`() {
86 val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol") 86 val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol")
87 - // Flip the last character of the signature segment.  
88 - val tampered = issued.value.dropLast(1) + if (issued.value.last() == 'a') 'b' else 'a' 87 + // Tamper with the PAYLOAD (middle JWT segment) by flipping one
  88 + // character to a definitely-different valid base64url character.
  89 + // Flipping the signature was flaky: the JWT spec uses unpadded
  90 + // base64url, and the last character of the signature can encode
  91 + // partial bits — flipping it sometimes produces a byte the
  92 + // decoder lenient-parses to the same value, leaving the
  93 + // signature still valid. Mutating the payload sidesteps the
  94 + // edge case entirely: any change to the payload changes the
  95 + // bytes the signature is computed over, which always fails
  96 + // verification.
  97 + val parts = issued.value.split(".")
  98 + require(parts.size == 3) { "expected a 3-part JWT, got ${parts.size}" }
  99 + val payload = parts[1]
  100 + // Flip the first character of the payload to a different
  101 + // base64url character that is guaranteed to be different from
  102 + // whatever was there. We pick 'A' if the first char isn't 'A',
  103 + // else 'B'. Both are valid base64url so the format stays
  104 + // intact and the failure mode is "signature does not verify",
  105 + // not "malformed token" — exactly what the test should assert.
  106 + val firstChar = payload[0]
  107 + val replacement = if (firstChar == 'A') 'B' else 'A'
  108 + val tamperedPayload = replacement + payload.substring(1)
  109 + val tampered = "${parts[0]}.$tamperedPayload.${parts[2]}"
89 110
90 assertFailure { verifier.verify(tampered) } 111 assertFailure { verifier.verify(tampered) }
91 .isInstanceOf(AuthenticationFailedException::class) 112 .isInstanceOf(AuthenticationFailedException::class)