feat(security): load verify keys from auth JWKS
Build, Test & Push Core Service / quality-check (push) Successful in 40s
Build, Test & Push Core Service / docker (push) Successful in 1m24s

Core finds the key by the token kid and loads the JWKS again for an
unknown kid, at most every 10 s. JWT_PUBLIC_KEY is used only until the
first JWKS load works, so a key removed from the JWKS cannot verify
later. While that key still works, requests do not wait for a slow
Auth.
This commit is contained in:
lobo committed 2026-09-25 11:01:28 +02:00
1 parent 9fd552a785
commit 762b7898d3
8 files changed
+452 -60

No files matched your search

@@ -0,0 +1,262 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.mockito.Mockito.lenient;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.client.ExpectedCount.once;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class JwksKeyLocatorTest extends AbstractUnitTest {
private static final String JWKS_URL = "http://auth/.well-known/jwks.json";
@Mock
private JwtConfig jwtConfig;
@Mock
private Clock clock;
private Instant now = Instant.parse("2026-09-25T10:00:00Z");
private RestClient.Builder restClientBuilder;
private MockRestServiceServer authServer;
private KeyPair first;
private KeyPair second;
@BeforeEach
void setUp() throws Exception {
lenient().when(clock.instant()).thenAnswer(invocation -> now);
restClientBuilder = RestClient.builder();
authServer = MockRestServiceServer.bindTo(restClientBuilder).build();
first = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
second = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
}
private static String jwk(String kid, PublicKey key) {
byte[] der = key.getEncoded();
// The X.509 DER of an Ed25519 key is a 12-byte header, then the raw 32-byte key.
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(Arrays.copyOfRange(der, 12, der.length));
return """
{"kty":"OKP","crv":"Ed25519","x":"%s","kid":"%s","use":"sig","alg":"EdDSA"}""".formatted(x, kid);
}
private void authAnswers(String... jwks) {
String keys = Stream.of(jwks).collect(Collectors.joining(","));
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withSuccess("{\"keys\":[" + keys + "]}", APPLICATION_JSON));
}
private JwtParser parser(String jwksUrl, KeyPair fallback) {
lenient().when(jwtConfig.getJwksUrl()).thenReturn(jwksUrl);
lenient().when(jwtConfig.getPublicKey()).thenReturn(
fallback == null ? null : Base64.getEncoder().encodeToString(fallback.getPublic().getEncoded()));
JwksKeyLocator locator = new JwksKeyLocator(jwtConfig, restClientBuilder.build(), clock);
return Jwts.parser().keyLocator(locator).build();
}
private static String token(KeyPair keys, String kid) {
return Jwts.builder()
.header().keyId(kid).and()
.subject("a@b.de")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(keys.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private static String subject(JwtParser parser, String token) {
return parser.parseSignedClaims(token).getPayload().getSubject();
}
@Test
void knownKid_VerifiesWithTheJwksKey() {
authAnswers(jwk("k1", first.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
authServer.verify();
}
@Test
void unknownKid_LoadsTheJwksAgain() {
authAnswers(jwk("k1", first.getPublic()));
authAnswers(jwk("k1", first.getPublic()), jwk("k2", second.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertEquals("a@b.de", subject(parser, token(second, "k2")));
assertEquals("a@b.de", subject(parser, token(second, "k2")));
authServer.verify();
}
@Test
void unknownKid_LoadsAtMostOncePerInterval() {
authAnswers(jwk("k1", first.getPublic()));
authAnswers(jwk("k1", first.getPublic()));
authAnswers(jwk("k2", second.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertThrows(JwtException.class, () -> subject(parser, token(second, "k2")));
assertThrows(JwtException.class, () -> subject(parser, token(second, "made-up")));
now = now.plus(JwksKeyLocator.MIN_REFRESH_INTERVAL).minus(Duration.ofSeconds(1));
assertThrows(JwtException.class, () -> subject(parser, token(second, "k2")));
now = now.plus(Duration.ofSeconds(1));
assertEquals("a@b.de", subject(parser, token(second, "k2")));
authServer.verify();
}
@Test
void authDownAtStartup_TheFirstTokenLoadsTheJwks() {
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
authAnswers(jwk("k1", first.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
authServer.verify();
}
@Test
void authDown_VerifiesWithThePublicKey() {
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
JwtParser parser = parser(JWKS_URL, first);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
authServer.verify();
}
@Test
void noJwksUrl_VerifiesWithThePublicKeyWithAndWithoutKid() {
JwtParser parser = parser(null, first);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
assertEquals("a@b.de", subject(parser, token(first, null)));
assertThrows(JwtException.class, () -> subject(parser, token(second, "k1")));
}
@Test
void neitherJwksUrlNorPublicKey_FailsAtStartup() {
assertThrows(IllegalStateException.class, () -> parser(" ", null));
}
@Test
void unknownKid_ThreadWaitingOnTheLoadSeesTheFreshKeys() throws Exception {
authAnswers(jwk("k1", first.getPublic()));
CountDownLatch authMayAnswer = new CountDownLatch(1);
String rotated = "{\"keys\":[" + jwk("k1", first.getPublic()) + "," + jwk("k2", second.getPublic()) + "]}";
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(request -> {
try {
if (!authMayAnswer.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("test did not release the JWKS answer");
}
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return withSuccess(rotated, APPLICATION_JSON).createResponse(request);
});
JwtParser parser = parser(JWKS_URL, null);
String tokenK2 = token(second, "k2");
Map<String, Object> outcome = new ConcurrentHashMap<>();
Runnable parse = () -> {
try {
outcome.put(Thread.currentThread().getName(), subject(parser, tokenK2));
} catch (Exception e) {
outcome.put(Thread.currentThread().getName(), e);
}
};
Thread firstCaller = new Thread(parse, "first");
Thread secondCaller = new Thread(parse, "second");
firstCaller.start();
awaitState(firstCaller, Thread.State.TIMED_WAITING); // inside load(), waiting for Auth
secondCaller.start();
awaitState(secondCaller, Thread.State.WAITING); // waiting on the refresh lock
authMayAnswer.countDown();
firstCaller.join(5_000);
secondCaller.join(5_000);
assertEquals("a@b.de", outcome.get("first"));
assertEquals("a@b.de", outcome.get("second"), "second caller should see the keys the first caller loaded");
}
@Test
void authSlowBeforeTheFirstLoad_OtherRequestsVerifyWithThePublicKeyWithoutWaiting() throws Exception {
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
CountDownLatch authMayAnswer = new CountDownLatch(1);
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(request -> {
try {
authMayAnswer.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return withServerError().createResponse(request);
});
JwtParser parser = parser(JWKS_URL, first);
String token = token(first, "k1");
Thread loader = new Thread(() -> subject(parser, token), "loader");
loader.start();
awaitState(loader, Thread.State.TIMED_WAITING); // inside load(), waiting for Auth
try {
assertEquals("a@b.de", assertTimeout(Duration.ofSeconds(1), () -> subject(parser, token)));
} finally {
authMayAnswer.countDown();
loader.join(5_000);
}
authServer.verify();
}
private static void awaitState(Thread thread, Thread.State state) throws InterruptedException {
long deadline = System.currentTimeMillis() + 5_000;
while (thread.getState() != state) {
if (System.currentTimeMillis() > deadline) {
throw new AssertionError(thread.getName() + " never reached " + state + ", is " + thread.getState());
}
Thread.sleep(5);
}
}
@Test
void afterTheFirstJwksLoad_ThePublicKeyNoLongerVerifies() {
authAnswers(jwk("k2", second.getPublic()));
authAnswers(jwk("k2", second.getPublic()));
JwtParser parser = parser(JWKS_URL, first);
assertEquals("a@b.de", subject(parser, token(second, "k2")));
assertThrows(JwtException.class, () -> subject(parser, token(first, "tbp-auth-1")),
"kid tbp-auth-1 is not in the JWKS any more, the old key must not verify");
}
}
@@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.web.client.RestClient;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@@ -37,8 +38,7 @@ class JwtServiceTest extends AbstractUnitTest {
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
jwtService = new JwtService(jwtConfig);
jwtService.init();
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
}
private String signToken(String userId, String email, Long steamId) {