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
|
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();
|
||||||
|
|||||||
Reference in new issue
Block a user