feat: add Ed25519 JWT signing and verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-30 10:25:27 +02:00
parent aff10704e0
commit 33c4a2991f
3 changed files with 246 additions and 3 deletions

View File

@@ -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<String, Object> 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<String, Object> payload = parsePayload(token);
return (String) payload.get("sub");
}
public UUID extractUserId(String token) {
Map<String, Object> payload = parsePayload(token);
return UUID.fromString((String) payload.get("uid"));
}
@SuppressWarnings("unchecked")
public Set<String> extractRoles(String token) {
Map<String, Object> payload = parsePayload(token);
List<String> roles = (List<String>) 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<String, Object> payload = parsePayload(token);
long exp = ((Number) payload.get("exp")).longValue();
return Instant.now().getEpochSecond() < exp;
} catch (Exception e) {
return false;
}
}
private Map<String, Object> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}