Public Access
feat(security): load verify keys from auth JWKS
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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user