feat(license): require licenseId + tenantId in validator
Spec §2.1 — both fields are required and the validator rejects a token whose tenantId does not match the server's configured tenant (CAMELEER_SERVER_TENANT_ID). Self-hosted customers cannot strip tenantId because the field is in the signed payload. LicenseBeanConfig and LicenseAdminController updated to pass the expected tenant to the validator constructor. The transient placeholder/TODO from Task 2 is removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.security.*;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class LicenseValidator {
|
||||
|
||||
@@ -18,8 +22,13 @@ public class LicenseValidator {
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final PublicKey publicKey;
|
||||
private final String expectedTenantId;
|
||||
|
||||
public LicenseValidator(String publicKeyBase64) {
|
||||
public LicenseValidator(String publicKeyBase64, String expectedTenantId) {
|
||||
Objects.requireNonNull(expectedTenantId, "expectedTenantId is required");
|
||||
if (expectedTenantId.isBlank()) {
|
||||
throw new IllegalArgumentException("expectedTenantId must not be blank");
|
||||
}
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||
KeyFactory kf = KeyFactory.getInstance("Ed25519");
|
||||
@@ -27,6 +36,7 @@ public class LicenseValidator {
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to load license public key", e);
|
||||
}
|
||||
this.expectedTenantId = expectedTenantId;
|
||||
}
|
||||
|
||||
public LicenseInfo validate(String token) {
|
||||
@@ -38,7 +48,6 @@ public class LicenseValidator {
|
||||
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
|
||||
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
|
||||
|
||||
// Verify signature
|
||||
try {
|
||||
Signature verifier = Signature.getInstance("Ed25519");
|
||||
verifier.initVerify(publicKey);
|
||||
@@ -52,11 +61,24 @@ public class LicenseValidator {
|
||||
throw new SecurityException("License signature verification failed", e);
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(payloadBytes);
|
||||
|
||||
String tier = root.get("tier").asText();
|
||||
String licenseIdStr = textOrThrow(root, "licenseId");
|
||||
UUID licenseId;
|
||||
try {
|
||||
licenseId = UUID.fromString(licenseIdStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr);
|
||||
}
|
||||
|
||||
String tenantId = textOrThrow(root, "tenantId");
|
||||
if (!tenantId.equals(expectedTenantId)) {
|
||||
throw new IllegalArgumentException(
|
||||
"License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'");
|
||||
}
|
||||
|
||||
String label = root.has("label") ? root.get("label").asText() : null;
|
||||
|
||||
Map<String, Integer> limits = new HashMap<>();
|
||||
if (root.has("limits")) {
|
||||
@@ -65,15 +87,17 @@ public class LicenseValidator {
|
||||
}
|
||||
|
||||
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
|
||||
Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null;
|
||||
if (!root.has("exp")) {
|
||||
throw new IllegalArgumentException("exp is required");
|
||||
}
|
||||
Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong());
|
||||
int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0;
|
||||
|
||||
// TODO Task 3 — replace with parsed licenseId/tenantId/gracePeriodDays/label
|
||||
LicenseInfo info = new LicenseInfo(
|
||||
java.util.UUID.randomUUID(), "placeholder", tier,
|
||||
limits, issuedAt, expiresAt, 0);
|
||||
LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays);
|
||||
|
||||
if (info.isExpired()) {
|
||||
throw new IllegalArgumentException("License expired at " + expiresAt);
|
||||
throw new IllegalArgumentException("License expired at " + expiresAt
|
||||
+ " (grace period " + gracePeriodDays + " days)");
|
||||
}
|
||||
|
||||
return info;
|
||||
@@ -83,4 +107,11 @@ public class LicenseValidator {
|
||||
throw new IllegalArgumentException("Failed to parse license payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String textOrThrow(JsonNode root, String field) {
|
||||
if (!root.has(field) || root.get(field).asText().isBlank()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
return root.get(field).asText();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user