From 5f7099cad795a74e102dcb9366b5968b875a84bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:13:51 +0900 Subject: [PATCH 1/2] test(auth): define identity key snapshot authority --- .../viewer/auth/IdentityKeySnapshotTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/auth/IdentityKeySnapshotTest.java diff --git a/src/test/java/com/clearfolio/viewer/auth/IdentityKeySnapshotTest.java b/src/test/java/com/clearfolio/viewer/auth/IdentityKeySnapshotTest.java new file mode 100644 index 00000000..73ee38de --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/auth/IdentityKeySnapshotTest.java @@ -0,0 +1,112 @@ +package com.clearfolio.viewer.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class IdentityKeySnapshotTest { + + private static final Instant FETCHED_AT = Instant.parse("2026-08-11T04:00:00Z"); + private static final Instant EXPIRES_AT = Instant.parse("2026-08-11T04:05:00Z"); + + @Test + void preservesImmutableGenerationBoundKeyAuthority() { + Set mutableKeyIds = new HashSet<>(Set.of("key-a", "key-b")); + IdentityKeySnapshot snapshot = new IdentityKeySnapshot( + "workforce", + 3, + FETCHED_AT, + EXPIRES_AT, + mutableKeyIds + ); + mutableKeyIds.clear(); + + assertEquals("workforce", snapshot.verifierProfileId()); + assertEquals(3, snapshot.generation()); + assertEquals(FETCHED_AT, snapshot.fetchedAt()); + assertEquals(EXPIRES_AT, snapshot.expiresAt()); + assertEquals(Set.of("key-a", "key-b"), snapshot.trustedKeyIds()); + assertThrows(UnsupportedOperationException.class, () -> snapshot.trustedKeyIds().add("key-c")); + + assertTrue(snapshot.isCurrentAt(FETCHED_AT)); + assertTrue(snapshot.isCurrentAt(EXPIRES_AT.minusNanos(1))); + assertFalse(snapshot.isCurrentAt(FETCHED_AT.minusNanos(1))); + assertFalse(snapshot.isCurrentAt(EXPIRES_AT)); + assertTrue(snapshot.authorizes("key-a", 3, FETCHED_AT)); + assertFalse(snapshot.authorizes("key-c", 3, FETCHED_AT)); + assertFalse(snapshot.authorizes("key-a", 2, FETCHED_AT)); + assertFalse(snapshot.authorizes("key-a", 3, EXPIRES_AT)); + } + + @Test + void rejectsMalformedSnapshotAuthorityAndVerificationInputs() { + assertEquals("verifierProfileId", assertThrows( + NullPointerException.class, + () -> new IdentityKeySnapshot(null, 1, FETCHED_AT, EXPIRES_AT, Set.of("key-a")) + ).getMessage()); + assertEquals("verifierProfileId must not be blank", assertThrows( + IllegalArgumentException.class, + () -> new IdentityKeySnapshot(" ", 1, FETCHED_AT, EXPIRES_AT, Set.of("key-a")) + ).getMessage()); + assertEquals("generation must be positive", assertThrows( + IllegalArgumentException.class, + () -> new IdentityKeySnapshot("workforce", 0, FETCHED_AT, EXPIRES_AT, Set.of("key-a")) + ).getMessage()); + assertEquals("fetchedAt", assertThrows( + NullPointerException.class, + () -> new IdentityKeySnapshot("workforce", 1, null, EXPIRES_AT, Set.of("key-a")) + ).getMessage()); + assertEquals("expiresAt", assertThrows( + NullPointerException.class, + () -> new IdentityKeySnapshot("workforce", 1, FETCHED_AT, null, Set.of("key-a")) + ).getMessage()); + assertEquals("fetchedAt must precede expiresAt", assertThrows( + IllegalArgumentException.class, + () -> new IdentityKeySnapshot("workforce", 1, EXPIRES_AT, EXPIRES_AT, Set.of("key-a")) + ).getMessage()); + assertEquals("trustedKeyIds", assertThrows( + NullPointerException.class, + () -> new IdentityKeySnapshot("workforce", 1, FETCHED_AT, EXPIRES_AT, null) + ).getMessage()); + assertEquals("trustedKeyIds must not be empty", assertThrows( + IllegalArgumentException.class, + () -> new IdentityKeySnapshot("workforce", 1, FETCHED_AT, EXPIRES_AT, Set.of()) + ).getMessage()); + + Set nullKeyIds = new HashSet<>(); + nullKeyIds.add(null); + assertEquals("trustedKeyIds must not contain null", assertThrows( + NullPointerException.class, + () -> new IdentityKeySnapshot("workforce", 1, FETCHED_AT, EXPIRES_AT, nullKeyIds) + ).getMessage()); + assertEquals("trustedKeyIds must not contain blank identifiers", assertThrows( + IllegalArgumentException.class, + () -> new IdentityKeySnapshot("workforce", 1, FETCHED_AT, EXPIRES_AT, Set.of(" ")) + ).getMessage()); + + IdentityKeySnapshot snapshot = new IdentityKeySnapshot( + "workforce", + 1, + FETCHED_AT, + EXPIRES_AT, + Set.of("key-a") + ); + assertEquals("now", assertThrows( + NullPointerException.class, + () -> snapshot.isCurrentAt(null) + ).getMessage()); + assertEquals("keyId", assertThrows( + NullPointerException.class, + () -> snapshot.authorizes(null, 1, FETCHED_AT) + ).getMessage()); + assertFalse(snapshot.authorizes(" ", 1, FETCHED_AT)); + assertFalse(snapshot.authorizes("key-a", 0, FETCHED_AT)); + } +} From 959de4116c8d0587c038d6b1c8363c55dc4b6d0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:16:14 +0900 Subject: [PATCH 2/2] feat(auth): add generation-fenced identity key snapshot --- .../viewer/auth/IdentityKeySnapshot.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/main/java/com/clearfolio/viewer/auth/IdentityKeySnapshot.java diff --git a/src/main/java/com/clearfolio/viewer/auth/IdentityKeySnapshot.java b/src/main/java/com/clearfolio/viewer/auth/IdentityKeySnapshot.java new file mode 100644 index 00000000..b221139e --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/auth/IdentityKeySnapshot.java @@ -0,0 +1,144 @@ +package com.clearfolio.viewer.auth; + +import java.time.Instant; +import java.util.Objects; +import java.util.Set; + +/** + * Immutable provider-neutral authority for one bounded identity-provider key snapshot. + * + *

The snapshot contains only server-owned verifier profile identity, key identifiers, + * cache lifetime, and a monotonically assigned generation fence. It does not parse + * tokens, select algorithms, fetch provider metadata, or carry JWK bodies. Callers + * must still perform cryptographic verification with trusted key material and compare + * the current configured generation before accepting a token key identifier.

+ */ +public final class IdentityKeySnapshot { + + private final String verifierProfileId; + private final long generation; + private final Instant fetchedAt; + private final Instant expiresAt; + private final Set trustedKeyIds; + + /** + * Creates one immutable key snapshot. + * + * @param verifierProfileId server-owned verifier profile identifier + * @param generation positive key-set generation used to fence stale snapshots + * @param fetchedAt inclusive instant at which this snapshot became current + * @param expiresAt exclusive cache-expiration instant + * @param trustedKeyIds non-empty exact key identifiers in this generation + * @throws NullPointerException when a required value or key identifier is null + * @throws IllegalArgumentException when an identifier is blank, generation is not + * positive, the lifetime is empty/reversed, or no trusted key is present + */ + public IdentityKeySnapshot( + String verifierProfileId, + long generation, + Instant fetchedAt, + Instant expiresAt, + Set trustedKeyIds) { + this.verifierProfileId = Objects.requireNonNull(verifierProfileId, "verifierProfileId"); + if (verifierProfileId.isBlank()) { + throw new IllegalArgumentException("verifierProfileId must not be blank"); + } + if (generation <= 0) { + throw new IllegalArgumentException("generation must be positive"); + } + this.fetchedAt = Objects.requireNonNull(fetchedAt, "fetchedAt"); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt"); + if (!fetchedAt.isBefore(expiresAt)) { + throw new IllegalArgumentException("fetchedAt must precede expiresAt"); + } + Set requiredKeyIds = Objects.requireNonNull(trustedKeyIds, "trustedKeyIds"); + if (requiredKeyIds.isEmpty()) { + throw new IllegalArgumentException("trustedKeyIds must not be empty"); + } + for (String keyId : requiredKeyIds) { + Objects.requireNonNull(keyId, "trustedKeyIds must not contain null"); + if (keyId.isBlank()) { + throw new IllegalArgumentException("trustedKeyIds must not contain blank identifiers"); + } + } + this.generation = generation; + this.trustedKeyIds = Set.copyOf(requiredKeyIds); + } + + /** + * Returns the server-owned verifier profile identifier. + * + * @return verifier profile identifier + */ + public String verifierProfileId() { + return verifierProfileId; + } + + /** + * Returns the positive key-set generation fence. + * + * @return key-set generation + */ + public long generation() { + return generation; + } + + /** + * Returns the inclusive instant at which this snapshot became current. + * + * @return fetch instant + */ + public Instant fetchedAt() { + return fetchedAt; + } + + /** + * Returns the exclusive snapshot cache-expiration instant. + * + * @return expiration instant + */ + public Instant expiresAt() { + return expiresAt; + } + + /** + * Returns an immutable copy of exact trusted key identifiers. + * + * @return immutable trusted key identifier set + */ + public Set trustedKeyIds() { + return trustedKeyIds; + } + + /** + * Checks whether the snapshot is current at an exact verifier time. + * + * @param now verifier-owned current time + * @return true from {@code fetchedAt} inclusive until {@code expiresAt} exclusive + * @throws NullPointerException when {@code now} is null + */ + public boolean isCurrentAt(Instant now) { + Instant requiredNow = Objects.requireNonNull(now, "now"); + return !requiredNow.isBefore(fetchedAt) && requiredNow.isBefore(expiresAt); + } + + /** + * Checks whether this current snapshot authorizes an exact key identifier under + * the caller's expected generation fence. + * + * @param keyId token key identifier to compare exactly + * @param expectedGeneration caller-owned current generation + * @param now verifier-owned current time + * @return true only for a known key in the expected current snapshot generation + * @throws NullPointerException when {@code keyId} is null + */ + public boolean authorizes(String keyId, long expectedGeneration, Instant now) { + String requiredKeyId = Objects.requireNonNull(keyId, "keyId"); + if (requiredKeyId.isBlank() + || expectedGeneration != generation + || !isCurrentAt(now)) { + return false; + } + return trustedKeyIds.contains(requiredKeyId); + } +}