Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/main/java/com/clearfolio/viewer/auth/IdentityKeySnapshot.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
public final class IdentityKeySnapshot {

private final String verifierProfileId;
private final long generation;
private final Instant fetchedAt;
private final Instant expiresAt;
private final Set<String> 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<String> 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<String> 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<String> 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);
}
}
112 changes: 112 additions & 0 deletions src/test/java/com/clearfolio/viewer/auth/IdentityKeySnapshotTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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));
}
}
Loading