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:
hsiegeln
2026-04-27 09:11:55 +02:00
parent 6e4977ea3b
commit 7066795c3c
2 changed files with 68 additions and 2 deletions

View File

@@ -84,9 +84,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
JwtValidationResult result = jwtService.validateAccessToken(token); JwtValidationResult result = jwtService.validateAccessToken(token);
String subject = result.subject(); 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) { 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(); Instant revoked = user.tokenRevokedBefore();
if (revoked != null && result.issuedAt().isBefore(revoked)) { if (revoked != null && result.issuedAt().isBefore(revoked)) {
serverMetrics.recordAuthFailure("revoked"); serverMetrics.recordAuthFailure("revoked");

View File

@@ -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);
}
}