feat(login): publish verify key as JWKS
Build, Test & Push Auth Service / quality-check (push) Successful in 26s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s

Auth serves its Ed25519 public key at GET /.well-known/jwks.json, so
Core can find the key by the token kid. Startup fails on a blank
JWT_KID, because compose turns an unset value into "" and Core finds
keys only by kid.
This commit is contained in:
lobo committed 2026-09-25 11:01:28 +02:00
1 parent 3a48ce0ff1
commit 600b0b143f
7 files changed
+110 -3

No files matched your search

+2 -2
View File
@@ -18,7 +18,8 @@ To Be Played is split into three repositories:
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
wrong password, and every failed login gets the same error.
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
tokens but never create them. Tokens carry a `kid` header for key rotation.
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
public key as a JWK, so Core finds the right key after a rotation.
- User settings: display name (unique, case-insensitive), Steam ID, language, dark mode.
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
@@ -102,7 +103,6 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap
- JWKS endpoint (`/.well-known/jwks.json`), so Core fetches the public key and keys rotate without a redeploy.
- Short-lived access token plus rotating refresh token in an httpOnly cookie, with CSRF protection. Today the token
lives 24 h in `localStorage` and cannot be revoked.
- Account deletion and data export.
@@ -1,8 +1,10 @@
package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.PublicKeyPublisher;
import auth_service.shared.security.TokenGenerator;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Jwks;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
@@ -13,13 +15,15 @@ import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* Service responsible for generating and validating JSON Web Tokens (JWT).
* Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
*/
@Service
public class JwtService implements TokenGenerator, ProfileTicketIssuer {
public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher {
// Token validity: 24 hours in milliseconds
private static final long JWT_EXPIRATION_MS = 86400000;
@@ -39,6 +43,10 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
@PostConstruct
void init() {
// Compose passes an unset JWT_KID as "", which hides the yml default. Core finds keys only by kid.
if (jwtConfig.getKid() == null || jwtConfig.getKid().isBlank()) {
throw new IllegalStateException("JWT_KID must not be blank");
}
this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
}
@@ -74,6 +82,16 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
.compact();
}
@Override
public List<Map<String, ?>> publicJwks() {
return List.of(Jwks.builder()
.key(publicKey)
.id(jwtConfig.getKid())
.publicKeyUse("sig")
.algorithm("EdDSA")
.build());
}
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith(publicKey)
@@ -38,6 +38,7 @@ public class SecurityConfig {
"/api/stats/public", // Public landing-page counters (cached, no PII)
"/api/public/**", // Opt-in public profiles, see PublicProfileController
"/actuator/health", // Docker health check; details are off
"/.well-known/jwks.json", // Public verify keys for Core
"/error" // Default Spring Boot error page routing
};
@@ -0,0 +1,26 @@
package auth_service.modules.login.api;
import auth_service.shared.security.PublicKeyPublisher;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class JwksController {
private final PublicKeyPublisher publicKeyPublisher;
@GetMapping(value = "/.well-known/jwks.json", produces = "application/json")
public ResponseEntity<Map<String, List<Map<String, ?>>>> jwks() {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(Map.of("keys", publicKeyPublisher.publicJwks()));
}
}
@@ -0,0 +1,15 @@
package auth_service.shared.security;
import java.util.List;
import java.util.Map;
/**
* Port for the public keys that verify this service's tokens, published as JWKS for Core.
*/
public interface PublicKeyPublisher {
/**
* @return one JWK (RFC 8037, OKP / Ed25519) per key that can verify a current token
*/
List<Map<String, ?>> publicJwks();
}
@@ -12,13 +12,18 @@ import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* Tests for JwtService (EdDSA / Ed25519).
@@ -124,4 +129,36 @@ class JwtServiceTest extends AbstractUnitTest {
void isTokenValid_ProfileTicketIsNoLoginToken() {
assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L)));
}
@Test
void init_BlankKid_FailsAtStartup() {
when(jwtConfig.getKid()).thenReturn(" ");
JwtService service = new JwtService(jwtConfig);
assertThrows(IllegalStateException.class, service::init);
}
@Test
void publicJwks_PublishesTheVerifyKeyAsOkpJwk() throws Exception {
List<Map<String, ?>> jwks = jwtService.publicJwks();
assertEquals(1, jwks.size());
Map<String, ?> jwk = jwks.getFirst();
assertEquals("OKP", jwk.get("kty"));
assertEquals("Ed25519", jwk.get("crv"));
assertEquals("EdDSA", jwk.get("alg"));
assertEquals("sig", jwk.get("use"));
assertEquals(KID, jwk.get("kid"));
byte[] raw = Base64.getUrlDecoder().decode((String) jwk.get("x"));
assertEquals(32, raw.length);
// A verifier that builds the key from "x" alone accepts a token of this service.
byte[] prefix = HexFormat.of().parseHex("302a300506032b6570032100");
byte[] der = new byte[prefix.length + raw.length];
System.arraycopy(prefix, 0, der, 0, prefix.length);
System.arraycopy(raw, 0, der, prefix.length, raw.length);
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
String token = jwtService.generateToken("7", "a@b.de", null);
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
}
}
@@ -11,6 +11,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
@@ -27,6 +28,15 @@ class PublicEndpointsTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.components").doesNotExist());
}
@Test
void jwksIsPublicAndCacheable() throws Exception {
mockMvc.perform(get("/.well-known/jwks.json"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("max-age=300")))
.andExpect(jsonPath("$.keys[0].kty").value("OKP"))
.andExpect(jsonPath("$.keys[0].kid").exists());
}
@Test
void prometheusIsNotPublic() throws Exception {
int status = mockMvc.perform(get("/actuator/prometheus")).andReturn().getResponse().getStatus();