Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ public ResponseEntity<Error> handleInvalidCredentials(InvalidCredentialsExceptio
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}

@ExceptionHandler(InvalidRefreshTokenException.class)
public ResponseEntity<Error> handleInvalidRefreshToken(InvalidRefreshTokenException ex) {
Error error = new Error();
error.setCode("INVALID_REFRESH_TOKEN");
error.setMessage(ex.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}

@ExceptionHandler(AccountLockedException.class)
public ResponseEntity<Error> handleAccountLocked(AccountLockedException ex) {
Error error = new Error();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.accountabilityatlas.userservice.exception;

public class InvalidRefreshTokenException extends RuntimeException {
public InvalidRefreshTokenException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.accountabilityatlas.userservice.domain.Session;
import com.accountabilityatlas.userservice.domain.User;
import com.accountabilityatlas.userservice.exception.InvalidCredentialsException;
import com.accountabilityatlas.userservice.exception.InvalidRefreshTokenException;
import com.accountabilityatlas.userservice.repository.SessionRepository;
import com.accountabilityatlas.userservice.repository.UserRepository;
import java.time.Instant;
Expand Down Expand Up @@ -68,4 +69,34 @@ public AuthResult login(String email, String password, String deviceInfo, String
public void logout(UUID sessionId) {
sessionRepository.revokeById(sessionId, Instant.now());
}

@Transactional
public AuthResult refresh(String refreshToken) {
String hash = tokenService.hashRefreshToken(refreshToken);
Instant now = Instant.now();

Session session =
sessionRepository
.findValidByRefreshTokenHash(hash, now)
.orElseThrow(
() -> new InvalidRefreshTokenException("Invalid or expired refresh token"));

User user =
userRepository
.findById(session.getUserId())
.orElseThrow(() -> new InvalidRefreshTokenException("User not found for session"));

// Rotate refresh token
String newRefreshToken = tokenService.generateRefreshToken();
String newRefreshTokenHash = tokenService.hashRefreshToken(newRefreshToken);
session.setRefreshTokenHash(newRefreshTokenHash);
session.setExpiresAt(now.plus(jwtProperties.getRefreshTokenExpiry()));
sessionRepository.save(session);

String accessToken =
tokenService.generateAccessToken(
user.getId(), user.getEmail(), user.getTrustTier(), session.getId());

return new AuthResult(user, accessToken, newRefreshToken);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.accountabilityatlas.userservice.web;

import com.accountabilityatlas.userservice.config.JwtAuthenticationFilter.JwtAuthenticationToken;
import com.accountabilityatlas.userservice.config.JwtProperties;
import com.accountabilityatlas.userservice.service.AuthResult;
import com.accountabilityatlas.userservice.service.AuthenticationService;
import com.accountabilityatlas.userservice.service.RegistrationService;
Expand Down Expand Up @@ -32,11 +33,15 @@ public class AuthController implements AuthenticationApi {

private final RegistrationService registrationService;
private final AuthenticationService authenticationService;
private final JwtProperties jwtProperties;

public AuthController(
RegistrationService registrationService, AuthenticationService authenticationService) {
RegistrationService registrationService,
AuthenticationService authenticationService,
JwtProperties jwtProperties) {
this.registrationService = registrationService;
this.authenticationService = authenticationService;
this.jwtProperties = jwtProperties;
}

@Override
Expand Down Expand Up @@ -80,7 +85,11 @@ public ResponseEntity<OAuthResponse> oauthLogin(

@Override
public ResponseEntity<RefreshResponse> refreshTokens(RefreshRequest refreshRequest) {
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
AuthResult result = authenticationService.refresh(refreshRequest.getRefreshToken());

RefreshResponse response = new RefreshResponse();
response.setTokens(toTokenPair(result.accessToken(), result.refreshToken()));
return ResponseEntity.ok(response);
}

@Override
Expand Down Expand Up @@ -117,7 +126,7 @@ private TokenPair toTokenPair(String accessToken, String refreshToken) {
TokenPair tokens = new TokenPair();
tokens.setAccessToken(accessToken);
tokens.setRefreshToken(refreshToken);
tokens.setExpiresIn(900);
tokens.setExpiresIn((int) jwtProperties.getAccessTokenExpiry().toSeconds());
tokens.setTokenType("Bearer");
return tokens;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ void handleInvalidCredentials_returns401WithInvalidCredentialsCode() {
assertThat(response.getBody().getMessage()).isEqualTo("Email or password is incorrect");
}

@Test
void handleInvalidRefreshToken_returns401WithInvalidRefreshTokenCode() {
// Arrange
InvalidRefreshTokenException ex =
new InvalidRefreshTokenException("Invalid or expired refresh token");

// Act
ResponseEntity<Error> response = handler.handleInvalidRefreshToken(ex);

// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getCode()).isEqualTo("INVALID_REFRESH_TOKEN");
assertThat(response.getBody().getMessage()).isEqualTo("Invalid or expired refresh token");
}

@Test
void handleAccountLocked_returns429WithAccountLockedCode() {
// Arrange
Expand Down Expand Up @@ -102,8 +118,8 @@ void handleValidation_returns400WithFieldError() throws NoSuchMethodException {
assertThat(response.getBody().getCode()).isEqualTo("VALIDATION_ERROR");
assertThat(response.getBody().getMessage()).isEqualTo("Request validation failed");
assertThat(response.getBody().getDetails()).hasSize(1);
assertThat(response.getBody().getDetails().get(0).getField()).isEqualTo("displayName");
assertThat(response.getBody().getDetails().get(0).getMessage())
assertThat(response.getBody().getDetails().getFirst().getField()).isEqualTo("displayName");
assertThat(response.getBody().getDetails().getFirst().getMessage())
.isEqualTo("size must be between 2 and 50");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,124 @@ void logout_withoutToken_returns401() throws Exception {
mockMvc.perform(post("/auth/logout")).andExpect(status().isUnauthorized());
}

@Test
void refresh_returnsNewTokenPair() throws Exception {
// Register to get initial tokens
MvcResult registerResult =
mockMvc
.perform(
post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"email": "refresh@example.com",
"password": "SecurePass123",
"displayName": "RefreshUser"
}
"""))
.andExpect(status().isCreated())
.andReturn();

String refreshToken =
JsonPath.read(registerResult.getResponse().getContentAsString(), "$.tokens.refreshToken");

// Refresh
MvcResult refreshResult =
mockMvc
.perform(
post("/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(
String.format(
"""
{"refreshToken": "%s"}
""",
refreshToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tokens.accessToken").exists())
.andExpect(jsonPath("$.tokens.refreshToken").exists())
.andExpect(jsonPath("$.tokens.expiresIn").value(900))
.andExpect(jsonPath("$.tokens.tokenType").value("Bearer"))
.andReturn();

// New access token should work for authenticated endpoints
String newAccessToken =
JsonPath.read(refreshResult.getResponse().getContentAsString(), "$.tokens.accessToken");
mockMvc
.perform(get("/users/me").header("Authorization", "Bearer " + newAccessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("refresh@example.com"));
}

@Test
void refresh_rotatesRefreshToken() throws Exception {
MvcResult registerResult =
mockMvc
.perform(
post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"email": "rotate@example.com",
"password": "SecurePass123",
"displayName": "RotateUser"
}
"""))
.andExpect(status().isCreated())
.andReturn();

String originalRefreshToken =
JsonPath.read(registerResult.getResponse().getContentAsString(), "$.tokens.refreshToken");

// First refresh succeeds
MvcResult refreshResult =
mockMvc
.perform(
post("/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(
String.format(
"""
{"refreshToken": "%s"}
""",
originalRefreshToken)))
.andExpect(status().isOk())
.andReturn();

String newRefreshToken =
JsonPath.read(refreshResult.getResponse().getContentAsString(), "$.tokens.refreshToken");
assertThat(newRefreshToken).isNotEqualTo(originalRefreshToken);

// Original refresh token no longer works (rotation)
mockMvc
.perform(
post("/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(
String.format(
"""
{"refreshToken": "%s"}
""",
originalRefreshToken)))
.andExpect(status().isUnauthorized());
}

@Test
void refresh_rejectsInvalidToken() throws Exception {
mockMvc
.perform(
post("/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"refreshToken": "completely-invalid-token"}
"""))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_REFRESH_TOKEN"));
}

@Test
void jwksEndpoint_returnsValidJwkSet() throws Exception {
mockMvc
Expand Down
Loading