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);
+ }
+}
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));
+ }
+}