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 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. 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 - 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. - 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 - 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. 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 ## 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 - 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. lives 24 h in `localStorage` and cannot be revoked.
- Account deletion and data export. - Account deletion and data export.
@@ -1,8 +1,10 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.PublicKeyPublisher;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Jwks;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -13,13 +15,15 @@ import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Map;
/** /**
* Service responsible for generating and validating JSON Web Tokens (JWT). * 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. * Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
*/ */
@Service @Service
public class JwtService implements TokenGenerator, ProfileTicketIssuer { public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher {
// Token validity: 24 hours in milliseconds // Token validity: 24 hours in milliseconds
private static final long JWT_EXPIRATION_MS = 86400000; private static final long JWT_EXPIRATION_MS = 86400000;
@@ -39,6 +43,10 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
@PostConstruct @PostConstruct
void init() { 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.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
this.publicKey = decodePublicKey(jwtConfig.getPublicKey()); this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
} }
@@ -74,6 +82,16 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
.compact(); .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) { public String extractEmail(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(publicKey) .verifyWith(publicKey)
@@ -38,6 +38,7 @@ public class SecurityConfig {
"/api/stats/public", // Public landing-page counters (cached, no PII) "/api/stats/public", // Public landing-page counters (cached, no PII)
"/api/public/**", // Opt-in public profiles, see PublicProfileController "/api/public/**", // Opt-in public profiles, see PublicProfileController
"/actuator/health", // Docker health check; details are off "/actuator/health", // Docker health check; details are off
"/.well-known/jwks.json", // Public verify keys for Core
"/error" // Default Spring Boot error page routing "/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.PublicKey;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/** /**
* Tests for JwtService (EdDSA / Ed25519). * Tests for JwtService (EdDSA / Ed25519).
@@ -124,4 +129,36 @@ class JwtServiceTest extends AbstractUnitTest {
void isTokenValid_ProfileTicketIsNoLoginToken() { void isTokenValid_ProfileTicketIsNoLoginToken() {
assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L))); 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.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -27,6 +28,15 @@ class PublicEndpointsTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.components").doesNotExist()); .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 @Test
void prometheusIsNotPublic() throws Exception { void prometheusIsNotPublic() throws Exception {
int status = mockMvc.perform(get("/actuator/prometheus")).andReturn().getResponse().getStatus(); int status = mockMvc.perform(get("/actuator/prometheus")).andReturn().getResponse().getStatus();