fix(auth): strip user: prefix before token-revocation lookup
JwtAuthenticationFilter compared the JWT subject (user:alice) against users.user_id (bare alice), so token_revoked_before was never read for any user. Strips the prefix to match the convention documented in CLAUDE.md. Adds JwtRevocationIT as a regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<String> before = call(accessToken);
|
||||
assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1));
|
||||
|
||||
ResponseEntity<String> after = call(accessToken);
|
||||
assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> call(String accessToken) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(accessToken);
|
||||
return restTemplate.exchange(
|
||||
"/api/v1/auth/me",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
String.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user