WIP: feat(security): implement refresh tokens #49
@@ -186,12 +186,9 @@ class TokenServiceTest {
|
||||
|
||||
@Test
|
||||
void revokedFamily_throwsNotAuthenticated() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
tokenFamily.setRevokedAt(Instant.now(clock));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
|
||||
@@ -208,14 +205,8 @@ class TokenServiceTest {
|
||||
// Happy Path
|
||||
@Test
|
||||
void unusedToken_returnsUserIdAndRefreshToken() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
|
||||
when(refreshTokenRepository.findByHash(any()))
|
||||
.thenReturn(Optional.of(refreshToken));
|
||||
@@ -228,14 +219,8 @@ class TokenServiceTest {
|
||||
|
||||
@Test
|
||||
void unsedToken_savesRefreshToken() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
|
||||
when(refreshTokenRepository.findByHash(any()))
|
||||
.thenReturn(Optional.of(refreshToken));
|
||||
@@ -257,14 +242,8 @@ class TokenServiceTest {
|
||||
|
||||
@Test
|
||||
void ununsedToken_savesOldTokenWithUsedAt() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
|
||||
when(refreshTokenRepository.findByHash(any()))
|
||||
.thenReturn(Optional.of(refreshToken));
|
||||
@@ -283,14 +262,8 @@ class TokenServiceTest {
|
||||
|
||||
@Test
|
||||
void unusedToken_doesNotRevokeFamily() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
|
||||
when(refreshTokenRepository.findByHash(any()))
|
||||
.thenReturn(Optional.of(refreshToken));
|
||||
@@ -307,15 +280,9 @@ class TokenServiceTest {
|
||||
|
||||
@Test
|
||||
void unusedToken_newTokenIsDifferentFromOldToken() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setTokenHash(rawToken.getBytes());
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
|
||||
|
||||
when(refreshTokenRepository.findByHash(any()))
|
||||
.thenReturn(Optional.of(refreshToken));
|
||||
@@ -329,15 +296,9 @@ class TokenServiceTest {
|
||||
void usedWithinGrace_returnsNewToken() {
|
||||
Instant usedAt = Instant.now(clock);
|
||||
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(usedAt.plusSeconds(10));
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setTokenHash(rawToken.getBytes());
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(usedAt.plusSeconds(10));
|
||||
refreshToken.setUsedAt(usedAt);
|
||||
|
||||
when(refreshTokenRepository.findByHash(any()))
|
||||
@@ -362,5 +323,215 @@ class TokenServiceTest {
|
||||
assertSame(tokenFamily, newToken.getTokenFamily());
|
||||
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usedAtExactGraceBoundary_returnsNewToken() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(authProperties.expiration().gracePeriod());
|
||||
|
||||
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||
|
||||
assertEquals(1L, rotation.userId());
|
||||
assertNull(tokenFamily.getRevokedAt());
|
||||
}
|
||||
|
||||
// Reuse after grace period
|
||||
@Test
|
||||
void usedAfterGrace_throwsNotAuthenticated() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(6));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usedAfterGrace_revokesFamily() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(6));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
verify(tokenFamilyRepository).save(familyCaptor.capture());
|
||||
assertSame(tokenFamily, familyCaptor.getValue());
|
||||
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usedAfterGrace_doesNotIssueNewToken() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(6));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
verify(refreshTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usedAfterGrace_subsequentRotateRejectedWithoutResaving() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(6));
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
verify(tokenFamilyRepository, times(1)).save(any());
|
||||
}
|
||||
|
||||
// Expiry
|
||||
@Test
|
||||
void expiredToken_throwsNotAuthenticated() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setExpiresAt(clock.instant().minusSeconds(1));
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredFamily_throwsNotAuthenticated() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
tokenFamily.setExpiresAt(clock.instant().minusSeconds(1));
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredToken_doesNotSaveOrRevoke() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(11));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
verify(refreshTokenRepository, never()).save(any());
|
||||
verify(tokenFamilyRepository, never()).save(any());
|
||||
assertNull(refreshToken.getUsedAt());
|
||||
assertNull(tokenFamily.getRevokedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenAtExactExpiry_returnsNewToken() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(10));
|
||||
|
||||
TokenService.Rotation rotation = tokenService.rotate(rawToken);
|
||||
|
||||
assertEquals(1L, rotation.userId());
|
||||
}
|
||||
|
||||
// Ordering
|
||||
@Test
|
||||
void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
Instant revokedAt = clock.instant();
|
||||
tokenFamily.setRevokedAt(revokedAt);
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(6));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
assertEquals(revokedAt, tokenFamily.getRevokedAt());
|
||||
verify(tokenFamilyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokedAndExpiredFamily_throwsNotAuthenticated() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
tokenFamily.setRevokedAt(clock.instant());
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(11));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredAndUsedAfterGrace_revokesFamily() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(11));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
|
||||
verify(tokenFamilyRepository).save(tokenFamily);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredWithinGrace_doesNotRevokeFamily() {
|
||||
TokenFamily tokenFamily = activeFamily();
|
||||
RefreshToken refreshToken = tokenIn(tokenFamily);
|
||||
refreshToken.setExpiresAt(clock.instant().plusSeconds(2));
|
||||
refreshToken.setUsedAt(clock.instant());
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
|
||||
|
||||
clock.advance(Duration.ofSeconds(3));
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
assertNull(tokenFamily.getRevokedAt());
|
||||
verify(tokenFamilyRepository, never()).save(any());
|
||||
verify(refreshTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownToken_doesNotTouchRepositoriesFurther() {
|
||||
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
|
||||
|
||||
verify(refreshTokenRepository, never()).save(any());
|
||||
verifyNoInteractions(tokenFamilyRepository);
|
||||
}
|
||||
|
||||
private TokenFamily activeFamily() {
|
||||
TokenFamily tokenFamily = new TokenFamily();
|
||||
tokenFamily.setId(1L);
|
||||
tokenFamily.setUser(createUser(1L, "user", "user"));
|
||||
tokenFamily.setExpiresAt(clock.instant().plusSeconds(10));
|
||||
return tokenFamily;
|
||||
}
|
||||
|
||||
private RefreshToken tokenIn(TokenFamily tokenFamily) {
|
||||
RefreshToken refreshToken = new RefreshToken();
|
||||
refreshToken.setTokenFamily(tokenFamily);
|
||||
refreshToken.setExpiresAt(clock.instant().plusSeconds(10));
|
||||
return refreshToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user