diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java index 737c1b4a..c8b0b875 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java @@ -84,9 +84,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { JwtValidationResult result = jwtService.validateAccessToken(token); String subject = result.subject(); - // Token revocation check: reject tokens issued before revocation timestamp + // Token revocation check: reject tokens issued before revocation timestamp. + // JWT subject carries the "user:" prefix; users.user_id is the bare form + // (see CLAUDE.md "User ID conventions"). Strip before lookup. if (subject.startsWith("user:") && result.issuedAt() != null) { - userRepository.findById(subject).ifPresent(user -> { + String userId = subject.substring(5); + userRepository.findById(userId).ifPresent(user -> { Instant revoked = user.tokenRevokedBefore(); if (revoked != null && result.issuedAt().isBefore(revoked)) { serverMetrics.recordAuthFailure("revoked"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java new file mode 100644 index 00000000..675a63e3 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java @@ -0,0 +1,63 @@ +package com.cameleer.server.app.security; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.security.JwtService; +import com.cameleer.server.core.security.UserInfo; +import com.cameleer.server.core.security.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying that {@code users.token_revoked_before} is honoured + * by {@link JwtAuthenticationFilter}. Regression for the prefix-mismatch bug + * where the filter looked up the JWT subject ({@code user:alice}) against + * {@code users.user_id} (bare {@code alice}), so revocation never fired. + */ +class JwtRevocationIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private JwtService jwtService; + + @Autowired + private UserRepository userRepository; + + @Test + void revokedTokenIsRejectedOnAuthenticatedRequest() { + userRepository.upsert(new UserInfo( + "revoke-me", "local", "", "Revoke Me", Instant.now())); + String accessToken = jwtService.createAccessToken( + "user:revoke-me", "user", List.of("VIEWER")); + + ResponseEntity before = call(accessToken); + assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK); + + userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1)); + + ResponseEntity after = call(accessToken); + assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + private ResponseEntity call(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + return restTemplate.exchange( + "/api/v1/auth/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + } +}