RefreshTokenService.java

1
package com.rbctcsworld.ecommerce.auth;
2
3
import com.rbctcsworld.ecommerce.common.exception.InvalidRefreshTokenException;
4
import org.springframework.beans.factory.annotation.Value;
5
import org.springframework.stereotype.Service;
6
import org.springframework.transaction.annotation.Transactional;
7
8
import java.security.SecureRandom;
9
import java.time.Clock;
10
import java.time.Duration;
11
import java.time.LocalDateTime;
12
import java.util.Base64;
13
import java.util.UUID;
14
15
/**
16
 * Long-lived refresh tokens with rotation and reuse detection (OAuth 2.0 Security BCP).
17
 *
18
 *  - The token is 256 random bits (not a JWT): nothing to decode, nothing to forge.
19
 *  - Only its SHA-256 hash is stored.
20
 *  - Every refresh uses the token up and returns a new one (rotation). The tokens of one login form a family.
21
 *  - A used token that comes back was copied: the thief or the real user is replaying it. Both get logged
22
 *    out by revoking the whole family; the real user simply logs in again.
23
 *  - Grace period (as Okta/Auth0): a response can get lost - the page reloads or the tab closes while the
24
 *    refresh is on the way, and the browser still holds the spent token. Within GRACE seconds the spent
25
 *    token is accepted once more, but only while its successor is still unused and the session is alive.
26
 *    Two tabs refreshing at the same moment are covered by the same rule.
27
 *  - Logout revokes the family, so the refresh token is worthless afterwards (the access token still lives
28
 *    until it expires, at most 15 minutes - the reason it is kept short).
29
 */
30
@Service
31
public class RefreshTokenService {
32
33
    private static final SecureRandom RANDOM = new SecureRandom();
34
35
    private final RefreshTokenRepository repo;
36
    private final UserRepository users;
37
    private final Clock clock;
38
    private final Duration lifetime;
39
    private final Duration grace;
40
41
    public RefreshTokenService(RefreshTokenRepository repo, UserRepository users, Clock clock,
42
                               @Value("${app.jwt.refresh-days:14}") long refreshDays,
43
                               @Value("${app.jwt.refresh-grace-seconds:30}") long graceSeconds) {
44
        this.repo = repo;
45
        this.users = users;
46
        this.clock = clock;
47
        this.lifetime = Duration.ofDays(refreshDays);
48
        this.grace = Duration.ofSeconds(graceSeconds);
49
    }
50
51
    public record Rotated(AppUser user, String refreshToken) {
52
    }
53
54
    /** New login: a new family. Expired tokens of the user are cleaned up on the way. */
55
    @Transactional
56
    public String issue(AppUser user) {
57
        LocalDateTime now = LocalDateTime.now(clock);
58
        repo.deleteExpired(user.getId(), now);
59 1 1. issue : replaced return value with "" for com/rbctcsworld/ecommerce/auth/RefreshTokenService::issue → KILLED
        return create(user.getId(), UUID.randomUUID().toString(), now);
60
    }
61
62
    /**
63
     * Uses the token up and returns its successor. noRollbackFor: the family revocation on reuse must be
64
     * committed even though the call ends with 401.
65
     */
66
    @Transactional(noRollbackFor = InvalidRefreshTokenException.class)
67
    public Rotated rotate(String rawToken) {
68
        RefreshToken token = find(rawToken);
69
        LocalDateTime now = LocalDateTime.now(clock);
70 1 1. rotate : removed conditional - replaced equality check with false → KILLED
        if (!now.isBefore(token.getExpiresAt())) {
71
            throw new InvalidRefreshTokenException();
72
        }
73 2 1. rotate : removed conditional - replaced equality check with false → KILLED
2. rotate : removed conditional - replaced equality check with false → KILLED
        if (token.getRevokedAt() == null && repo.revokeIfActive(token.getId(), now) == 1) {
74 1 1. rotate : replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::rotate → KILLED
            return successor(token, now);                            // the normal case
75
        }
76
        // spent - earlier, or a moment ago by a parallel request (then read its new state)
77 1 1. rotate : removed conditional - replaced equality check with false → KILLED
        RefreshToken spent = token.getRevokedAt() != null ? token : find(rawToken);
78 1 1. rotate : removed conditional - replaced equality check with false → KILLED
        if (lostResponse(spent, now)) {
79 1 1. rotate : replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::rotate → KILLED
            return successor(spent, now);
80
        }
81
        repo.revokeFamily(spent.getFamilyId(), now);                 // replay of a used token: end the session
82
        throw new InvalidRefreshTokenException();
83
    }
84
85
    /** Spent within the grace period, its successor unused, the session still alive. */
86
    private boolean lostResponse(RefreshToken spent, LocalDateTime now) {
87
        LocalDateTime revokedAt = spent.getRevokedAt();
88 2 1. lostResponse : replaced boolean return with true for com/rbctcsworld/ecommerce/auth/RefreshTokenService::lostResponse → KILLED
2. lostResponse : removed conditional - replaced equality check with false → KILLED
        return revokedAt != null
89 1 1. lostResponse : removed conditional - replaced equality check with false → KILLED
                && now.isBefore(revokedAt.plus(grace))
90 1 1. lostResponse : removed conditional - replaced equality check with false → KILLED
                && !repo.existsByFamilyIdAndRevokedAtAfter(spent.getFamilyId(), revokedAt)
91 1 1. lostResponse : removed conditional - replaced equality check with false → KILLED
                && repo.existsByFamilyIdAndRevokedAtIsNull(spent.getFamilyId());
92
    }
93
94
    private Rotated successor(RefreshToken token, LocalDateTime now) {
95
        AppUser user = users.findById(token.getUserId()).orElseThrow(InvalidRefreshTokenException::new);
96 1 1. successor : replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::successor → KILLED
        return new Rotated(user, create(token.getUserId(), token.getFamilyId(), now));
97
    }
98
99
    /** Logout. Unknown or empty tokens are ignored: logging out twice is not an error. */
100
    @Transactional
101
    public void revoke(String rawToken) {
102 2 1. revoke : removed conditional - replaced equality check with false → KILLED
2. revoke : removed conditional - replaced equality check with false → KILLED
        if (rawToken == null || rawToken.isBlank()) return;
103
        repo.findByTokenHash(hash(rawToken))
104 1 1. revoke : removed call to java/util/Optional::ifPresent → KILLED
                .ifPresent(t -> repo.revokeFamily(t.getFamilyId(), LocalDateTime.now(clock)));
105
    }
106
107
    public long lifetimeSeconds() {
108 1 1. lifetimeSeconds : replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RefreshTokenService::lifetimeSeconds → KILLED
        return lifetime.toSeconds();
109
    }
110
111
    private RefreshToken find(String rawToken) {
112 2 1. find : removed conditional - replaced equality check with false → KILLED
2. find : removed conditional - replaced equality check with false → KILLED
        if (rawToken == null || rawToken.isBlank()) throw new InvalidRefreshTokenException();
113 1 1. find : replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::find → KILLED
        return repo.findByTokenHash(hash(rawToken)).orElseThrow(InvalidRefreshTokenException::new);
114
    }
115
116
    private String create(Long userId, String familyId, LocalDateTime now) {
117
        byte[] bytes = new byte[32];
118 1 1. create : removed call to java/security/SecureRandom::nextBytes → KILLED
        RANDOM.nextBytes(bytes);
119
        String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
120
        repo.save(new RefreshToken(userId, hash(raw), familyId, now, now.plus(lifetime)));
121 1 1. create : replaced return value with "" for com/rbctcsworld/ecommerce/auth/RefreshTokenService::create → KILLED
        return raw;
122
    }
123
124
    static String hash(String rawToken) {
125 1 1. hash : replaced return value with "" for com/rbctcsworld/ecommerce/auth/RefreshTokenService::hash → KILLED
        return Hashes.sha256Hex(rawToken);
126
    }
127
}

Mutations

59

1.1
Location : issue
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:issueCreatesANewFamilyStoresOnlyTheHashAndCleansUpExpiredTokens()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/RefreshTokenService::issue → KILLED

70

1.1
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:expiredTokenIsRejectedExactlyAtItsExpiryTime()]
removed conditional - replaced equality check with false → KILLED

73

1.1
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:deletedUserCannotRefresh()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:deletedUserCannotRefresh()]
removed conditional - replaced equality check with false → KILLED

74

1.1
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:tokenOneSecondBeforeExpiryStillWorks()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::rotate → KILLED

77

1.1
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:reusedTokenRevokesTheWholeFamily()]
removed conditional - replaced equality check with false → KILLED

78

1.1
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:twoParallelRefreshesBothSucceed()]
removed conditional - replaced equality check with false → KILLED

79

1.1
Location : rotate
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:twoParallelRefreshesBothSucceed()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::rotate → KILLED

88

1.1
Location : lostResponse
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:successorAlreadyUsedMeansTheftEvenWithinTheGracePeriod()]
replaced boolean return with true for com/rbctcsworld/ecommerce/auth/RefreshTokenService::lostResponse → KILLED

2.2
Location : lostResponse
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:successorAlreadyUsedMeansTheftEvenWithinTheGracePeriod()]
removed conditional - replaced equality check with false → KILLED

89

1.1
Location : lostResponse
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:successorAlreadyUsedMeansTheftEvenWithinTheGracePeriod()]
removed conditional - replaced equality check with false → KILLED

90

1.1
Location : lostResponse
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:parallelRefreshAgainstALogoutFails()]
removed conditional - replaced equality check with false → KILLED

91

1.1
Location : lostResponse
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:twoParallelRefreshesBothSucceed()]
removed conditional - replaced equality check with false → KILLED

96

1.1
Location : successor
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:tokenOneSecondBeforeExpiryStillWorks()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::successor → KILLED

102

1.1
Location : revoke
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:logoutWithAnUnknownTokenDoesNothing()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : revoke
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[test-template:logoutWithoutATokenIsANoOp(java.lang.String)]/[test-template-invocation:#3]
removed conditional - replaced equality check with false → KILLED

104

1.1
Location : revoke
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:logoutRevokesTheFamily()]
removed call to java/util/Optional::ifPresent → KILLED

108

1.1
Location : lifetimeSeconds
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:lifetimeIsFourteenDaysInSeconds()]
replaced long return with 0 for com/rbctcsworld/ecommerce/auth/RefreshTokenService::lifetimeSeconds → KILLED

112

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[test-template:blankTokenIsRejectedWithoutADatabaseLookup(java.lang.String)]/[test-template-invocation:#2]
removed conditional - replaced equality check with false → KILLED

2.2
Location : find
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:unknownTokenIsRejected()]
removed conditional - replaced equality check with false → KILLED

113

1.1
Location : find
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:successorAlreadyUsedMeansTheftEvenWithinTheGracePeriod()]
replaced return value with null for com/rbctcsworld/ecommerce/auth/RefreshTokenService::find → KILLED

118

1.1
Location : create
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:everyLoginGetsADifferentTokenAndFamily()]
removed call to java/security/SecureRandom::nextBytes → KILLED

121

1.1
Location : create
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:issueCreatesANewFamilyStoresOnlyTheHashAndCleansUpExpiredTokens()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/RefreshTokenService::create → KILLED

125

1.1
Location : hash
Killed by : com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.auth.RefreshTokenServiceTest]/[method:hashIsSha256Hex()]
replaced return value with "" for com/rbctcsworld/ecommerce/auth/RefreshTokenService::hash → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support