From 33c4a2991fc9800f287e547cecf382dde16a8fc6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:25:27 +0200 Subject: [PATCH] feat: add Ed25519 JWT signing and verification Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cameleer/saas/auth/JwtService.java | 107 +++++++++++++++++- .../cameleer/saas/config/JwtConfig.java | 38 +++++++ .../cameleer/saas/auth/JwtServiceTest.java | 104 +++++++++++++++++ 3 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java b/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java index 4254ad4..a74bdf6 100644 --- a/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java +++ b/src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java @@ -1,19 +1,120 @@ package net.siegeln.cameleer.saas.auth; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.config.JwtConfig; import org.springframework.stereotype.Service; +import java.security.Signature; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + @Service public class JwtService { + private final JwtConfig jwtConfig; + private final ObjectMapper objectMapper; + + public JwtService(JwtConfig jwtConfig) { + this.jwtConfig = jwtConfig; + this.objectMapper = new ObjectMapper(); + } + public String generateToken(UserEntity user) { - return "stub-token"; + try { + String header = base64UrlEncode(objectMapper.writeValueAsBytes( + Map.of("alg", "EdDSA", "typ", "JWT") + )); + + Instant now = Instant.now(); + Map payload = new LinkedHashMap<>(); + payload.put("sub", user.getEmail()); + payload.put("uid", user.getId().toString()); + payload.put("name", user.getName()); + payload.put("roles", user.getRoles().stream() + .map(RoleEntity::getName) + .toList()); + payload.put("iat", now.getEpochSecond()); + payload.put("exp", now.getEpochSecond() + jwtConfig.getExpirationSeconds()); + + String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload)); + + String signingInput = header + "." + payloadEncoded; + Signature sig = Signature.getInstance("Ed25519"); + sig.initSign(jwtConfig.getPrivateKey()); + sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String signature = base64UrlEncode(sig.sign()); + + return signingInput + "." + signature; + } catch (Exception e) { + throw new RuntimeException("Failed to generate JWT", e); + } } public String extractEmail(String token) { - return null; + Map payload = parsePayload(token); + return (String) payload.get("sub"); + } + + public UUID extractUserId(String token) { + Map payload = parsePayload(token); + return UUID.fromString((String) payload.get("uid")); + } + + @SuppressWarnings("unchecked") + public Set extractRoles(String token) { + Map payload = parsePayload(token); + List roles = (List) payload.get("roles"); + return roles.stream().collect(Collectors.toSet()); } public boolean isTokenValid(String token) { - return false; + try { + String[] parts = token.split("\\."); + if (parts.length != 3) { + return false; + } + + String signingInput = parts[0] + "." + parts[1]; + byte[] signatureBytes = base64UrlDecode(parts[2]); + + Signature sig = Signature.getInstance("Ed25519"); + sig.initVerify(jwtConfig.getPublicKey()); + sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + if (!sig.verify(signatureBytes)) { + return false; + } + + Map payload = parsePayload(token); + long exp = ((Number) payload.get("exp")).longValue(); + return Instant.now().getEpochSecond() < exp; + } catch (Exception e) { + return false; + } + } + + private Map parsePayload(String token) { + try { + String[] parts = token.split("\\."); + byte[] payloadBytes = base64UrlDecode(parts[1]); + return objectMapper.readValue(payloadBytes, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException("Failed to parse JWT payload", e); + } + } + + private String base64UrlEncode(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + private byte[] base64UrlDecode(String data) { + return Base64.getUrlDecoder().decode(data); } } diff --git a/src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java new file mode 100644 index 0000000..1d14cc3 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java @@ -0,0 +1,38 @@ +package net.siegeln.cameleer.saas.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; + +@Component +public class JwtConfig { + + @Value("${cameleer.jwt.expiration:86400}") + private long expirationSeconds = 86400; + + private KeyPair keyPair; + + @PostConstruct + public void init() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519"); + this.keyPair = keyGen.generateKeyPair(); + } + + public PrivateKey getPrivateKey() { + return keyPair.getPrivate(); + } + + public PublicKey getPublicKey() { + return keyPair.getPublic(); + } + + public long getExpirationSeconds() { + return expirationSeconds; + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java new file mode 100644 index 0000000..151c9a8 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java @@ -0,0 +1,104 @@ +package net.siegeln.cameleer.saas.auth; + +import net.siegeln.cameleer.saas.config.JwtConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +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.assertTrue; + +class JwtServiceTest { + + private JwtService jwtService; + + @BeforeEach + void setUp() throws Exception { + JwtConfig config = new JwtConfig(); + config.init(); + jwtService = new JwtService(config); + } + + @Test + void generateToken_producesValidJwt() { + UserEntity user = createUser("test@example.com", "OWNER"); + + String token = jwtService.generateToken(user); + + assertNotNull(token); + String[] parts = token.split("\\."); + assertEquals(3, parts.length, "JWT should have 3 parts separated by dots"); + } + + @Test + void extractEmail_returnsCorrectEmail() { + UserEntity user = createUser("test@example.com", "OWNER"); + + String token = jwtService.generateToken(user); + String email = jwtService.extractEmail(token); + + assertEquals("test@example.com", email); + } + + @Test + void isTokenValid_returnsTrueForValidToken() { + UserEntity user = createUser("test@example.com", "OWNER"); + + String token = jwtService.generateToken(user); + + assertTrue(jwtService.isTokenValid(token)); + } + + @Test + void isTokenValid_returnsFalseForTamperedToken() { + UserEntity user = createUser("test@example.com", "OWNER"); + + String token = jwtService.generateToken(user); + // Tamper with the last 5 characters of the signature + String tampered = token.substring(0, token.length() - 5) + "XXXXX"; + + assertFalse(jwtService.isTokenValid(tampered)); + } + + @Test + void extractRoles_returnsUserRoles() { + UserEntity user = createUser("test@example.com", "OWNER"); + + String token = jwtService.generateToken(user); + var roles = jwtService.extractRoles(token); + + assertNotNull(roles); + assertTrue(roles.contains("OWNER")); + assertEquals(1, roles.size()); + } + + @Test + void extractUserId_returnsCorrectId() { + UserEntity user = createUser("test@example.com", "OWNER"); + + String token = jwtService.generateToken(user); + UUID extractedId = jwtService.extractUserId(token); + + assertEquals(user.getId(), extractedId); + } + + private UserEntity createUser(String email, String roleName) { + var role = new RoleEntity(); + role.setName(roleName); + var user = new UserEntity(); + user.setEmail(email); + user.setName("Test User"); + user.getRoles().add(role); + try { + var idField = UserEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, UUID.randomUUID()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return user; + } +}