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

@@ -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();