From ecfcc09af486c612a19050a19fc94c32e626ef56 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sat, 26 Sep 2026 23:22:26 +0800 Subject: [PATCH] feat(security): add opaque entity references Implements the portable UserContext reference contract and runnable evidence.\n\nRefs #176 --- examples/security-foundations/pom.xml | 25 ++++ .../SecurityFoundationsExampleTest.java | 35 +++++ pom.xml | 1 + scripts/verify-examples.sh | 4 +- .../io/teaql/core/EntityReferenceClaims.java | 13 ++ .../io/teaql/core/EntityReferenceCodec.java | 10 ++ .../core/EntityReferenceTokenException.java | 15 +++ .../io/teaql/core/EntityReferenceWire.java | 96 ++++++++++++++ .../main/java/io/teaql/core/UserContext.java | 46 +++++++ .../security/AeadEntityReferenceCodec.java | 120 ++++++++++++++++++ .../AeadEntityReferenceCodecTest.java | 63 +++++++++ 11 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 examples/security-foundations/pom.xml create mode 100644 examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java create mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java create mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java create mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java create mode 100644 teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java diff --git a/examples/security-foundations/pom.xml b/examples/security-foundations/pom.xml new file mode 100644 index 00000000..14a72b17 --- /dev/null +++ b/examples/security-foundations/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + io.teaql + teaql-java-parent + 1.550-RELEASE + ../../pom.xml + + teaql-security-foundations-example + TeaQL security foundations focused example + + + io.teaql + teaql-runtime + + + junit + junit + test + + + diff --git a/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java b/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java new file mode 100644 index 00000000..a3ab2722 --- /dev/null +++ b/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java @@ -0,0 +1,35 @@ +package io.teaql.examples.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import io.teaql.core.EntityReferenceTokenException; +import io.teaql.runtime.DefaultUserContext; +import io.teaql.runtime.security.AeadEntityReferenceCodec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Map; +import org.junit.Test; + +public class SecurityFoundationsExampleTest { + @Test + public void userContextProtectsBoundaryReferences() { + byte[] key = new byte[32]; + byte[] nonce = new byte[12]; + Arrays.fill(key, (byte) 0x22); + Arrays.fill(nonce, (byte) 0x33); + AeadEntityReferenceCodec codec = new AeadEntityReferenceCodec(2, Map.of(2, key)) + .withClock(Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC)) + .withNonceSource(() -> nonce.clone()); + DefaultUserContext context = new DefaultUserContext(null); + context.withEntityReferenceCodec(codec); + String token = context.encodeEntityReference("OrderItem", 42, 7, "edit-order", Duration.ofHours(1)); + assertEquals(42, context.decodeEntityReference(token, "OrderItem", "edit-order").id()); + assertThrows(EntityReferenceTokenException.class, + () -> context.decodeEntityReference(token, "OrderItem", "view-order")); + System.out.println("PASS Java security foundations: opaque reference is portable and purpose-bound"); + } +} diff --git a/pom.xml b/pom.xml index e224bc43..6b8066b6 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,7 @@ teaql-android teaql-business-id-jdbc examples/business-id-runtime + examples/security-foundations teaql-tfp-endpoint teaql-opentelemetry examples/conformance/lib diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh index 5755153a..1e2bf541 100755 --- a/scripts/verify-examples.sh +++ b/scripts/verify-examples.sh @@ -4,7 +4,7 @@ set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" verification_dir="$(mktemp -d)" trap 'rm -rf -- "$verification_dir"' EXIT -expected=(business-id-runtime conformance order-management school-management) +expected=(business-id-runtime conformance order-management school-management security-foundations) mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) if [[ "${actual[*]}" != "${expected[*]}" ]]; then echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2 @@ -15,6 +15,8 @@ cd "$repo" mvn -q -DskipTests install mvn -q -pl examples/business-id-runtime \ -Dtest=BusinessIdRuntimeExampleTest test +mvn -q -pl examples/security-foundations \ + -Dtest=SecurityFoundationsExampleTest test mvn -q -f examples/conformance/lib/pom.xml install -DskipTests mvn -q -f examples/conformance/pom.xml spring-boot:run \ -Dspring-boot.run.arguments="--spring.main.web-application-type=none --spring.datasource.url=jdbc:sqlite:$verification_dir/conformance.db" diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java new file mode 100644 index 00000000..90828277 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java @@ -0,0 +1,13 @@ +package io.teaql.core; + +import java.time.Instant; + +/** Verified, boundary-safe identity recovered from an opaque entity reference. */ +public record EntityReferenceClaims( + String entityType, + long id, + long version, + Instant issuedAt, + Instant expiresAt, + String purpose, + int keyVersion) {} diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java new file mode 100644 index 00000000..9ea944e2 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java @@ -0,0 +1,10 @@ +package io.teaql.core; + +import java.time.Duration; + +/** Provider SPI for boundary-safe entity references. */ +public interface EntityReferenceCodec { + String encode(String entityType, long id, long version, String purpose, Duration lifetime); + + EntityReferenceClaims decode(String token, String expectedEntityType, String purpose); +} diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java new file mode 100644 index 00000000..d5999197 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java @@ -0,0 +1,15 @@ +package io.teaql.core; + +/** Stable fail-closed error; deliberately does not reveal why validation failed. */ +public final class EntityReferenceTokenException extends TeaQLRuntimeException { + private final String code; + + public EntityReferenceTokenException(String code) { + super(code); + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java new file mode 100644 index 00000000..77b05690 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java @@ -0,0 +1,96 @@ +package io.teaql.core; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; + +/** Portable wire helpers shared by the runtime codec and the explicit development bypass. */ +public final class EntityReferenceWire { + public static final String ENCRYPTED_PREFIX = "tqr1."; + public static final String RAW_PREFIX = "tqr0."; + public static final String ASSOCIATED_DATA = "teaql.entity-reference.v1"; + public static final String UNSAFE_ENVIRONMENT = "TEAQL_UNSAFE_RAW_ENTITY_REFERENCES"; + public static final String UNSAFE_ACKNOWLEDGEMENT = + "I_UNDERSTAND_RAW_ENTITY_IDS_ARE_VISIBLE_FOR_LOCAL_DEVELOPMENT_ONLY"; + + private EntityReferenceWire() {} + + public static byte[] encodeClaims(EntityReferenceClaims claims) { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + writeText(output, claims.entityType()); + output.write(ByteBuffer.allocate(32) + .putLong(claims.id()) + .putLong(claims.version()) + .putLong(claims.issuedAt().getEpochSecond()) + .putLong(claims.expiresAt().getEpochSecond()) + .array()); + writeText(output, claims.purpose()); + return output.toByteArray(); + } catch (EntityReferenceTokenException exception) { + throw exception; + } catch (Exception exception) { + throw invalid(); + } + } + + public static EntityReferenceClaims decodeClaims(byte[] data) { + try { + ByteBuffer input = ByteBuffer.wrap(data); + String entityType = readText(input); + if (input.remaining() < 32) throw invalid(); + long id = input.getLong(); + long version = input.getLong(); + long issuedAt = input.getLong(); + long expiresAt = input.getLong(); + String purpose = readText(input); + if (input.hasRemaining() || entityType.isEmpty() || id <= 0) throw invalid(); + return new EntityReferenceClaims( + entityType, id, version, Instant.ofEpochSecond(issuedAt), + Instant.ofEpochSecond(expiresAt), purpose, 0); + } catch (EntityReferenceTokenException exception) { + throw exception; + } catch (Exception exception) { + throw invalid(); + } + } + + public static String base64Url(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + public static byte[] base64UrlDecode(String text) { + try { + return Base64.getUrlDecoder().decode(text); + } catch (IllegalArgumentException exception) { + throw invalid(); + } + } + + public static boolean unsafeRawReferencesEnabled() { + return UNSAFE_ACKNOWLEDGEMENT.equals(System.getenv(UNSAFE_ENVIRONMENT)); + } + + public static EntityReferenceTokenException invalid() { + return new EntityReferenceTokenException("ENTITY_REFERENCE_INVALID"); + } + + private static void writeText(ByteArrayOutputStream output, String value) { + byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8); + if (bytes.length > 0xffff) throw invalid(); + output.write((bytes.length >>> 8) & 0xff); + output.write(bytes.length & 0xff); + output.writeBytes(bytes); + } + + private static String readText(ByteBuffer input) { + if (input.remaining() < 2) throw invalid(); + int length = Short.toUnsignedInt(input.getShort()); + if (input.remaining() < length) throw invalid(); + byte[] bytes = new byte[length]; + input.get(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/UserContext.java b/teaql-core/src/main/java/io/teaql/core/UserContext.java index 36b8be45..67072fe7 100644 --- a/teaql-core/src/main/java/io/teaql/core/UserContext.java +++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java @@ -11,6 +11,8 @@ import io.teaql.core.businessid.BusinessClock; import io.teaql.core.businessid.BusinessIdSchemaContributor; import io.teaql.core.businessid.BusinessIdService; +import java.time.Duration; +import java.time.Instant; public interface UserContext extends OptNullBasicTypeFromObjectGetter { @@ -19,6 +21,50 @@ public interface UserContext extends OptNullBasicTypeFromObjectGetter { String TEAQL_ACTIVE_ROOT = "teaql.active.root"; String TEAQL_FIX_EVIDENCE_CURRENT = "teaql.fix.evidence.current"; String TEAQL_FIX_EVIDENCE_LAST = "teaql.fix.evidence.last"; + String TEAQL_ENTITY_REFERENCE_CODEC = EntityReferenceCodec.class.getName(); + + default UserContext withEntityReferenceCodec(EntityReferenceCodec codec) { + if (codec == null) throw new IllegalArgumentException("codec must not be null"); + putAttribute(TEAQL_ENTITY_REFERENCE_CODEC, codec); + return this; + } + + default String encodeEntityReference( + String entityType, long id, long version, String purpose, Duration lifetime) { + EntityReferenceCodec codec = getAttribute(TEAQL_ENTITY_REFERENCE_CODEC, EntityReferenceCodec.class); + if (codec == null) codec = capability(EntityReferenceCodec.class); + if (codec != null) return codec.encode(entityType, id, version, purpose, lifetime); + if (!EntityReferenceWire.unsafeRawReferencesEnabled()) { + throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED"); + } + if (entityType == null || entityType.isBlank() || id <= 0 || lifetime == null + || lifetime.isZero() || lifetime.isNegative()) throw EntityReferenceWire.invalid(); + Instant now = Instant.now(); + EntityReferenceClaims claims = new EntityReferenceClaims( + entityType, id, version, now, now.plus(lifetime), purpose == null ? "" : purpose, 0); + return EntityReferenceWire.RAW_PREFIX + + EntityReferenceWire.base64Url(EntityReferenceWire.encodeClaims(claims)); + } + + default EntityReferenceClaims decodeEntityReference( + String token, String expectedEntityType, String purpose) { + EntityReferenceCodec codec = getAttribute(TEAQL_ENTITY_REFERENCE_CODEC, EntityReferenceCodec.class); + if (codec == null) codec = capability(EntityReferenceCodec.class); + if (codec != null) return codec.decode(token, expectedEntityType, purpose); + if (!EntityReferenceWire.unsafeRawReferencesEnabled()) { + throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED"); + } + if (token == null || !token.startsWith(EntityReferenceWire.RAW_PREFIX)) { + throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED"); + } + EntityReferenceClaims claims = EntityReferenceWire.decodeClaims(EntityReferenceWire.base64UrlDecode( + token.substring(EntityReferenceWire.RAW_PREFIX.length()))); + Instant now = Instant.now(); + if (!claims.expiresAt().isAfter(now) || claims.issuedAt().isAfter(now.plusSeconds(60)) + || !claims.entityType().equals(expectedEntityType) + || !claims.purpose().equals(purpose)) throw EntityReferenceWire.invalid(); + return claims; + } default void beginFixEvidence() { putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList()); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java b/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java new file mode 100644 index 00000000..c6854a1a --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java @@ -0,0 +1,120 @@ +package io.teaql.runtime.security; + +import io.teaql.core.EntityReferenceClaims; +import io.teaql.core.EntityReferenceCodec; +import io.teaql.core.EntityReferenceTokenException; +import io.teaql.core.EntityReferenceWire; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** Portable AES-256-GCM implementation of TeaQL opaque entity references. */ +public final class AeadEntityReferenceCodec implements EntityReferenceCodec { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private final int activeKeyVersion; + private final Map keys; + private Clock clock = Clock.systemUTC(); + private Supplier nonceSource = () -> { + byte[] nonce = new byte[12]; + SECURE_RANDOM.nextBytes(nonce); + return nonce; + }; + + public AeadEntityReferenceCodec(int activeKeyVersion, Map keys) { + if (keys == null || !keys.containsKey(activeKeyVersion)) { + throw new IllegalArgumentException("Active entity reference key is missing"); + } + this.keys = new HashMap<>(); + keys.forEach((version, key) -> { + if (key == null || key.length != 32) { + throw new IllegalArgumentException("Every entity reference key must contain 32 bytes"); + } + this.keys.put(version, Arrays.copyOf(key, key.length)); + }); + this.activeKeyVersion = activeKeyVersion; + } + + public AeadEntityReferenceCodec withClock(Clock clock) { + this.clock = java.util.Objects.requireNonNull(clock); + return this; + } + + public AeadEntityReferenceCodec withNonceSource(Supplier nonceSource) { + this.nonceSource = java.util.Objects.requireNonNull(nonceSource); + return this; + } + + @Override + public String encode(String entityType, long id, long version, String purpose, Duration lifetime) { + if (entityType == null || entityType.isBlank() || id <= 0 || lifetime == null + || lifetime.isZero() || lifetime.isNegative()) throw EntityReferenceWire.invalid(); + try { + Instant now = clock.instant(); + byte[] plaintext = EntityReferenceWire.encodeClaims(new EntityReferenceClaims( + entityType, id, version, now, now.plus(lifetime), purpose == null ? "" : purpose, 0)); + byte[] nonce = nonceSource.get(); + if (nonce == null || nonce.length != 12) throw new IllegalArgumentException("AES-GCM nonce must contain 12 bytes"); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keys.get(activeKeyVersion), "AES"), + new GCMParameterSpec(128, nonce)); + cipher.updateAAD(EntityReferenceWire.ASSOCIATED_DATA.getBytes(StandardCharsets.UTF_8)); + byte[] ciphertextAndTag = cipher.doFinal(plaintext); + byte[] envelope = ByteBuffer.allocate(4 + nonce.length + ciphertextAndTag.length) + .putInt(activeKeyVersion).put(nonce).put(ciphertextAndTag).array(); + return EntityReferenceWire.ENCRYPTED_PREFIX + EntityReferenceWire.base64Url(envelope); + } catch (EntityReferenceTokenException exception) { + throw exception; + } catch (Exception exception) { + throw new TeaqlEntityReferenceConfigurationException("Unable to encode entity reference", exception); + } + } + + @Override + public EntityReferenceClaims decode(String token, String expectedEntityType, String purpose) { + try { + if (token == null || !token.startsWith(EntityReferenceWire.ENCRYPTED_PREFIX)) throw EntityReferenceWire.invalid(); + byte[] envelope = EntityReferenceWire.base64UrlDecode(token.substring(EntityReferenceWire.ENCRYPTED_PREFIX.length())); + if (envelope.length < 4 + 12 + 16) throw EntityReferenceWire.invalid(); + ByteBuffer input = ByteBuffer.wrap(envelope); + int keyVersion = input.getInt(); + byte[] key = keys.get(keyVersion); + if (key == null) throw EntityReferenceWire.invalid(); + byte[] nonce = new byte[12]; + input.get(nonce); + byte[] ciphertextAndTag = new byte[input.remaining()]; + input.get(ciphertextAndTag); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce)); + cipher.updateAAD(EntityReferenceWire.ASSOCIATED_DATA.getBytes(StandardCharsets.UTF_8)); + EntityReferenceClaims decoded = EntityReferenceWire.decodeClaims(cipher.doFinal(ciphertextAndTag)); + EntityReferenceClaims claims = new EntityReferenceClaims( + decoded.entityType(), decoded.id(), decoded.version(), decoded.issuedAt(), + decoded.expiresAt(), decoded.purpose(), keyVersion); + Instant now = clock.instant(); + if (!claims.expiresAt().isAfter(now) || claims.issuedAt().isAfter(now.plusSeconds(60)) + || !claims.entityType().equals(expectedEntityType) + || !claims.purpose().equals(purpose)) throw EntityReferenceWire.invalid(); + return claims; + } catch (EntityReferenceTokenException exception) { + throw exception; + } catch (Exception exception) { + throw EntityReferenceWire.invalid(); + } + } + + public static final class TeaqlEntityReferenceConfigurationException extends RuntimeException { + public TeaqlEntityReferenceConfigurationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java new file mode 100644 index 00000000..94ec2909 --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java @@ -0,0 +1,63 @@ +package io.teaql.runtime.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import io.teaql.core.EntityReferenceClaims; +import io.teaql.core.EntityReferenceTokenException; +import io.teaql.runtime.DefaultUserContext; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Map; +import org.junit.Test; + +public class AeadEntityReferenceCodecTest { + private static final String GOLDEN = "tqr1.AAAAAjMzMzMzMzMzMzMzM3bKiZgRSQQhfIj2cBXRDZIloUGHWLBp8QrXL_aejwIXPFtvV_E71O7wbOXy3cvYo_SwxvuS-89x572T9CO_pDAY4tbjWCNv"; + private static final Instant NOW = Instant.parse("2026-09-26T12:00:00Z"); + + @Test + public void referenceIsPortableOpaqueBoundRotatableAndExpiring() { + byte[] key = new byte[32]; + byte[] nonce = new byte[12]; + Arrays.fill(key, (byte) 0x22); + Arrays.fill(nonce, (byte) 0x33); + AeadEntityReferenceCodec codec = new AeadEntityReferenceCodec(2, Map.of(2, key)) + .withClock(Clock.fixed(NOW, ZoneOffset.UTC)) + .withNonceSource(() -> nonce.clone()); + DefaultUserContext context = new DefaultUserContext(null); + context.withEntityReferenceCodec(codec); + + String token = context.encodeEntityReference( + "OrderItem", 42, 7, "edit-order", Duration.ofHours(1)); + assertEquals(GOLDEN, token); + assertFalse(token.contains("OrderItem")); + assertFalse(token.contains("42")); + EntityReferenceClaims claims = context.decodeEntityReference(token, "OrderItem", "edit-order"); + assertEquals(42, claims.id()); + assertEquals(7, claims.version()); + assertEquals(2, claims.keyVersion()); + + assertThrows(EntityReferenceTokenException.class, + () -> context.decodeEntityReference(token, "InvoiceItem", "edit-order")); + assertThrows(EntityReferenceTokenException.class, + () -> context.decodeEntityReference(token, "OrderItem", "other-purpose")); + assertThrows(EntityReferenceTokenException.class, + () -> context.decodeEntityReference(token.substring(0, token.length() - 1) + "A", "OrderItem", "edit-order")); + + codec.withClock(Clock.fixed(NOW.plus(Duration.ofHours(2)), ZoneOffset.UTC)); + assertThrows(EntityReferenceTokenException.class, + () -> context.decodeEntityReference(token, "OrderItem", "edit-order")); + } + + @Test + public void missingCodecFailsClosedWithoutExactDevelopmentAcknowledgement() { + DefaultUserContext context = new DefaultUserContext(null); + EntityReferenceTokenException error = assertThrows(EntityReferenceTokenException.class, + () -> context.encodeEntityReference("Order", 1, 1, "edit", Duration.ofMinutes(1))); + assertEquals("ENTITY_REFERENCE_CODEC_REQUIRED", error.code()); + } +}