Skip to content
Draft
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
68 changes: 63 additions & 5 deletions src/main/java/com/clearfolio/viewer/auth/TenantAccessService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
import java.util.Objects;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
Expand All @@ -17,6 +18,10 @@
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import com.clearfolio.viewer.credential.CredentialPurpose;
import com.clearfolio.viewer.credential.CredentialReference;
import com.clearfolio.viewer.credential.CredentialRegistry;
import com.clearfolio.viewer.credential.CredentialSnapshot;
import com.clearfolio.viewer.model.ConversionJob;

/**
Expand All @@ -27,8 +32,12 @@ public class TenantAccessService {

private static final String HMAC_SHA_256 = "HmacSHA256";
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final CredentialReference TENANT_CLAIMS_CREDENTIAL = new CredentialReference(
"tenant-claims-signing",
CredentialPurpose.TENANT_CLAIMS_SIGNING
);

private final String claimsHmacSecret;
private final byte[] claimsHmacSecret;
private final long maxSkewSeconds;
private final Clock clock;

Expand All @@ -42,6 +51,11 @@ public TenantAccessService() {
/**
* Creates an access service with optional signed gateway claim validation.
*
* <p>This compatibility constructor remains the current Spring wiring until
* the credential-registry bootstrap stack is integrated. Production
* application wiring must migrate to the registry-backed constructor rather
* than treating this property as long-lived secret authority.</p>
*
* @param claimsHmacSecret optional shared gateway HMAC secret
* @param maxSkewSeconds maximum accepted clock skew in seconds
*/
Expand All @@ -52,10 +66,46 @@ public TenantAccessService(
this(claimsHmacSecret, maxSkewSeconds, Clock.systemUTC());
}

/**
* Creates a verifier from the provider-neutral credential registry.
*
* <p>The server-owned reference is fixed to tenant-claim signing. The
* returned snapshot must bind both the expected logical credential and
* purpose before its opaque bytes become verification authority.</p>
*
* @param credentialRegistry provider-neutral credential authority
* @param maxSkewSeconds maximum accepted clock skew in seconds
* @param clock verification clock
* @throws NullPointerException when registry, clock, or snapshot is absent
* @throws IllegalStateException when registry metadata does not match the
* server-owned tenant-claims reference
*/
public TenantAccessService(
CredentialRegistry credentialRegistry,
long maxSkewSeconds,
Clock clock
) {
CredentialSnapshot snapshot = Objects.requireNonNull(
Objects.requireNonNull(credentialRegistry, "credentialRegistry")
.resolve(TENANT_CLAIMS_CREDENTIAL),
"tenant claims credential snapshot"
);
if (!TENANT_CLAIMS_CREDENTIAL.credentialName().equals(snapshot.credentialId())
|| snapshot.purpose() != TENANT_CLAIMS_CREDENTIAL.purpose()) {
throw new IllegalStateException("tenant claims credential purpose mismatch");
}
this.claimsHmacSecret = snapshot.secretBytes();
this.maxSkewSeconds = Math.max(0L, maxSkewSeconds);
this.clock = Objects.requireNonNull(clock, "clock");
}

TenantAccessService(String claimsHmacSecret, long maxSkewSeconds, Clock clock) {
this.claimsHmacSecret = clean(claimsHmacSecret);
String cleanedSecret = clean(claimsHmacSecret);
this.claimsHmacSecret = cleanedSecret == null
? null
: cleanedSecret.getBytes(StandardCharsets.UTF_8);
this.maxSkewSeconds = Math.max(0L, maxSkewSeconds);
this.clock = clock;
this.clock = Objects.requireNonNull(clock, "clock");
}

/**
Expand Down Expand Up @@ -135,6 +185,14 @@ private static long parseIssuedAt(String issuedAt) {
* @return Base64URL HMAC signature
*/
public static String signClaims(TenantContext context, String issuedAt, String secret) {
return signClaims(
context,
issuedAt,
Objects.requireNonNull(secret, "secret").getBytes(StandardCharsets.UTF_8)
);
}

private static String signClaims(TenantContext context, String issuedAt, byte[] secret) {
String payload = String.join("\n",
context.tenantId(),
context.subjectId(),
Expand All @@ -144,10 +202,10 @@ public static String signClaims(TenantContext context, String issuedAt, String s
return hmac(payload, secret);
}

private static String hmac(String payload, String secret) {
private static String hmac(String payload, byte[] secret) {
try {
Mac mac = Mac.getInstance(HMAC_SHA_256);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA_256));
mac.init(new SecretKeySpec(secret, HMAC_SHA_256));
return URL_ENCODER.encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("tenant claims signing failed", ex);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.clearfolio.viewer.auth;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;

import com.clearfolio.viewer.credential.CredentialPurpose;
import com.clearfolio.viewer.credential.CredentialRegistry;
import com.clearfolio.viewer.credential.CredentialSnapshot;

class TenantAccessServiceCredentialRegistryTest {

private static final String SECRET = "tenant-registry-signing-key";
private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z");

@Test
void registryBackedServiceValidatesClaimsWithPurposeScopedSecretBytes() {
CredentialRegistry registry = reference -> {
assertEquals("tenant-claims-signing", reference.credentialName());
assertEquals(CredentialPurpose.TENANT_CLAIMS_SIGNING, reference.purpose());
return new CredentialSnapshot(
reference.credentialName(),
"v1",
CredentialPurpose.TENANT_CLAIMS_SIGNING,
SECRET.getBytes(StandardCharsets.UTF_8)
);
};
TenantAccessService service = new TenantAccessService(
registry,
300L,
Clock.fixed(NOW, ZoneOffset.UTC)
);
HttpHeaders headers = signedHeaders();

assertDoesNotThrow(() -> service.require(headers, TenantPermissions.JOB_READ));
}

@Test
void registryBackedServiceRejectsWrongPurposeSnapshot() {
CredentialRegistry registry = reference -> new CredentialSnapshot(
reference.credentialName(),
"v1",
CredentialPurpose.ARTIFACT_TOKEN_SIGNING,
SECRET.getBytes(StandardCharsets.UTF_8)
);

IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> new TenantAccessService(registry, 300L, Clock.fixed(NOW, ZoneOffset.UTC))
);

assertEquals("tenant claims credential purpose mismatch", exception.getMessage());
}

@Test
void registryBackedServiceRejectsWrongCredentialIdentity() {
CredentialRegistry registry = reference -> new CredentialSnapshot(
"different-credential",
"v1",
CredentialPurpose.TENANT_CLAIMS_SIGNING,
SECRET.getBytes(StandardCharsets.UTF_8)
);

IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> new TenantAccessService(registry, 300L, Clock.fixed(NOW, ZoneOffset.UTC))
);

assertEquals("tenant claims credential purpose mismatch", exception.getMessage());
}

@Test
void registryBackedServiceFailsClosedForMissingAuthority() {
assertThrows(
NullPointerException.class,
() -> new TenantAccessService(
(CredentialRegistry) null,
300L,
Clock.fixed(NOW, ZoneOffset.UTC)
)
);
assertThrows(
NullPointerException.class,
() -> new TenantAccessService(
reference -> null,
300L,
Clock.fixed(NOW, ZoneOffset.UTC)
)
);
assertThrows(
NullPointerException.class,
() -> new TenantAccessService(
reference -> new CredentialSnapshot(
reference.credentialName(),
"v1",
CredentialPurpose.TENANT_CLAIMS_SIGNING,
SECRET.getBytes(StandardCharsets.UTF_8)
),
300L,
null
)
);
}

private static HttpHeaders signedHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID);
headers.set(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID);
headers.set(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ);
String issuedAt = String.valueOf(NOW.getEpochSecond());
TenantContext context = TenantContext.fromHeaders(headers).orElseThrow();
headers.set(TenantContext.CLAIMS_ISSUED_AT_HEADER, issuedAt);
headers.set(
TenantContext.CLAIMS_SIGNATURE_HEADER,
TenantAccessService.signClaims(context, issuedAt, SECRET)
);
return headers;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ void requireAcceptsSignedGatewayClaimsWhenSecretIsConfigured() {
@Test
void requireSkipsSignatureValidationWhenSecretIsBlankOrNull() {
TenantAccessService blankSecret = new TenantAccessService(" ", 300L, Clock.fixed(NOW, ZoneOffset.UTC));
TenantAccessService nullSecret = new TenantAccessService(null, 300L, Clock.fixed(NOW, ZoneOffset.UTC));
TenantAccessService nullSecret = new TenantAccessService(
(String) null,
300L,
Clock.fixed(NOW, ZoneOffset.UTC)
);

assertDoesNotThrow(() -> blankSecret.require(headers(TenantPermissions.JOB_READ), TenantPermissions.JOB_READ));
assertDoesNotThrow(() -> nullSecret.require(headers(TenantPermissions.JOB_READ), TenantPermissions.JOB_READ));
Expand Down