Skip to content
Draft
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
55 changes: 55 additions & 0 deletions .github/scripts/run_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Run verify after removing checkout artifacts; expose only this invocation's reports."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess


def clean_target(root):
target = root / "target"
if target.is_symlink():
target.unlink()
elif target.exists():
shutil.rmtree(target)


def run_verify(root, command):
clean_target(root)
# A failed Maven invocation must retain its exit status even if it wrote reports.
try:
status = subprocess.run(command, cwd=root, check=False).returncode
except OSError as error:
print(error)
status = 127
reports = {
"surefire": any((root / "target/surefire-reports").glob("TEST-*.xml")),
"jacoco": (root / "target/site/jacoco/jacoco.xml").is_file(),
}
output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a", encoding="utf-8") as stream:
for name, present in reports.items():
stream.write(f"{name}={str(present).lower()}\n")
for name, present in reports.items():
if present:
directory = root / ("target/surefire-reports" if name == "surefire" else "target/site/jacoco")
(directory / "ci-origin.json").write_text(json.dumps({
"sha": os.environ.get("GITHUB_SHA"),
"run_id": os.environ.get("GITHUB_RUN_ID"),
"run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
"verify_exit_code": status,
}) + "\n", encoding="utf-8")
return status if status >= 0 else 128 - status


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--clean-only", action="store_true")
args = parser.parse_args()
root = Path.cwd()
if args.clean_only:
clean_target(root)
else:
raise SystemExit(run_verify(root, ["mvn", "-B", "--no-transfer-progress", "-s", ".mvn/ci-settings.xml", "clean", "verify"]))
62 changes: 62 additions & 0 deletions .github/scripts/test_run_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Synthetic report-provenance tests; these do not stand in for Maven tests."""
import importlib.util
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch

spec = importlib.util.spec_from_file_location("run_verify", Path(__file__).with_name("run_verify.py"))
verify = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verify)


class ReportProvenanceTest(unittest.TestCase):
def exercise(self, exit_code, generate):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for path in ["target/surefire-reports/TEST-old.xml", "target/site/jacoco/jacoco.xml"]:
old = root / path
old.parent.mkdir(parents=True, exist_ok=True)
old.write_text("old Mac / Jackson 2.17 report")
output = root / "outputs"
command = [sys.executable, "-c", (
"from pathlib import Path; "
"assert not Path('target').exists(); "
+ ("p=Path('target/surefire-reports/TEST-current.xml'); p.parent.mkdir(parents=True); p.write_text('<testsuite tests=\"1\"/>'); "
"p=Path('target/site/jacoco/jacoco.xml'); p.parent.mkdir(parents=True); p.write_text('<report/>'); " if generate else "")
+ f"raise SystemExit({exit_code})"
)]
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(output), "GITHUB_SHA": "current-sha"}):
self.assertEqual(exit_code, verify.run_verify(root, command))
expected = str(generate).lower()
self.assertEqual(f"surefire={expected}\njacoco={expected}\n", output.read_text())
self.assertFalse((root / 'target/surefire-reports/TEST-old.xml').exists())
for path in (root / 'target').rglob('*'):
if path.is_file():
self.assertNotIn('Jackson 2.17', path.read_text())
if generate:
self.assertIn('current-sha', (root / 'target/surefire-reports/ci-origin.json').read_text())

def test_success_uploads_only_current_reports(self):
self.exercise(0, True)

def test_failure_retains_failure_and_current_reports(self):
self.exercise(1, True)

def test_failure_before_tests_does_not_upload_old_reports(self):
self.exercise(2, False)

def test_skipped_project_build_has_no_reports_or_output(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'target/surefire-reports').mkdir(parents=True)
(root / 'target/surefire-reports/TEST-old.xml').write_text('old Mac / Jackson 2.17')
verify.clean_target(root)
self.assertFalse((root / 'target').exists())
self.assertFalse((root / 'outputs').exists())


if __name__ == '__main__':
unittest.main()
49 changes: 26 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ jobs:
- name: Checkout source
uses: actions/checkout@v7

- name: Remove checkout build artifacts on runner
run: python3 .github/scripts/run_verify.py --clean-only

- name: Test report provenance helper
run: python3 .github/scripts/test_run_verify.py

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
Expand All @@ -45,46 +51,43 @@ jobs:
tar -xzf /tmp/apache-maven-4-bin.tar.gz -C "$HOME/apache-maven-4" --strip-components=1
echo "$HOME/apache-maven-4/bin" >> "$GITHUB_PATH"

- name: Install Easy4J snapshot dependencies
env:
EASY4J_BRANCH: ${{ github.base_ref || github.ref_name }}
EASY4J_DEPENDENCIES: "jwt-issuer spring-security-extension"
- name: Build pinned public Easy4J snapshot sources
run: |
set -euo pipefail
dependency_root="${RUNNER_TEMP}/easy4j-dependencies"
mkdir -p "${dependency_root}"
for dependency in ${EASY4J_DEPENDENCIES}; do
git clone --depth 1 --branch "${EASY4J_BRANCH}" \
"https://github.com/easy-4-java/${dependency}.git" \
"${dependency_root}/${dependency}"
for attempt in 1 2 3; do
if mvn -B --no-transfer-progress \
-f "${dependency_root}/${dependency}/pom.xml" \
-Dmaven.test.skip=true -Djacoco.skip=true -Dgpg.skip=true install; then
break
fi
if [[ "${attempt}" -eq 3 ]]; then
exit 1
fi
sleep "$((attempt * 10))"
done
done
# Fixed public revisions: changing a sibling main must not silently
# change the snapshot classes tested by this repository.
while read -r dependency revision; do
git init "${dependency_root}/${dependency}"
git -C "${dependency_root}/${dependency}" remote add origin "https://github.com/easy-4-java/${dependency}.git"
git -C "${dependency_root}/${dependency}" fetch --depth 1 origin "${revision}"
git -C "${dependency_root}/${dependency}" checkout --detach FETCH_HEAD
test "$(git -C "${dependency_root}/${dependency}" rev-parse HEAD)" = "${revision}"
mvn -B --no-transfer-progress -s .mvn/ci-settings.xml -f "${dependency_root}/${dependency}/pom.xml" -Dgpg.skip=true clean install
done <<'SOURCES'
jwt-issuer fe29ffd8471eff70d2117b73e4cb2459e5ec4a2f
spring-security-extension 385e4ae3b16f369c94a9068afcb27fca47494767
SOURCES

- name: Build and verify with JaCoCo coverage gate
run: mvn -B --no-transfer-progress clean verify
id: project_verify
run: python3 .github/scripts/run_verify.py

- name: Upload JaCoCo coverage report
if: always()
if: ${{ always() && steps.project_verify.outputs.jacoco == 'true' }}
uses: actions/upload-artifact@v7
with:
name: jacoco-coverage
path: target/site/jacoco
if-no-files-found: error
retention-days: 14

- name: Upload surefire reports
if: always()
if: ${{ always() && steps.project_verify.outputs.surefire == 'true' }}
uses: actions/upload-artifact@v7
with:
name: surefire-reports
path: target/surefire-reports
if-no-files-found: error
retention-days: 14
11 changes: 11 additions & 0 deletions .mvn/ci-settings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<!-- CI builds snapshots from pinned public source; it needs no private repository credentials. -->
<mirrors>
<mirror>
<id>central-public</id>
<mirrorOf>*</mirrorOf>
<url>https://repo.maven.apache.org/maven2</url>
</mirror>
</mirrors>
</settings>
18 changes: 14 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<!-- Dependency versions -->
<commons-lang3.version>3.20.0</commons-lang3.version>
<easy4j-spring-security-extension.version>2.0.x.20260630-SNAPSHOT</easy4j-spring-security-extension.version>
<jackson.version>2.17.2</jackson.version>
<jackson.version>2.18.12</jackson.version>
<javax-servlet-api.version>4.0.1</javax-servlet-api.version>
<junit-jupiter.version>6.1.0</junit-jupiter.version>
<mockito.version>5.18.0</mockito.version>
Expand Down Expand Up @@ -98,11 +98,13 @@
<version>${spring-framework.version}</version>
<scope>provided</scope>
</dependency>
<!-- For Jackson -->
<!-- Align all Jackson 2.x components, including transitive JWT codecs. -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- For Commons Lang3 -->
<dependency>
Expand Down Expand Up @@ -222,6 +224,14 @@
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin>

<!-- https://mvnrepository.com/artifact/org.jacoco/jacoco-maven-plugin -->
<plugin>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.springframework.security.boot.jwt.authentication;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Base64;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import static org.junit.jupiter.api.Assertions.*;

class JacksonJwtCompatibilityTest {
private final ObjectMapper mapper = new ObjectMapper();

@Test
void signedJwtJsonPreservesUnicodeAndClaimTypes() throws Exception {
String header = encode(mapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT")));
String payload = encode(mapper.writeValueAsBytes(Map.of("sub", "用户", "roles", new String[]{"reader"}, "exp", 2000000000L)));
String input = header + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(new byte[32], "HmacSHA256"));
String jwt = input + "." + encode(mac.doFinal(input.getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
String[] parts = jwt.split("\\.");
assertEquals(3, parts.length);
assertArrayEquals(mac.doFinal(input.getBytes(java.nio.charset.StandardCharsets.US_ASCII)), Base64.getUrlDecoder().decode(parts[2]));
JsonNode claims = mapper.readTree(Base64.getUrlDecoder().decode(parts[1]));
assertEquals("用户", claims.get("sub").asText());
assertEquals("reader", claims.get("roles").get(0).asText());
assertEquals(2000000000L, claims.get("exp").longValue());
JwtAuthenticationToken token = new JwtAuthenticationToken(claims.get("sub").asText(), jwt);
assertFalse(token.isAuthenticated());
assertThrows(IllegalArgumentException.class, () -> token.setAuthenticated(true));
token.eraseCredentials();
assertNull(token.getCredentials());
}

@Test
void rejectsMalformedClaimsAndDoesNotEnablePolymorphicTyping() throws Exception {
assertThrows(JsonProcessingException.class, () -> mapper.readTree("{\"sub\": [}"));
Object value = mapper.readValue("{\"@class\":\"java.lang.Runtime\",\"sub\":\"alice\"}", Object.class);
assertInstanceOf(Map.class, value);
assertEquals("alice", ((Map<?, ?>) value).get("sub"));
}

@Test
void rejectsOverlyNestedUntrustedClaims() {
String json = "[".repeat(1001) + "0" + "]".repeat(1001);
assertThrows(com.fasterxml.jackson.core.exc.StreamConstraintsException.class,
() -> mapper.readTree(json));
}

private static String encode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
Loading