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,23 @@
package core_service.configuration.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration
public class JwksClientConfig {
@Bean
public RestClient jwksRestClient() {
// The call runs on a request thread, so a slow Auth must not hold it for long.
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(2));
requestFactory.setReadTimeout(Duration.ofSeconds(3));
return RestClient.builder()
.requestFactory(requestFactory)
.build();
}
}
@@ -0,0 +1,140 @@
package core_service.configuration.security;
import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.LocatorAdapter;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Jwk;
import io.jsonwebtoken.security.JwkSet;
import io.jsonwebtoken.security.Jwks;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.security.Key;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
/**
* Finds the verify key for a token by its {@code kid} in Auth's JWKS. An unknown {@code kid} (Auth rotated its key)
* loads the JWKS again, at most once per {@link #MIN_REFRESH_INTERVAL}, so tokens with made-up ids cannot flood Auth.
* The static {@code JWT_PUBLIC_KEY} is used only until the first JWKS load works; after that a key that left the
* JWKS must not verify any more.
*/
@Slf4j
@Component
public class JwksKeyLocator extends LocatorAdapter<Key> {
static final Duration MIN_REFRESH_INTERVAL = Duration.ofSeconds(10);
private final RestClient jwksRestClient;
private final String jwksUrl;
private final PublicKey fallbackKey;
private final Clock clock;
private volatile Map<String, PublicKey> keysById = Map.of();
private volatile boolean jwksLoaded;
private final ReentrantLock refreshLock = new ReentrantLock();
private Instant lastRefresh = Instant.MIN;
@Autowired
public JwksKeyLocator(JwtConfig jwtConfig, @Qualifier("jwksRestClient") RestClient jwksRestClient) {
this(jwtConfig, jwksRestClient, Clock.systemUTC());
}
JwksKeyLocator(JwtConfig jwtConfig, RestClient jwksRestClient, Clock clock) {
this.jwksRestClient = jwksRestClient;
this.jwksUrl = blankToNull(jwtConfig.getJwksUrl());
String publicKey = blankToNull(jwtConfig.getPublicKey());
this.fallbackKey = publicKey == null ? null : decodePublicKey(publicKey);
this.clock = clock;
if (jwksUrl == null && fallbackKey == null) {
throw new IllegalStateException("Set JWT_JWKS_URL or JWT_PUBLIC_KEY, else no token can be verified");
}
if (jwksUrl != null) {
// Auth may start after Core. A failed first load is retried on the first token.
load();
}
}
@Override
protected Key locate(JwsHeader header) {
String kid = header.getKeyId();
if (kid != null) {
PublicKey key = keysById.get(kid);
if (key == null) {
// While the static key can still verify, a slow or down Auth must not make every request wait.
refreshIfAllowed(fallbackKey == null || jwksLoaded);
// Read again even when this thread did not load: another thread may have loaded while it waited.
key = keysById.get(kid);
}
if (key != null) {
return key;
}
}
if (fallbackKey != null && !jwksLoaded) {
return fallbackKey;
}
throw new UnsupportedJwtException("No verify key for kid " + kid);
}
private void refreshIfAllowed(boolean waitForRunningLoad) {
if (jwksUrl == null) {
return;
}
if (waitForRunningLoad) {
refreshLock.lock();
} else if (!refreshLock.tryLock()) {
return;
}
try {
Instant now = clock.instant();
if (lastRefresh.plus(MIN_REFRESH_INTERVAL).isAfter(now)) {
return;
}
lastRefresh = now;
load();
} finally {
refreshLock.unlock();
}
}
private void load() {
try {
String body = jwksRestClient.get().uri(jwksUrl).retrieve().body(String.class);
JwkSet jwkSet = Jwks.setParser().build().parse(body);
Map<String, PublicKey> keys = new HashMap<>();
for (Jwk<?> jwk : jwkSet.getKeys()) {
if (jwk.getId() != null && jwk.toKey() instanceof PublicKey publicKey) {
keys.put(jwk.getId(), publicKey);
}
}
keysById = Map.copyOf(keys);
jwksLoaded = true;
} catch (Exception e) {
log.warn("Loading JWKS from {} failed: {}", jwksUrl, e.getMessage());
}
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private static PublicKey decodePublicKey(String base64X509Der) {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
}
}
}
@@ -13,4 +13,6 @@ public class JwtConfig {
/** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */
private String publicKey;
private String jwksUrl;
}
@@ -1,64 +1,43 @@
package core_service.configuration.security;
import core_service.shared.security.ProfileTicketVerifier;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Optional;
/**
* Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key.
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
*/
@Service
public class JwtService implements ProfileTicketVerifier {
private static final String ED25519 = "Ed25519";
// Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private final JwtConfig jwtConfig;
private PublicKey publicKey;
private final JwtParser parser;
public JwtService(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
public JwtService(JwksKeyLocator keyLocator) {
this.parser = Jwts.parser().keyLocator(keyLocator).build();
}
@PostConstruct
void init() {
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
private Claims claims(String token) {
return parser.parseSignedClaims(token).getPayload();
}
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
return claims(token).getSubject();
}
public String extractUserId(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("userId", String.class);
return claims(token).get("userId", String.class);
}
public Long extractSteamId(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("steamId", Long.class);
return claims(token).get("steamId", Long.class);
}
/**
@@ -66,11 +45,7 @@ public class JwtService implements ProfileTicketVerifier {
*/
public boolean isTokenValid(String token) {
try {
var claims = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
Claims claims = claims(token);
return claims.get(TYPE_CLAIM) == null;
} catch (Exception e) {
return false;
@@ -80,11 +55,7 @@ public class JwtService implements ProfileTicketVerifier {
@Override
public Optional<Long> verifyProfileTicket(String ticket) {
try {
var claims = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(ticket)
.getPayload();
Claims claims = claims(ticket);
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
return Optional.empty();
}
@@ -93,13 +64,4 @@ public class JwtService implements ProfileTicketVerifier {
return Optional.empty();
}
}
private static PublicKey decodePublicKey(String base64X509Der) {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
}
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ management:
show-details: never
security:
jwt:
public-key: ${JWT_PUBLIC_KEY}
public-key: ${JWT_PUBLIC_KEY:}
jwks-url: ${JWT_JWKS_URL:}
app:
cors:
allowed-origins: "https://tbp.andreas-dahm.eu"
@@ -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) {