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
@@ -35,10 +35,12 @@ To Be Played is split into three repositories:
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and
|
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
||||||
reads `userId` and `steamId` from the claims. A token with a `typ` claim (for example a profile ticket) is rejected
|
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
||||||
on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the
|
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and
|
||||||
`X-Profile-Ticket` header.
|
`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
|
## API
|
||||||
|
|
||||||
@@ -98,7 +100,8 @@ Configuration comes from environment variables:
|
|||||||
|
|
||||||
| Variable | Purpose |
|
| 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 |
|
| `STEAM_API_KEY` | Steam Web API key |
|
||||||
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
||||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
| `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).
|
- Mood filter for the picker (needs genres or themes from IGDB).
|
||||||
- Sale alerts for wishlist games.
|
- Sale alerts for wishlist games.
|
||||||
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
|
- 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */
|
||||||
private String publicKey;
|
private String publicKey;
|
||||||
|
|
||||||
|
private String jwksUrl;
|
||||||
}
|
}
|
||||||
@@ -1,64 +1,43 @@
|
|||||||
package core_service.configuration.security;
|
package core_service.configuration.security;
|
||||||
|
|
||||||
import core_service.shared.security.ProfileTicketVerifier;
|
import core_service.shared.security.ProfileTicketVerifier;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.JwtParser;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.springframework.stereotype.Service;
|
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;
|
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
|
@Service
|
||||||
public class JwtService implements ProfileTicketVerifier {
|
public class JwtService implements ProfileTicketVerifier {
|
||||||
|
|
||||||
private static final String ED25519 = "Ed25519";
|
|
||||||
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
||||||
private static final String TYPE_CLAIM = "typ";
|
private static final String TYPE_CLAIM = "typ";
|
||||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
||||||
|
|
||||||
private final JwtConfig jwtConfig;
|
private final JwtParser parser;
|
||||||
private PublicKey publicKey;
|
|
||||||
|
|
||||||
public JwtService(JwtConfig jwtConfig) {
|
public JwtService(JwksKeyLocator keyLocator) {
|
||||||
this.jwtConfig = jwtConfig;
|
this.parser = Jwts.parser().keyLocator(keyLocator).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
private Claims claims(String token) {
|
||||||
void init() {
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String extractEmail(String token) {
|
public String extractEmail(String token) {
|
||||||
return Jwts.parser()
|
return claims(token).getSubject();
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.getSubject();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String extractUserId(String token) {
|
public String extractUserId(String token) {
|
||||||
return Jwts.parser()
|
return claims(token).get("userId", String.class);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.get("userId", String.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long extractSteamId(String token) {
|
public Long extractSteamId(String token) {
|
||||||
return Jwts.parser()
|
return claims(token).get("steamId", Long.class);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.get("steamId", Long.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,11 +45,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
*/
|
*/
|
||||||
public boolean isTokenValid(String token) {
|
public boolean isTokenValid(String token) {
|
||||||
try {
|
try {
|
||||||
var claims = Jwts.parser()
|
Claims claims = claims(token);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload();
|
|
||||||
return claims.get(TYPE_CLAIM) == null;
|
return claims.get(TYPE_CLAIM) == null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -80,11 +55,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<Long> verifyProfileTicket(String ticket) {
|
public Optional<Long> verifyProfileTicket(String ticket) {
|
||||||
try {
|
try {
|
||||||
var claims = Jwts.parser()
|
Claims claims = claims(ticket);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(ticket)
|
|
||||||
.getPayload();
|
|
||||||
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -93,13 +64,4 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
return Optional.empty();
|
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
|
show-details: never
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
public-key: ${JWT_PUBLIC_KEY}
|
public-key: ${JWT_PUBLIC_KEY:}
|
||||||
|
jwks-url: ${JWT_JWKS_URL:}
|
||||||
app:
|
app:
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: "https://tbp.andreas-dahm.eu"
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
@@ -37,8 +38,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
|
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
|
||||||
|
|
||||||
jwtService = new JwtService(jwtConfig);
|
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
|
||||||
jwtService.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signToken(String userId, String email, Long steamId) {
|
private String signToken(String userId, String email, Long steamId) {
|
||||||
|
|||||||
Reference in new issue
Block a user