Public Access
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.
This commit is contained in:
1 parent
3a48ce0ff1
commit
600b0b143f
7 files changed
+110
-3
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user