diff --git a/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java b/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java index 2b3f9695..19ee24db 100644 --- a/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java +++ b/src/main/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifier.java @@ -196,6 +196,12 @@ public Optional 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); } diff --git a/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java b/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java index eb6d2f0b..635fd5ef 100644 --- a/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java +++ b/src/test/java/com/williamcallahan/javachat/adapters/out/clerk/ClerkApiKeyVerifierTest.java @@ -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; @@ -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 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(); diff --git a/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java b/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java index e9eedf5c..c89231c4 100644 --- a/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/AuthenticatedUserEndpointSecurityIntegrationTest.java @@ -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; @@ -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());