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
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ public Optional<VerifiedApiKey> verify(String presentedSecret) {
if (verification.revoked() || verification.expired()) {
return Optional.empty();
}
if (verification.id() == null
|| verification.id().isBlank()
|| verification.subject() == null
|| verification.subject().isBlank()) {
throw new ApiKeyOperationUnavailableException("Clerk returned an incomplete API key identity");
}
VerifiedApiKey verifiedApiKey = new VerifiedApiKey(verification.id(), verification.subject());
return Optional.of(verifiedApiKey);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
import com.williamcallahan.javachat.application.auth.ApiKeyOperationUnavailableException;
import com.williamcallahan.javachat.application.auth.VerifiedApiKey;
import java.util.Optional;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
Expand Down Expand Up @@ -106,6 +108,30 @@ void rejectsIncompleteLifecycleState() {
clerkServer.verify();
}

@ParameterizedTest
@MethodSource("incompleteIdentityVerificationResponses")
void rejectsIncompleteIdentity(String verificationResponse) {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer clerkServer =
MockRestServiceServer.bindTo(restClientBuilder).build();
ClerkApiKeyVerifier verifier = new ClerkApiKeyVerifier(restClientBuilder.build(), CLERK_SECRET_KEY);
clerkServer
.expect(requestTo(CLERK_VERIFY_ENDPOINT))
.andRespond(withSuccess(verificationResponse, MediaType.APPLICATION_JSON));

assertThrows(ApiKeyOperationUnavailableException.class, () -> verifier.verify(PRESENTED_API_KEY));
clerkServer.verify();
}

private static Stream<String> incompleteIdentityVerificationResponses() {
return Stream.of(
"{\"id\":\"ak_0123456789abcdef0123456789abcdef\",\"revoked\":false,\"expired\":false}",
"{\"subject\":\"user_0123456789abcdefghijklmnopq\",\"revoked\":false,\"expired\":false}",
"{\"revoked\":false,\"expired\":false}",
"{\"id\":\"ak_0123456789abcdef0123456789abcdef\",\"subject\":\"\",\"revoked\":false,\"expired\":false}",
"{\"id\":\"\",\"subject\":\"user_0123456789abcdefghijklmnopq\",\"revoked\":false,\"expired\":false}");
}

@Test
void distinguishesRejectedCredentialFromUnavailableVerification() {
RestClient.Builder rejectionClientBuilder = RestClient.builder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.williamcallahan.javachat.adapters.in.web.security.ClerkApiKeyAuthenticationFilter;
import com.williamcallahan.javachat.adapters.out.clerk.ClerkApiKeyVerifier;
import com.williamcallahan.javachat.application.auth.ApiKeyOperationUnavailableException;
import com.williamcallahan.javachat.application.auth.VerifiedApiKey;
Expand Down Expand Up @@ -173,6 +174,31 @@ void unavailableApiKeyRevocationReturns503AndLogsOneFailure() throws Exception {
}
}

@Test
void unavailableApiKeyVerificationReturns503AndLogsOneFailure() throws Exception {
ApiKeyOperationUnavailableException verificationFailure =
new ApiKeyOperationUnavailableException("Clerk returned an incomplete API key identity");
when(clerkApiKeyVerifier.verify(CLERK_API_KEY_SECRET)).thenThrow(verificationFailure);
Logger filterLogger = (Logger) LoggerFactory.getLogger(ClerkApiKeyAuthenticationFilter.class);

try (ExpectedLogEvents verificationLogEvents = ExpectedLogEvents.capture(filterLogger)) {
mockMvc.perform(get("/api/me").header("Authorization", "Bearer " + CLERK_API_KEY_SECRET))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.status").value("error"))
.andExpect(jsonPath("$.message")
.value("API key verification is temporarily unavailable. Please retry."));

assertEquals(1, verificationLogEvents.events().size());
ILoggingEvent verificationLogEvent = verificationLogEvents.events().getFirst();
assertEquals(Level.ERROR, verificationLogEvent.getLevel());
assertEquals("Clerk API key verification was unavailable", verificationLogEvent.getFormattedMessage());
assertEquals(
ApiKeyOperationUnavailableException.class.getName(),
verificationLogEvent.getThrowableProxy().getClassName());
assertFalse(verificationLogEvent.getFormattedMessage().contains(CLERK_API_KEY_SECRET));
}
}

@Test
void publicChatSurfaceStaysAnonymous() throws Exception {
mockMvc.perform(get("/api/security/csrf")).andExpect(status().isOk());
Expand Down