Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,6 @@
import java.util.HexFormat;
import java.util.Set;

/**
* Enforces presence of {@code Idempotency-Key} header on state-mutating endpoints
* (POST, PATCH, DELETE) -- excluding auth, JWKS, and actuator endpoints.
* Uses INSERT-first reservation pattern to prevent TOCTOU races:
* 1. Try INSERT with status_code=0 (reservation)
* 2. If INSERT succeeds, proceed with request and UPDATE with real response
* 3. If INSERT fails (duplicate), re-read stored record for replay or conflict
*/
@Slf4j
@Component
@Order(2)
Expand Down Expand Up @@ -242,10 +234,6 @@ private String computeSha256(byte[] body) {

record IdempotencyRecord(String idempotencyKey, String requestHash, String responseBody, int statusCode) {}

/**
* Request wrapper that replays a cached body so downstream filters and
* controllers can read the request body after it has already been consumed.
*/
private static class CachedBodyRequestWrapper extends HttpServletRequestWrapper {

private final byte[] body;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,13 @@

import java.util.UUID;

/**
* Extracts the authenticated principal's merchant ID from the SecurityContext
* and enforces that it matches the target merchant ID.
* Used via {@code @PreAuthorize("@merchantScopeEnforcer.hasAccess(#merchantId)")}.
*/
@Component
@RequiredArgsConstructor
public class MerchantScopeEnforcer {

private final ApiKeyRepository apiKeyRepository;
private final AccessTokenRepository accessTokenRepository;

/**
* Checks whether the authenticated principal has access to the given merchant.
*
* @return true if the principal's merchant ID matches the target
* @throws MerchantAccessDeniedException if no merchant-scoped authentication is present or IDs don't match
*/
public boolean hasAccess(UUID targetMerchantId) {
var principalMerchantId = authenticatedMerchantId();
if (!principalMerchantId.equals(targetMerchantId)) {
Expand All @@ -38,37 +27,18 @@ public boolean hasAccess(UUID targetMerchantId) {
return true;
}

/**
* Checks whether the authenticated principal owns the API key.
*
* @return true if the key's merchant ID matches the principal's
* @throws ApiKeyNotFoundException if the key does not exist
* @throws MerchantAccessDeniedException if the principal does not own the key
*/
public boolean hasAccessToApiKey(UUID keyId) {
var apiKey = apiKeyRepository.findById(keyId)
.orElseThrow(() -> ApiKeyNotFoundException.byId(keyId));
return hasAccess(apiKey.getMerchantId());
}

/**
* Checks whether the authenticated principal owns the access token.
*
* @return true if the token's merchant ID matches the principal's
* @throws TokenRevokedException if the token does not exist
* @throws MerchantAccessDeniedException if the principal does not own the token
*/
public boolean hasAccessToToken(UUID jti) {
var token = accessTokenRepository.findByJti(jti)
.orElseThrow(() -> TokenRevokedException.of(jti));
return hasAccess(token.getMerchantId());
}

/**
* Returns the authenticated principal's merchant ID.
*
* @throws MerchantAccessDeniedException if no merchant-scoped authentication is present
*/
public UUID authenticatedMerchantId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
return extractMerchantId(auth);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@
import java.util.Objects;
import java.util.UUID;

/**
* Spring Security principal for S13-authenticated users accessing S10 gateway.
* Distinct from {@link MerchantAuthentication} which represents merchant/client-level auth.
*/
public class UserAuthentication extends AbstractAuthenticationToken {

private final UUID userId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,6 @@
import java.util.List;
import java.util.UUID;

/**
* Validates S13-issued user JWTs at the S10 gateway.
* Fetches S13's JWKS to verify ES256 signatures, then extracts user claims
* into a {@link UserAuthentication} principal.
*
* <p>Runs after the S10 merchant JWT filter. If the token's issuer doesn't match
* the S13 issuer, this filter passes through (the token may be an S10 merchant JWT
* that was already handled or will be rejected upstream).</p>
*/
@Slf4j
@RequiredArgsConstructor
public class UserJwtAuthenticationFilter extends OncePerRequestFilter {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
package com.stablecoin.payments.gateway.iam.domain.port;

import com.stablecoin.payments.gateway.iam.domain.exception.UserJwksUnavailableException;

/**
* Outbound port for fetching the JWKS (JSON Web Key Set) from the Merchant IAM service (S13).
* Infrastructure decides caching strategy and failover behaviour.
*/
public interface UserJwksProvider {

/**
* Returns the JWKS JSON from S13.
*
* @throws UserJwksUnavailableException if S13 is unreachable and no cached value is available
*/
String fetchJwks();
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ApiKeyAuthenticationFilterTest {
Expand Down Expand Up @@ -64,7 +63,7 @@ class WhenNoApiKeyHeader {
void shouldPassThroughWithoutHeader() throws ServletException, IOException {
filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
then(filterChain).should().doFilter(request, response);
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
}

Expand All @@ -74,8 +73,8 @@ void shouldPassThroughWithBlankHeader() throws ServletException, IOException {

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
verify(apiKeyService, never()).validate(anyString(), anyString());
then(filterChain).should().doFilter(request, response);
then(apiKeyService).shouldHaveNoInteractions();
}
}

Expand Down Expand Up @@ -106,11 +105,11 @@ void shouldAuthenticateWithValidKey() throws ServletException, IOException {
.version(0)
.build();

when(apiKeyService.validate(rawKey, "10.0.0.1")).thenReturn(apiKey);
given(apiKeyService.validate(rawKey, "10.0.0.1")).willReturn(apiKey);

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
then(filterChain).should().doFilter(request, response);
var auth = SecurityContextHolder.getContext().getAuthentication();
assertThat(auth).isInstanceOf(MerchantAuthentication.class);
var merchantAuth = (MerchantAuthentication) auth;
Expand All @@ -127,51 +126,51 @@ class WhenInvalidApiKey {
@Test
void shouldRejectNotFoundKey() throws ServletException, IOException {
request.addHeader("X-API-Key", "invalid_key");
when(apiKeyService.validate("invalid_key", "127.0.0.1"))
.thenThrow(ApiKeyNotFoundException.byHash());
given(apiKeyService.validate("invalid_key", "127.0.0.1"))
.willThrow(ApiKeyNotFoundException.byHash());

filter.doFilterInternal(request, response, filterChain);

verify(filterChain, never()).doFilter(request, response);
then(filterChain).should(never()).doFilter(request, response);
assertThat(response.getStatus()).isEqualTo(401);
}

@Test
void shouldRejectRevokedKey() throws ServletException, IOException {
var keyId = UUID.randomUUID();
request.addHeader("X-API-Key", "revoked_key");
when(apiKeyService.validate("revoked_key", "127.0.0.1"))
.thenThrow(ApiKeyRevokedException.of(keyId));
given(apiKeyService.validate("revoked_key", "127.0.0.1"))
.willThrow(ApiKeyRevokedException.of(keyId));

filter.doFilterInternal(request, response, filterChain);

verify(filterChain, never()).doFilter(request, response);
then(filterChain).should(never()).doFilter(request, response);
assertThat(response.getStatus()).isEqualTo(401);
}

@Test
void shouldRejectExpiredKey() throws ServletException, IOException {
var keyId = UUID.randomUUID();
request.addHeader("X-API-Key", "expired_key");
when(apiKeyService.validate("expired_key", "127.0.0.1"))
.thenThrow(ApiKeyExpiredException.of(keyId));
given(apiKeyService.validate("expired_key", "127.0.0.1"))
.willThrow(ApiKeyExpiredException.of(keyId));

filter.doFilterInternal(request, response, filterChain);

verify(filterChain, never()).doFilter(request, response);
then(filterChain).should(never()).doFilter(request, response);
assertThat(response.getStatus()).isEqualTo(401);
}

@Test
void shouldRejectDisallowedIp() throws ServletException, IOException {
request.addHeader("X-API-Key", "valid_key");
request.setRemoteAddr("192.168.1.1");
when(apiKeyService.validate("valid_key", "192.168.1.1"))
.thenThrow(IpNotAllowedException.of("192.168.1.1"));
given(apiKeyService.validate("valid_key", "192.168.1.1"))
.willThrow(IpNotAllowedException.of("192.168.1.1"));

filter.doFilterInternal(request, response, filterChain);

verify(filterChain, never()).doFilter(request, response);
then(filterChain).should(never()).doFilter(request, response);
assertThat(response.getStatus()).isEqualTo(401);
}
}
Expand All @@ -185,7 +184,7 @@ void shouldSkipWhenAlreadyAuthenticated() throws ServletException, IOException {

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
verify(apiKeyService, never()).validate(anyString(), anyString());
then(filterChain).should().doFilter(request, response);
then(apiKeyService).shouldHaveNoInteractions();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class AuditLogFilterTest {
Expand Down Expand Up @@ -58,14 +58,14 @@ void tearDown() {
void shouldAlwaysCallFilterChain() throws ServletException, IOException {
filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
then(filterChain).should().doFilter(request, response);
}

@Test
void shouldSkipAuditWhenNotAuthenticated() throws ServletException, IOException {
filter.doFilterInternal(request, response, filterChain);

verify(auditLogRepository, never()).save(any());
then(auditLogRepository).should(never()).save(any());
}

@Nested
Expand All @@ -88,7 +88,7 @@ void shouldPersistAuditLogEntry() throws ServletException, IOException {
filter.doFilterInternal(request, response, filterChain);

var captor = ArgumentCaptor.forClass(AuditLogEntry.class);
verify(auditLogRepository).save(captor.capture());
then(auditLogRepository).should().save(captor.capture());

var entry = captor.getValue();
assertThat(entry.getMerchantId()).isEqualTo(merchantId);
Expand All @@ -110,30 +110,30 @@ void shouldRecordJwtAuthMethod() throws ServletException, IOException {
filter.doFilterInternal(request, response, filterChain);

var captor = ArgumentCaptor.forClass(AuditLogEntry.class);
verify(auditLogRepository).save(captor.capture());
then(auditLogRepository).should().save(captor.capture());
assertThat(captor.getValue().getDetail()).containsEntry("auth_method", "JWT");
}

@Test
void shouldNotFailWhenRepositoryThrows() throws ServletException, IOException {
doThrow(new RuntimeException("DB down"))
.when(auditLogRepository).save(any());
willThrow(new RuntimeException("DB down"))
.given(auditLogRepository).save(any());

filter.doFilterInternal(request, response, filterChain);

verify(filterChain).doFilter(request, response);
then(filterChain).should().doFilter(request, response);
// No exception propagated — filter swallows it
}

@Test
void shouldAuditEvenWhenFilterChainThrows() throws ServletException, IOException {
doThrow(new ServletException("downstream error"))
.when(filterChain).doFilter(request, response);
willThrow(new ServletException("downstream error"))
.given(filterChain).doFilter(request, response);

assertThatThrownBy(() -> filter.doFilterInternal(request, response, filterChain))
.isInstanceOf(ServletException.class);

verify(auditLogRepository).save(any());
then(auditLogRepository).should().save(any());
}
}
}
Loading
Loading