diff --git a/readme.md b/readme.md index 5ddec34..b093f26 100644 --- a/readme.md +++ b/readme.md @@ -35,10 +35,12 @@ To Be Played is split into three repositories: ## Authentication -Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and -reads `userId` and `steamId` from the claims. A token with a `typ` claim (for example a profile ticket) is rejected -on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the -`X-Profile-Ticket` header. +Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's +JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay +until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and +`steamId` from the claims. +A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is +accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header. ## API @@ -98,7 +100,8 @@ Configuration comes from environment variables: | Variable | Purpose | |---|---| -| `JWT_PUBLIC_KEY` | Auth's Ed25519 public key, Base64 of the X.509 DER bytes | +| `JWT_JWKS_URL` | Auth's `/.well-known/jwks.json` | +| `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both | | `STEAM_API_KEY` | Steam Web API key | | `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) | | `DB_PASSWORD` | Local PostgreSQL password | @@ -116,7 +119,6 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne - Mood filter for the picker (needs genres or themes from IGDB). - Sale alerts for wishlist games. - Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit. -- Fetch the public key from a JWKS endpoint in Auth instead of an environment variable. ## License diff --git a/src/main/java/core_service/configuration/security/JwksClientConfig.java b/src/main/java/core_service/configuration/security/JwksClientConfig.java new file mode 100644 index 0000000..26e09bb --- /dev/null +++ b/src/main/java/core_service/configuration/security/JwksClientConfig.java @@ -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(); + } +} diff --git a/src/main/java/core_service/configuration/security/JwksKeyLocator.java b/src/main/java/core_service/configuration/security/JwksKeyLocator.java new file mode 100644 index 0000000..cb62542 --- /dev/null +++ b/src/main/java/core_service/configuration/security/JwksKeyLocator.java @@ -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 { + + 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 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 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); + } + } +} diff --git a/src/main/java/core_service/configuration/security/JwtConfig.java b/src/main/java/core_service/configuration/security/JwtConfig.java index 3694d76..61df85b 100644 --- a/src/main/java/core_service/configuration/security/JwtConfig.java +++ b/src/main/java/core_service/configuration/security/JwtConfig.java @@ -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; } diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index 44032f3..caaa428 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -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 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); - } - } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4082101..741648d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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" diff --git a/src/test/java/core_service/configuration/security/JwksKeyLocatorTest.java b/src/test/java/core_service/configuration/security/JwksKeyLocatorTest.java new file mode 100644 index 0000000..cb7b012 --- /dev/null +++ b/src/test/java/core_service/configuration/security/JwksKeyLocatorTest.java @@ -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 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"); + } +} diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index d27ccfc..692f8e2 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -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) {