From 600b0b143ff1626785b3ba39976c42f02aa6b240 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 11:01:28 +0200 Subject: [PATCH] feat(login): publish verify key as JWKS 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. --- readme.md | 4 +- .../configuration/security/JwtService.java | 20 +++++++++- .../security/SecurityConfig.java | 1 + .../modules/login/api/JwksController.java | 26 +++++++++++++ .../shared/security/PublicKeyPublisher.java | 15 ++++++++ .../security/JwtServiceTest.java | 37 +++++++++++++++++++ .../security/PublicEndpointsTest.java | 10 +++++ 7 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/main/java/auth_service/modules/login/api/JwksController.java create mode 100644 src/main/java/auth_service/shared/security/PublicKeyPublisher.java diff --git a/readme.md b/readme.md index cfbc39d..ff5dd17 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 6f98247..b762c1b 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -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> 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) diff --git a/src/main/java/auth_service/configuration/security/SecurityConfig.java b/src/main/java/auth_service/configuration/security/SecurityConfig.java index 56f4b62..2a36d84 100644 --- a/src/main/java/auth_service/configuration/security/SecurityConfig.java +++ b/src/main/java/auth_service/configuration/security/SecurityConfig.java @@ -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 }; diff --git a/src/main/java/auth_service/modules/login/api/JwksController.java b/src/main/java/auth_service/modules/login/api/JwksController.java new file mode 100644 index 0000000..da502b4 --- /dev/null +++ b/src/main/java/auth_service/modules/login/api/JwksController.java @@ -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>>> jwks() { + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic()) + .body(Map.of("keys", publicKeyPublisher.publicJwks())); + } +} diff --git a/src/main/java/auth_service/shared/security/PublicKeyPublisher.java b/src/main/java/auth_service/shared/security/PublicKeyPublisher.java new file mode 100644 index 0000000..422a62b --- /dev/null +++ b/src/main/java/auth_service/shared/security/PublicKeyPublisher.java @@ -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> publicJwks(); +} diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index a2cdd37..604666d 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -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> jwks = jwtService.publicJwks(); + + assertEquals(1, jwks.size()); + Map 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")); + } } diff --git a/src/test/java/auth_service/configuration/security/PublicEndpointsTest.java b/src/test/java/auth_service/configuration/security/PublicEndpointsTest.java index 1f62f69..b118b55 100644 --- a/src/test/java/auth_service/configuration/security/PublicEndpointsTest.java +++ b/src/test/java/auth_service/configuration/security/PublicEndpointsTest.java @@ -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();