From 0a16092b3dfb770c6e7ed4fb1485ccc48fd9cf00 Mon Sep 17 00:00:00 2001 From: Abed Date: Mon, 5 May 2025 02:35:20 +0200 Subject: [PATCH 1/2] Feat#1 creating auth-service, with JUnit test --- pom.xml | 88 +++++++++ .../authservice/AuthServiceApplication.java | 12 ++ .../authservice/config/WebClientConfig.java | 42 ++++ .../controller/AuthController.java | 95 +++++++++ .../authservice/dto/LoginRequest.java | 20 ++ .../authservice/dto/LogoutRequest.java | 15 ++ .../authservice/dto/TokenResponse.java | 23 +++ .../authservice/dto/VerificationResponse.java | 36 ++++ .../exception/AuthenticationException.java | 13 ++ .../exception/GlobalExceptionHandler.java | 64 +++++++ .../exception/LogoutException.java | 13 ++ .../exception/TokenVerificationException.java | 13 ++ .../service/IdentityManagerService.java | 41 ++++ .../impl/KeycloakIdentityManagerService.java | 149 ++++++++++++++ src/main/resources/application.yml | 31 +++ .../authservice/AuthApplicationTests.java | 181 ++++++++++++++++++ 16 files changed, 836 insertions(+) create mode 100644 pom.xml create mode 100644 src/main/java/com/cleverthis/authservice/AuthServiceApplication.java create mode 100644 src/main/java/com/cleverthis/authservice/config/WebClientConfig.java create mode 100644 src/main/java/com/cleverthis/authservice/controller/AuthController.java create mode 100644 src/main/java/com/cleverthis/authservice/dto/LoginRequest.java create mode 100644 src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java create mode 100644 src/main/java/com/cleverthis/authservice/dto/TokenResponse.java create mode 100644 src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java create mode 100644 src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java create mode 100644 src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/cleverthis/authservice/exception/LogoutException.java create mode 100644 src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java create mode 100644 src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java create mode 100644 src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/cleverthis/authservice/AuthApplicationTests.java diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..064cd5c --- /dev/null +++ b/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + com.cleverthis + auth-service + 0.0.1-SNAPSHOT + auth-service + User management project for CleverThis + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webflux + + + org.projectlombok + lombok + true + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java b/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java new file mode 100644 index 0000000..ecf71ca --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java @@ -0,0 +1,12 @@ +package com.cleverthis.authservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AuthServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthServiceApplication.class, args); + } +} diff --git a/src/main/java/com/cleverthis/authservice/config/WebClientConfig.java b/src/main/java/com/cleverthis/authservice/config/WebClientConfig.java new file mode 100644 index 0000000..5a7cfc9 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/config/WebClientConfig.java @@ -0,0 +1,42 @@ +package com.cleverthis.authservice.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * Configuration class for creating and configuring the WebClient bean + * used for making HTTP requests to Keycloak. + */ +@Configuration +public class WebClientConfig { + + // Timeout values in milliseconds + private static final int CONNECT_TIMEOUT = 5000; // 5 seconds + private static final int READ_TIMEOUT = 10000; // 10 seconds + private static final int WRITE_TIMEOUT = 10000; // 10 seconds + + @Bean + public WebClient keycloakWebClient() { + // Configure HttpClient with timeouts + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT) + .responseTimeout(Duration.ofMillis(READ_TIMEOUT)) // Max time to wait for response + .doOnConnected(conn -> + conn.addHandlerLast(new ReadTimeoutHandler(READ_TIMEOUT, TimeUnit.MILLISECONDS)) + .addHandlerLast(new WriteTimeoutHandler(WRITE_TIMEOUT, TimeUnit.MILLISECONDS))); + + // Build WebClient with the configured HttpClient + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/AuthController.java b/src/main/java/com/cleverthis/authservice/controller/AuthController.java new file mode 100644 index 0000000..8c6cd33 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java @@ -0,0 +1,95 @@ +package com.cleverthis.authservice.controller; + +import com.cleverthis.authservice.dto.LoginRequest; +import com.cleverthis.authservice.dto.LogoutRequest; +import com.cleverthis.authservice.dto.TokenResponse; +import com.cleverthis.authservice.dto.VerificationResponse; +import com.cleverthis.authservice.service.IdentityManagerService; +// Import custom exceptions if you want specific handling in onErrorResume, +// otherwise rely on GlobalExceptionHandler + +import com.cleverthis.authservice.exception.AuthenticationException; +import com.cleverthis.authservice.exception.TokenVerificationException; +import com.cleverthis.authservice.exception.LogoutException; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +/** + * REST Controller for handling authentication related requests. + * Delegates the actual identity provider interaction to the IdentityManagerService. + */ +@RestController +@RequestMapping("/") // Base path for auth endpoints +@Slf4j +public class AuthController { + + private final IdentityManagerService identityManagerService; + + @Autowired + public AuthController(IdentityManagerService identityManagerService) { + this.identityManagerService = identityManagerService; + } + + /** + * Handles user login requests using username and password. + * + * @param loginRequest DTO containing username and password. + * @return ResponseEntity containing the TokenResponse on success, or an error status handled globally. + */ + @PostMapping("/login") + public Mono> login(@Valid @RequestBody LoginRequest loginRequest) { + log.info("Received login request for user: {}", loginRequest.getUsername()); + return identityManagerService.login(loginRequest.getUsername(), loginRequest.getPassword()) + .map(ResponseEntity::ok) // Map successful response to ResponseEntity + .doOnError(e -> log.error("Login failed for user {}: {}", loginRequest.getUsername(), e.getMessage())); + // Let GlobalExceptionHandler handle mapping AuthenticationException to 401 + // Removed .cast() - Rely on GlobalExceptionHandler and type inference + } + + /** + * Handles token verification requests. + * Expects the access token in the Authorization header (Bearer scheme). + * + * @param authorizationHeader The full Authorization header value (e.g., "Bearer "). + * @return ResponseEntity containing the VerificationResponse on success, or an error status handled globally. + */ + @PostMapping("/verify") + public Mono> verify(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) { + log.info("Received token verification request"); + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + log.warn("Verification request missing or invalid Bearer token format."); + // Return 401 directly for invalid header format + return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } + String accessToken = authorizationHeader.substring(7); // Extract token after "Bearer " + + return identityManagerService.verifyToken(accessToken) + .map(ResponseEntity::ok) + .doOnError(e -> log.error("Token verification failed: {}", e.getMessage())); + // Let GlobalExceptionHandler handle mapping TokenVerificationException to 401 + // Removed .cast() + } + + /** + * Handles user logout requests using a refresh token. + * + * @param logoutRequest DTO containing the refresh token. + * @return ResponseEntity with 204 No Content on success, or an error status handled globally. + */ + @PostMapping("/logout") + public Mono> logout(@Valid @RequestBody LogoutRequest logoutRequest) { + log.info("Received logout request"); + return identityManagerService.logout(logoutRequest.getRefreshToken()) + .thenReturn(ResponseEntity.noContent().build()) // Return 204 on success, ensure type is Void + .doOnError(e -> log.error("Logout failed: {}", e.getMessage())); + // Let GlobalExceptionHandler handle mapping LogoutException to appropriate status + // Removed .cast() + } +} diff --git a/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java b/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java new file mode 100644 index 0000000..5d53f52 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java @@ -0,0 +1,20 @@ +package com.cleverthis.authservice.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * DTO for the /login request body. + */ +@Data // Lombok annotation for getters, setters, toString, equals, hashCode +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + @NotBlank(message = "Username cannot be blank") + private String username; + + @NotBlank(message = "Password cannot be blank") + private String password; +} \ No newline at end of file diff --git a/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java b/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java new file mode 100644 index 0000000..7d3805f --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java @@ -0,0 +1,15 @@ +package com.cleverthis.authservice.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; /** + * DTO for the /logout request body. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LogoutRequest { + @NotBlank(message = "Refresh token cannot be blank") + private String refreshToken; +} diff --git a/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java b/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java new file mode 100644 index 0000000..6db545b --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java @@ -0,0 +1,23 @@ +package com.cleverthis.authservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; /** + * DTO representing a successful token response from Keycloak (or similar). + * Contains the fields typically returned by an OAuth2 token endpoint. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TokenResponse { + // Using snake_case matching the typical Keycloak response JSON fields + private String access_token; + private int expires_in; + private int refresh_expires_in; + private String refresh_token; + private String token_type; + private String id_token; // Optional, depending on scope + private int not_before_policy; // Keycloak specific field + private String session_state; // Keycloak specific field + private String scope; +} diff --git a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java new file mode 100644 index 0000000..3577503 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java @@ -0,0 +1,36 @@ +package com.cleverthis.authservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO representing the response from Keycloak's token introspection endpoint. + * Also serves as the response for our /verify endpoint and basis for /auth headers. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class VerificationResponse { + private boolean active; // Standard introspection field: is the token active? + private String client_id; + private String username; // Often same as preferred_username + private String scope; + private String sub; // Subject (user ID) - Use this for X-User-Id + private long exp; // Expiration timestamp + private long iat; // Issued at timestamp + private long nbf; // Not before timestamp + private String aud; // Audience + private String iss; // Issuer + private String typ; // Type (e.g., Bearer) + private String email; // Standard OIDC claim + private Boolean email_verified; // Standard OIDC claim + private String preferred_username; // Standard OIDC claim - Use this for X-User-Name + + // --- Added for Metadata Headers --- + // Keycloak needs mappers configured for the client scope to include these in introspection +// private List groups; // Example: groups claim mapped from user group memberships + // You might prefer more structured role info if available + // private Map realm_access; + // private Map resource_access; +} diff --git a/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java b/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java new file mode 100644 index 0000000..7320d99 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java @@ -0,0 +1,13 @@ +package com.cleverthis.authservice.exception; + +/** + * Custom exception for authentication failures. + */ +public class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { + super(message); + } + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..72557b8 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,64 @@ +package com.cleverthis.authservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; /** + * Global Exception Handler to translate custom exceptions into appropriate + * HTTP responses using Problem Details (RFC 7807). + */ +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler(AuthenticationException.class) + public ProblemDetail handleAuthenticationException(AuthenticationException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + problemDetail.setTitle("Authentication Failed"); + // Add more details if needed + // problemDetail.setProperty("details", "Check username/password or Keycloak status."); + return problemDetail; + } + + @ExceptionHandler(TokenVerificationException.class) + public ProblemDetail handleTokenVerificationException(TokenVerificationException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + problemDetail.setTitle("Token Verification Failed"); + return problemDetail; + } + + @ExceptionHandler(LogoutException.class) + public ProblemDetail handleLogoutException(LogoutException ex) { + // Depending on the cause, might return 400 or 500 + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + problemDetail.setTitle("Logout Failed"); + return problemDetail; + } + +// // Handle validation errors from @Valid annotations +// @ExceptionHandler(WebExchangeBindException.class) +// public ProblemDetail handleValidationExceptions(WebExchangeBindException ex) { +// ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); +// problemDetail.setTitle("Bad Request"); +// // Optionally add details about specific field errors +// // Map errors = new HashMap<>(); +// // ex.getBindingResult().getAllErrors().forEach((error) -> { +// // String fieldName = ((FieldError) error).getField(); +// // String errorMessage = error.getDefaultMessage(); +// // errors.put(fieldName, errorMessage); +// // }); +// // problemDetail.setProperty("errors", errors); +// return problemDetail; +// } + + + // Catch-all for other unexpected exceptions + @ExceptionHandler(Exception.class) + public ProblemDetail handleGenericException(Exception ex) { + logger.error("An unexpected error occurred: ", ex); // Log the full stack trace + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected internal error occurred."); + problemDetail.setTitle("Internal Server Error"); + return problemDetail; + } +} diff --git a/src/main/java/com/cleverthis/authservice/exception/LogoutException.java b/src/main/java/com/cleverthis/authservice/exception/LogoutException.java new file mode 100644 index 0000000..07365a0 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/LogoutException.java @@ -0,0 +1,13 @@ +package com.cleverthis.authservice.exception; + +/** + * Custom exception for logout failures. + */ +public class LogoutException extends RuntimeException { + public LogoutException(String message) { + super(message); + } + public LogoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java b/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java new file mode 100644 index 0000000..995a217 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java @@ -0,0 +1,13 @@ +package com.cleverthis.authservice.exception; + +/** + * Custom exception for token verification failures. + */ +public class TokenVerificationException extends RuntimeException { + public TokenVerificationException(String message) { + super(message); + } + public TokenVerificationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java new file mode 100644 index 0000000..22ad08a --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java @@ -0,0 +1,41 @@ +package com.cleverthis.authservice.service; + +import com.cleverthis.authservice.dto.TokenResponse; +import com.cleverthis.authservice.dto.VerificationResponse; +import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient + +/** + * Interface defining operations for interacting with an identity provider. + * This abstraction allows swapping the underlying implementation (e.g., Keycloak, Okta) + * without changing the controller logic. + */ +public interface IdentityManagerService { + + /** + * Authenticates a user using username and password (Resource Owner Password Credentials Grant). + * + * @param username The user's username. + * @param password The user's password. + * @return A Mono emitting the TokenResponse upon successful authentication. + * Emits an error (e.g., AuthenticationException) if authentication fails. + */ + Mono login(String username, String password); + + /** + * Verifies the validity of an access token and retrieves associated user information. + * + * @param accessToken The access token (without "Bearer " prefix). + * @return A Mono emitting the VerificationResponse containing token details and user info. + * Emits an error if the token is invalid, expired, or introspection fails. + */ + Mono verifyToken(String accessToken); + + /** + * Logs out a user session by revoking the refresh token. + * + * @param refreshToken The refresh token associated with the user session. + * @return A Mono completing successfully upon successful logout/revocation. + * Emits an error if revocation fails. + */ + Mono logout(String refreshToken); +} diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java new file mode 100644 index 0000000..78944eb --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java @@ -0,0 +1,149 @@ +package com.cleverthis.authservice.service.impl; + +import com.cleverthis.authservice.dto.TokenResponse; +import com.cleverthis.authservice.dto.VerificationResponse; +import com.cleverthis.authservice.exception.AuthenticationException; +import com.cleverthis.authservice.exception.TokenVerificationException; +import com.cleverthis.authservice.exception.LogoutException; +import com.cleverthis.authservice.service.IdentityManagerService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * Keycloak-specific implementation of the IdentityManagerService. + * Uses WebClient to interact with Keycloak's REST API endpoints. + */ +@Service +@Slf4j // Lombok annotation for logging +public class KeycloakIdentityManagerService implements IdentityManagerService { + + private final WebClient webClient; + + // Keycloak configuration values injected from application.yml + @Value("${keycloak.server-url}") + private String keycloakServerUrl; + @Value("${keycloak.realm}") + private String realm; + @Value("${keycloak.client-id}") + private String clientId; + @Value("${keycloak.client-secret}") + private String clientSecret; + @Value("${keycloak.grant-type}") + private String grantType; // Should be 'password' for ROPC + + // Construct Keycloak endpoint URLs + private String getTokenEndpoint() { + return String.format("%s/realms/%s/protocol/openid-connect/token", keycloakServerUrl, realm); + } + + private String getIntrospectionEndpoint() { + return String.format("%s/realms/%s/protocol/openid-connect/token/introspect", keycloakServerUrl, realm); + } + + private String getRevocationEndpoint() { + return String.format("%s/realms/%s/protocol/openid-connect/revoke", keycloakServerUrl, realm); + } + + @Autowired + public KeycloakIdentityManagerService(WebClient keycloakWebClient) { + this.webClient = keycloakWebClient; + } + + @Override + public Mono login(String username, String password) { + log.debug("Attempting login for user: {}", username); + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("grant_type", grantType); // Use configured grant type (password) + formData.add("username", username); + formData.add("password", password); + formData.add("scope", "openid email profile"); // Request standard scopes + + return webClient.post() + .uri(getTokenEndpoint()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + // Handle specific HTTP status codes + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("Keycloak login failed with status {}: {}", clientResponse.statusCode(), errorBody); + return Mono.error(new AuthenticationException("Login failed: Invalid credentials or Keycloak error. Status: " + clientResponse.statusCode())); + })) + .bodyToMono(TokenResponse.class) + .doOnSuccess(response -> log.debug("Login successful for user: {}", username)) + .doOnError(error -> log.error("Error during login for user {}: {}", username, error.getMessage())); + } + + @Override + public Mono verifyToken(String accessToken) { + log.debug("Verifying access token"); + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("token", accessToken); + + return webClient.post() + .uri(getIntrospectionEndpoint()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("Keycloak token introspection failed with status {}: {}", clientResponse.statusCode(), errorBody); + return Mono.error(new TokenVerificationException("Token introspection failed. Status: " + clientResponse.statusCode())); + })) + .bodyToMono(VerificationResponse.class) + .flatMap(response -> { + if (!response.isActive()) { + log.warn("Token verification failed: Token is inactive."); + return Mono.error(new TokenVerificationException("Token is invalid or expired.")); + } + log.debug("Token verification successful. User: {}", response.getUsername()); + return Mono.just(response); + }) + .doOnError(error -> log.error("Error during token verification: {}", error.getMessage())); + } + + @Override + public Mono logout(String refreshToken) { + log.debug("Attempting logout (token revocation)"); + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("token", refreshToken); + formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak + + return webClient.post() + .uri(getRevocationEndpoint()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + // Keycloak might return 400 if token is already invalid, which is okay for logout + if(clientResponse.statusCode() == HttpStatus.BAD_REQUEST) { + log.warn("Token revocation returned 400 (possibly already invalid/revoked): {}", errorBody); + return Mono.empty(); // Treat as success in logout scenario + } + log.error("Keycloak token revocation failed with status {}: {}", clientResponse.statusCode(), errorBody); + return Mono.error(new LogoutException("Logout failed. Status: " + clientResponse.statusCode())); + })) + .bodyToMono(Void.class) // Expecting empty body on success (2xx) + .doOnSuccess(v -> log.debug("Logout successful (token revoked).")) + .doOnError(error -> log.error("Error during logout: {}", error.getMessage())); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6be254e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,31 @@ +# Spring Boot application configuration +server: + port: ${AUTH_SERVICE_PORT:8099} # Port the auth-service will run on + +spring: + application: + name: auth-service + +# Keycloak Configuration (adjust values as needed) +keycloak: + server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash) + realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using + client-id: ${KEYCLOAK_AUTH_SERVICE_Client:test-client} # Client ID created in Keycloak for this service + client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!) + # Grant type specific settings (ROPC for /login) + grant-type: password + +logging: + level: + # Set log levels as needed, e.g., DEBUG for Keycloak interactions + com.clevermicro.authservice.service.impl: DEBUG + org.springframework.web.client.RestTemplate: DEBUG + reactor.netty.http.client: DEBUG + + +# --- Security Note --- +# Storing secrets like client-secret directly in application.yml is NOT recommended for production. +# Use environment variables, Docker secrets, Vault, or Spring Cloud Config Server. +# Example using environment variables: +# keycloak: +# client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET} diff --git a/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java new file mode 100644 index 0000000..b5bd549 --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java @@ -0,0 +1,181 @@ +package com.cleverthis.authservice; // Assuming correct package + +import com.cleverthis.authservice.controller.AuthController; +import com.cleverthis.authservice.dto.*; +import com.cleverthis.authservice.exception.*; // Import custom exceptions +import com.cleverthis.authservice.service.IdentityManagerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.context.annotation.Import; // Import annotation +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.equalTo; + + +/** + * Unit tests for the AuthController using WebFluxTest and Mockito. + * Focuses on testing the controller logic and request/response handling. + */ +@WebFluxTest(AuthController.class) // Load only the WebFlux components needed for AuthController +@Import(GlobalExceptionHandler.class) // Explicitly import the GlobalExceptionHandler +class AuthControllerTest { + + @Autowired + private WebTestClient webTestClient; // Test client for making requests to the controller + + @MockitoBean // Creates a Mockito mock for the service layer + private IdentityManagerService identityManagerService; + + private TokenResponse mockTokenResponse; + private VerificationResponse mockVerificationResponse; + + @BeforeEach + void setUp() { + // Setup mock responses for successful scenarios + mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer", "id-789", 0, "state", "openid"); + mockVerificationResponse = new VerificationResponse(true, "test-client", "testuser", "openid", "sub-123", System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000, "aud", "iss", "Bearer", "test@example.com", true, "testuser"); + } + + // --- /login Tests --- + + @Test + void login_Success_ReturnsTokenResponse() { + LoginRequest loginRequest = new LoginRequest("testuser", "password"); + when(identityManagerService.login("testuser", "password")).thenReturn(Mono.just(mockTokenResponse)); + + webTestClient.post().uri("/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus().isOk() // Expect 200 + .expectBody(TokenResponse.class) + .value(TokenResponse::getAccess_token, equalTo("access-123")) + .value(TokenResponse::getRefresh_token, equalTo("refresh-456")); + } + + @Test + void login_ServiceThrowsAuthException_ReturnsUnauthorized() { + LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass"); + // Mock the service to throw the specific exception handled by GlobalExceptionHandler + when(identityManagerService.login("wronguser", "wrongpass")).thenReturn(Mono.error(new AuthenticationException("Invalid credentials"))); + + webTestClient.post().uri("/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler) + } + + @Test + void login_InvalidRequest_ReturnsBadRequest() { + LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request (blank username/password) + + // No need to mock service for validation errors + // The framework and GlobalExceptionHandler (via parent) should handle this + + webTestClient.post().uri("/login") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest) + .exchange() + .expectStatus().isBadRequest(); // Expect 400 due to @Valid and WebExchangeBindException handling + } + + + // --- /verify Tests --- + + @Test + void verify_Success_ReturnsVerificationResponse() { + String validToken = "valid-token-123"; + when(identityManagerService.verifyToken(validToken)).thenReturn(Mono.just(mockVerificationResponse)); + + webTestClient.post().uri("/verify") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken) + .exchange() + .expectStatus().isOk() // Expect 200 + .expectBody(VerificationResponse.class) + .value(VerificationResponse::isActive, equalTo(true)) + .value(VerificationResponse::getUsername, equalTo("testuser")); + } + + @Test + void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() { + String invalidToken = "invalid-token-456"; + // Mock the service to throw the specific exception + when(identityManagerService.verifyToken(invalidToken)).thenReturn(Mono.error(new TokenVerificationException("Token expired"))); + + webTestClient.post().uri("/verify") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken) + .exchange() + .expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler) + } + + @Test + void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity + // No service mocking needed, framework handles missing required header + webTestClient.post().uri("/verify") + .exchange() + .expectStatus().isBadRequest(); // Expect 400 BAD_REQUEST for missing required header + } + + @Test + void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() { + // No service mocking needed, controller handles this directly + webTestClient.post().uri("/verify") + .header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme + .exchange() + .expectStatus().isUnauthorized(); // Expect 401 (handled by controller logic) + } + + + // --- /logout Tests --- + + @Test + void logout_Success_ReturnsNoContent() { + LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke"); + when(identityManagerService.logout(logoutRequest.getRefreshToken())).thenReturn(Mono.empty()); // Mono completes successfully + + webTestClient.post().uri("/logout") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(logoutRequest) + .exchange() + .expectStatus().isNoContent(); // Expect 204 + } + + @Test + void logout_ServiceThrowsException_ReturnsInternalServerError() { + LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error"); + // Mock service to throw the specific exception + when(identityManagerService.logout(logoutRequest.getRefreshToken())).thenReturn(Mono.error(new LogoutException("Keycloak unavailable"))); + + webTestClient.post().uri("/logout") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(logoutRequest) + .exchange() + .expectStatus().is5xxServerError(); // Expect 500 (handled by GlobalExceptionHandler) + } + + @Test + void logout_InvalidRequest_ReturnsBadRequest() { + LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token) + + // No service mocking needed + + webTestClient.post().uri("/logout") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(logoutRequest) + .exchange() + .expectStatus().isBadRequest(); // Expect 400 due to @Valid and WebExchangeBindException handling + } + +} + +// You might also want tests for KeycloakIdentityManagerService using MockWebServer +// See: https://rieckpil.de/test-spring-webclient-with-mockwebserver-from-okhttp3/ -- 2.52.0 From 668bd05e5adecb0467f84b0b0f04aa8686e03fa1 Mon Sep 17 00:00:00 2001 From: Abed Date: Wed, 7 May 2025 01:23:13 +0200 Subject: [PATCH 2/2] Applying check style, and changing the log messages --- .../authservice/AuthServiceApplication.java | 11 +- .../controller/AuthController.java | 66 ++--- .../authservice/dto/LoginRequest.java | 2 +- .../authservice/dto/LogoutRequest.java | 5 +- .../authservice/dto/TokenResponse.java | 50 +++- .../authservice/dto/VerificationResponse.java | 12 +- .../exception/AuthenticationException.java | 2 + .../exception/GlobalExceptionHandler.java | 43 ++- .../exception/LogoutException.java | 2 + .../exception/TokenVerificationException.java | 2 + .../service/IdentityManagerService.java | 16 +- .../impl/KeycloakIdentityManagerService.java | 106 ++++---- .../authservice/AuthApplicationTests.java | 257 +++++++++--------- 13 files changed, 289 insertions(+), 285 deletions(-) diff --git a/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java b/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java index ecf71ca..a7cb2c5 100644 --- a/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java +++ b/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java @@ -3,10 +3,13 @@ package com.cleverthis.authservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication +/** + * Auth-Service starter + */ +@SpringBootApplication public class AuthServiceApplication { - public static void main(String[] args) { - SpringApplication.run(AuthServiceApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(AuthServiceApplication.class, args); + } } diff --git a/src/main/java/com/cleverthis/authservice/controller/AuthController.java b/src/main/java/com/cleverthis/authservice/controller/AuthController.java index 8c6cd33..2cd95bb 100644 --- a/src/main/java/com/cleverthis/authservice/controller/AuthController.java +++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java @@ -5,30 +5,26 @@ import com.cleverthis.authservice.dto.LogoutRequest; import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.VerificationResponse; import com.cleverthis.authservice.service.IdentityManagerService; -// Import custom exceptions if you want specific handling in onErrorResume, -// otherwise rely on GlobalExceptionHandler - -import com.cleverthis.authservice.exception.AuthenticationException; -import com.cleverthis.authservice.exception.TokenVerificationException; -import com.cleverthis.authservice.exception.LogoutException; - import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; /** - * REST Controller for handling authentication related requests. - * Delegates the actual identity provider interaction to the IdentityManagerService. + * REST Controller for handling authentication related requests. Delegates the actual identity + * provider interaction to the IdentityManagerService. */ @RestController -@RequestMapping("/") // Base path for auth endpoints -@Slf4j -public class AuthController { +@RequestMapping("/") +@Slf4j public class AuthController { private final IdentityManagerService identityManagerService; @@ -41,40 +37,40 @@ public class AuthController { * Handles user login requests using username and password. * * @param loginRequest DTO containing username and password. - * @return ResponseEntity containing the TokenResponse on success, or an error status handled globally. + * @return ResponseEntity containing the TokenResponse on success, or an error status handled + * globally. */ @PostMapping("/login") - public Mono> login(@Valid @RequestBody LoginRequest loginRequest) { + public Mono> login( + @Valid @RequestBody LoginRequest loginRequest) { log.info("Received login request for user: {}", loginRequest.getUsername()); - return identityManagerService.login(loginRequest.getUsername(), loginRequest.getPassword()) + return this.identityManagerService + .login(loginRequest.getUsername(), loginRequest.getPassword()) .map(ResponseEntity::ok) // Map successful response to ResponseEntity - .doOnError(e -> log.error("Login failed for user {}: {}", loginRequest.getUsername(), e.getMessage())); - // Let GlobalExceptionHandler handle mapping AuthenticationException to 401 - // Removed .cast() - Rely on GlobalExceptionHandler and type inference + .doOnError(e -> log.error("Login failed for user {}: " + "{}", + loginRequest.getUsername(), e.getMessage())); } /** - * Handles token verification requests. - * Expects the access token in the Authorization header (Bearer scheme). + * Handles token verification requests. Expects the access token in the Authorization header + * (Bearer scheme). * - * @param authorizationHeader The full Authorization header value (e.g., "Bearer "). - * @return ResponseEntity containing the VerificationResponse on success, or an error status handled globally. + * @param authorizationHeader The full Authorization header value. + * @return ResponseEntity containing the VerificationResponse on success, or an error status + * handled globally. */ @PostMapping("/verify") - public Mono> verify(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) { + public Mono> verify( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) { log.info("Received token verification request"); if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - log.warn("Verification request missing or invalid Bearer token format."); - // Return 401 directly for invalid header format + log.warn("Verification request missing or invalid Bearer token format. {}", + authorizationHeader); return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); } - String accessToken = authorizationHeader.substring(7); // Extract token after "Bearer " - - return identityManagerService.verifyToken(accessToken) - .map(ResponseEntity::ok) + String accessToken = authorizationHeader.substring(7); + return this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok) .doOnError(e -> log.error("Token verification failed: {}", e.getMessage())); - // Let GlobalExceptionHandler handle mapping TokenVerificationException to 401 - // Removed .cast() } /** @@ -86,10 +82,8 @@ public class AuthController { @PostMapping("/logout") public Mono> logout(@Valid @RequestBody LogoutRequest logoutRequest) { log.info("Received logout request"); - return identityManagerService.logout(logoutRequest.getRefreshToken()) - .thenReturn(ResponseEntity.noContent().build()) // Return 204 on success, ensure type is Void + return this.identityManagerService.logout(logoutRequest.getRefreshToken()) + .thenReturn(ResponseEntity.noContent().build()) .doOnError(e -> log.error("Logout failed: {}", e.getMessage())); - // Let GlobalExceptionHandler handle mapping LogoutException to appropriate status - // Removed .cast() } } diff --git a/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java b/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java index 5d53f52..5b1d4d2 100644 --- a/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java +++ b/src/main/java/com/cleverthis/authservice/dto/LoginRequest.java @@ -1,9 +1,9 @@ package com.cleverthis.authservice.dto; import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; /** * DTO for the /login request body. diff --git a/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java b/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java index 7d3805f..7c6f44f 100644 --- a/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java +++ b/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java @@ -3,9 +3,12 @@ package com.cleverthis.authservice.dto; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; /** +import lombok.NoArgsConstructor; + +/** * DTO for the /logout request body. */ + @Data @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java b/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java index 6db545b..502643a 100644 --- a/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java +++ b/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java @@ -1,23 +1,45 @@ package com.cleverthis.authservice.dto; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; /** - * DTO representing a successful token response from Keycloak (or similar). - * Contains the fields typically returned by an OAuth2 token endpoint. +import lombok.NoArgsConstructor; + +/** + * DTO representing a successful token response from Keycloak (or similar). Contains the fields + * typically returned by an OAuth2 token endpoint. */ + @Data @NoArgsConstructor -@AllArgsConstructor -public class TokenResponse { - // Using snake_case matching the typical Keycloak response JSON fields - private String access_token; - private int expires_in; - private int refresh_expires_in; - private String refresh_token; - private String token_type; - private String id_token; // Optional, depending on scope - private int not_before_policy; // Keycloak specific field - private String session_state; // Keycloak specific field +@AllArgsConstructor public class TokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private int expiresIn; + + @JsonProperty("refresh_expires_in") + private int refreshExpiresIn; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("id_token") + private String idToken; + + @JsonProperty("not-before-policy") + private int notBeforePolicy; + + @JsonProperty("session_state") + private String sessionState; + + @JsonProperty("scope") private String scope; + } diff --git a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java index 3577503..02cbac1 100644 --- a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java +++ b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java @@ -13,7 +13,7 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class VerificationResponse { private boolean active; // Standard introspection field: is the token active? - private String client_id; + private String clientId; private String username; // Often same as preferred_username private String scope; private String sub; // Subject (user ID) - Use this for X-User-Id @@ -24,13 +24,7 @@ public class VerificationResponse { private String iss; // Issuer private String typ; // Type (e.g., Bearer) private String email; // Standard OIDC claim - private Boolean email_verified; // Standard OIDC claim - private String preferred_username; // Standard OIDC claim - Use this for X-User-Name + private Boolean emailVerified; // Standard OIDC claim + private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name - // --- Added for Metadata Headers --- - // Keycloak needs mappers configured for the client scope to include these in introspection -// private List groups; // Example: groups claim mapped from user group memberships - // You might prefer more structured role info if available - // private Map realm_access; - // private Map resource_access; } diff --git a/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java b/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java index 7320d99..ff5c378 100644 --- a/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java +++ b/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java @@ -4,9 +4,11 @@ package com.cleverthis.authservice.exception; * Custom exception for authentication failures. */ public class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { super(message); } + public AuthenticationException(String message, Throwable cause) { super(message, cause); } diff --git a/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java index 72557b8..b2e313a 100644 --- a/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java @@ -4,17 +4,19 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.bind.support.WebExchangeBindException; -import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; /** - * Global Exception Handler to translate custom exceptions into appropriate - * HTTP responses using Problem Details (RFC 7807). +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; + +/** + * Global Exception Handler to translate custom exceptions into appropriate HTTP responses using + * Problem Details (RFC 7807). */ -@RestControllerAdvice -public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + +@RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(AuthenticationException.class) public ProblemDetail handleAuthenticationException(AuthenticationException ex) { - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + ProblemDetail problemDetail = + ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); problemDetail.setTitle("Authentication Failed"); // Add more details if needed // problemDetail.setProperty("details", "Check username/password or Keycloak status."); @@ -23,7 +25,8 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(TokenVerificationException.class) public ProblemDetail handleTokenVerificationException(TokenVerificationException ex) { - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + ProblemDetail problemDetail = + ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); problemDetail.setTitle("Token Verification Failed"); return problemDetail; } @@ -31,33 +34,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(LogoutException.class) public ProblemDetail handleLogoutException(LogoutException ex) { // Depending on the cause, might return 400 or 500 - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + ProblemDetail problemDetail = + ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); problemDetail.setTitle("Logout Failed"); return problemDetail; } -// // Handle validation errors from @Valid annotations -// @ExceptionHandler(WebExchangeBindException.class) -// public ProblemDetail handleValidationExceptions(WebExchangeBindException ex) { -// ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); -// problemDetail.setTitle("Bad Request"); -// // Optionally add details about specific field errors -// // Map errors = new HashMap<>(); -// // ex.getBindingResult().getAllErrors().forEach((error) -> { -// // String fieldName = ((FieldError) error).getField(); -// // String errorMessage = error.getDefaultMessage(); -// // errors.put(fieldName, errorMessage); -// // }); -// // problemDetail.setProperty("errors", errors); -// return problemDetail; -// } - // Catch-all for other unexpected exceptions @ExceptionHandler(Exception.class) public ProblemDetail handleGenericException(Exception ex) { - logger.error("An unexpected error occurred: ", ex); // Log the full stack trace - ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected internal error occurred."); + logger.error("An internal error occurred.: ", ex); // Log the full stack trace + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred."); problemDetail.setTitle("Internal Server Error"); return problemDetail; } diff --git a/src/main/java/com/cleverthis/authservice/exception/LogoutException.java b/src/main/java/com/cleverthis/authservice/exception/LogoutException.java index 07365a0..db1d2d6 100644 --- a/src/main/java/com/cleverthis/authservice/exception/LogoutException.java +++ b/src/main/java/com/cleverthis/authservice/exception/LogoutException.java @@ -4,9 +4,11 @@ package com.cleverthis.authservice.exception; * Custom exception for logout failures. */ public class LogoutException extends RuntimeException { + public LogoutException(String message) { super(message); } + public LogoutException(String message, Throwable cause) { super(message, cause); } diff --git a/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java b/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java index 995a217..2ddd8b2 100644 --- a/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java +++ b/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java @@ -4,9 +4,11 @@ package com.cleverthis.authservice.exception; * Custom exception for token verification failures. */ public class TokenVerificationException extends RuntimeException { + public TokenVerificationException(String message) { super(message); } + public TokenVerificationException(String message, Throwable cause) { super(message, cause); } diff --git a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java index 22ad08a..eb4064e 100644 --- a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java +++ b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java @@ -5,9 +5,9 @@ import com.cleverthis.authservice.dto.VerificationResponse; import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient /** - * Interface defining operations for interacting with an identity provider. - * This abstraction allows swapping the underlying implementation (e.g., Keycloak, Okta) - * without changing the controller logic. + * Interface defining operations for interacting with an identity provider. This abstraction allows + * swapping the underlying implementation (e.g., Keycloak, Okta) without changing the controller + * logic. */ public interface IdentityManagerService { @@ -16,8 +16,8 @@ public interface IdentityManagerService { * * @param username The user's username. * @param password The user's password. - * @return A Mono emitting the TokenResponse upon successful authentication. - * Emits an error (e.g., AuthenticationException) if authentication fails. + * @return A Mono emitting the TokenResponse upon successful authentication. Emits an error + * (e.g., AuthenticationException) if authentication fails. */ Mono login(String username, String password); @@ -26,7 +26,7 @@ public interface IdentityManagerService { * * @param accessToken The access token (without "Bearer " prefix). * @return A Mono emitting the VerificationResponse containing token details and user info. - * Emits an error if the token is invalid, expired, or introspection fails. + * Emits an error if the token is invalid, expired, or introspection fails. */ Mono verifyToken(String accessToken); @@ -34,8 +34,8 @@ public interface IdentityManagerService { * Logs out a user session by revoking the refresh token. * * @param refreshToken The refresh token associated with the user session. - * @return A Mono completing successfully upon successful logout/revocation. - * Emits an error if revocation fails. + * @return A Mono completing successfully upon successful logout/revocation. Emits an error if + * revocation fails. */ Mono logout(String refreshToken); } diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java index 78944eb..a5f5ed6 100644 --- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java @@ -3,8 +3,8 @@ package com.cleverthis.authservice.service.impl; import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.VerificationResponse; import com.cleverthis.authservice.exception.AuthenticationException; -import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.exception.LogoutException; +import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.service.IdentityManagerService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -19,16 +19,16 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; /** - * Keycloak-specific implementation of the IdentityManagerService. - * Uses WebClient to interact with Keycloak's REST API endpoints. + * Keycloak-specific implementation of the IdentityManagerService. Uses WebClient to interact with + * Keycloak's REST API endpoints. */ @Service -@Slf4j // Lombok annotation for logging +@Slf4j public class KeycloakIdentityManagerService implements IdentityManagerService { private final WebClient webClient; - // Keycloak configuration values injected from application.yml + @Value("${keycloak.server-url}") private String keycloakServerUrl; @Value("${keycloak.realm}") @@ -40,17 +40,20 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { @Value("${keycloak.grant-type}") private String grantType; // Should be 'password' for ROPC - // Construct Keycloak endpoint URLs + private String getTokenEndpoint() { - return String.format("%s/realms/%s/protocol/openid-connect/token", keycloakServerUrl, realm); + return String.format("%s/realms/%s/protocol/openid-connect/token", this.keycloakServerUrl, + this.realm); } private String getIntrospectionEndpoint() { - return String.format("%s/realms/%s/protocol/openid-connect/token/introspect", keycloakServerUrl, realm); + return String.format("%s/realms/%s/protocol/openid-connect/token/introspect", + keycloakServerUrl, this.realm); } private String getRevocationEndpoint() { - return String.format("%s/realms/%s/protocol/openid-connect/revoke", keycloakServerUrl, realm); + return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl, + this.realm); } @Autowired @@ -62,85 +65,90 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { public Mono login(String username, String password) { log.debug("Attempting login for user: {}", username); MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", clientId); - formData.add("client_secret", clientSecret); - formData.add("grant_type", grantType); // Use configured grant type (password) + formData.add("client_id", this.clientId); + formData.add("client_secret", this.clientSecret); + formData.add("grant_type", this.grantType); // Use configured grant type (password) formData.add("username", username); formData.add("password", password); formData.add("scope", "openid email profile"); // Request standard scopes - return webClient.post() - .uri(getTokenEndpoint()) + return this.webClient.post().uri(getTokenEndpoint()) .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)) - .retrieve() + .body(BodyInserters.fromFormData(formData)).retrieve() // Handle specific HTTP status codes .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse.bodyToMono(String.class) - .flatMap(errorBody -> { - log.error("Keycloak login failed with status {}: {}", clientResponse.statusCode(), errorBody); - return Mono.error(new AuthenticationException("Login failed: Invalid credentials or Keycloak error. Status: " + clientResponse.statusCode())); - })) + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("Keycloak login failed with status {}: {}", + clientResponse.statusCode(), errorBody); + return Mono.error(new AuthenticationException( + "Login failed: Invalid credentials or Keycloak error. Status: " + + clientResponse.statusCode())); + })) .bodyToMono(TokenResponse.class) .doOnSuccess(response -> log.debug("Login successful for user: {}", username)) - .doOnError(error -> log.error("Error during login for user {}: {}", username, error.getMessage())); + .doOnError(error -> log.error("Error during login for user {}: {}", username, + error.getMessage())); } @Override public Mono verifyToken(String accessToken) { log.debug("Verifying access token"); MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", clientId); - formData.add("client_secret", clientSecret); + formData.add("client_id", this.clientId); + formData.add("client_secret", this.clientSecret); formData.add("token", accessToken); - return webClient.post() - .uri(getIntrospectionEndpoint()) + return this.webClient.post().uri(getIntrospectionEndpoint()) .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)) - .retrieve() + .body(BodyInserters.fromFormData(formData)).retrieve() .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse.bodyToMono(String.class) - .flatMap(errorBody -> { - log.error("Keycloak token introspection failed with status {}: {}", clientResponse.statusCode(), errorBody); - return Mono.error(new TokenVerificationException("Token introspection failed. Status: " + clientResponse.statusCode())); - })) - .bodyToMono(VerificationResponse.class) - .flatMap(response -> { + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("Keycloak token introspection failed with status {}: {}", + clientResponse.statusCode(), errorBody); + return Mono.error(new TokenVerificationException( + "Token introspection failed. Status: " + + clientResponse.statusCode())); + })) + .bodyToMono(VerificationResponse.class).flatMap(response -> { if (!response.isActive()) { log.warn("Token verification failed: Token is inactive."); - return Mono.error(new TokenVerificationException("Token is invalid or expired.")); + return Mono.error( + new TokenVerificationException("Token is invalid or expired.")); } log.debug("Token verification successful. User: {}", response.getUsername()); return Mono.just(response); - }) - .doOnError(error -> log.error("Error during token verification: {}", error.getMessage())); + }).doOnError(error -> log.error("Error during token verification: {}", + error.getMessage())); } @Override public Mono logout(String refreshToken) { log.debug("Attempting logout (token revocation)"); MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", clientId); - formData.add("client_secret", clientSecret); + formData.add("client_id", this.clientId); + formData.add("client_secret", this.clientSecret); formData.add("token", refreshToken); formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak - return webClient.post() - .uri(getRevocationEndpoint()) + return this.webClient.post().uri(getRevocationEndpoint()) .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)) - .retrieve() + .body(BodyInserters.fromFormData(formData)).retrieve() .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), clientResponse -> clientResponse.bodyToMono(String.class) .flatMap(errorBody -> { - // Keycloak might return 400 if token is already invalid, which is okay for logout - if(clientResponse.statusCode() == HttpStatus.BAD_REQUEST) { - log.warn("Token revocation returned 400 (possibly already invalid/revoked): {}", errorBody); + // Keycloak might return 400 if token is already invalid, which + // is okay for logout + if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) { + log.warn("Token revocation returned 400 (possibly already invalid/revoked): {}", + errorBody); return Mono.empty(); // Treat as success in logout scenario } - log.error("Keycloak token revocation failed with status {}: {}", clientResponse.statusCode(), errorBody); - return Mono.error(new LogoutException("Logout failed. Status: " + clientResponse.statusCode())); + log.error("Keycloak token revocation failed with status {}: {}", + clientResponse.statusCode(), errorBody); + return Mono.error(new LogoutException("Logout failed. Status: " + + clientResponse.statusCode())); })) .bodyToMono(Void.class) // Expecting empty body on success (2xx) .doOnSuccess(v -> log.debug("Logout successful (token revoked).")) diff --git a/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java index b5bd549..a673d6c 100644 --- a/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java +++ b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java @@ -1,8 +1,17 @@ package com.cleverthis.authservice; // Assuming correct package +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.when; + import com.cleverthis.authservice.controller.AuthController; -import com.cleverthis.authservice.dto.*; -import com.cleverthis.authservice.exception.*; // Import custom exceptions +import com.cleverthis.authservice.dto.LoginRequest; +import com.cleverthis.authservice.dto.LogoutRequest; +import com.cleverthis.authservice.dto.TokenResponse; +import com.cleverthis.authservice.dto.VerificationResponse; +import com.cleverthis.authservice.exception.AuthenticationException; +import com.cleverthis.authservice.exception.GlobalExceptionHandler; +import com.cleverthis.authservice.exception.LogoutException; +import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.service.IdentityManagerService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,167 +24,143 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; -import static org.hamcrest.Matchers.equalTo; - - /** - * Unit tests for the AuthController using WebFluxTest and Mockito. - * Focuses on testing the controller logic and request/response handling. + * Unit tests for the AuthController using WebFluxTest and Mockito. Focuses on testing the + * controller logic and request/response handling. */ -@WebFluxTest(AuthController.class) // Load only the WebFlux components needed for AuthController -@Import(GlobalExceptionHandler.class) // Explicitly import the GlobalExceptionHandler -class AuthControllerTest { +@WebFluxTest(AuthController.class) +@Import(GlobalExceptionHandler.class) class AuthControllerTest { - @Autowired - private WebTestClient webTestClient; // Test client for making requests to the controller + @Autowired + private WebTestClient webTestClient; // Test client for making requests to the controller - @MockitoBean // Creates a Mockito mock for the service layer - private IdentityManagerService identityManagerService; + @MockitoBean // Creates a Mockito mock for the service layer + private IdentityManagerService identityManagerService; - private TokenResponse mockTokenResponse; - private VerificationResponse mockVerificationResponse; + private TokenResponse mockTokenResponse; + private VerificationResponse mockVerificationResponse; - @BeforeEach - void setUp() { - // Setup mock responses for successful scenarios - mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer", "id-789", 0, "state", "openid"); - mockVerificationResponse = new VerificationResponse(true, "test-client", "testuser", "openid", "sub-123", System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000, "aud", "iss", "Bearer", "test@example.com", true, "testuser"); - } + @BeforeEach + void setUp() { + // Setup mock responses for successful scenarios + this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer", + "id-789", 0, "state", "openid"); + this.mockVerificationResponse = new VerificationResponse(true, "test-client", "testuser", + "openid", "sub-123", System.currentTimeMillis() / 1000 + 300, + System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000, "aud", "iss", + "Bearer", "test@example.com", true, "testuser"); + } - // --- /login Tests --- + // --- /login Tests --- - @Test - void login_Success_ReturnsTokenResponse() { - LoginRequest loginRequest = new LoginRequest("testuser", "password"); - when(identityManagerService.login("testuser", "password")).thenReturn(Mono.just(mockTokenResponse)); + @Test + void login_Success_ReturnsTokenResponse() { + LoginRequest loginRequest = new LoginRequest("testuser", "password"); + when(this.identityManagerService.login("testuser", "password")) + .thenReturn(Mono.just(this.mockTokenResponse)); - webTestClient.post().uri("/login") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(loginRequest) - .exchange() - .expectStatus().isOk() // Expect 200 - .expectBody(TokenResponse.class) - .value(TokenResponse::getAccess_token, equalTo("access-123")) - .value(TokenResponse::getRefresh_token, equalTo("refresh-456")); - } + this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest).exchange().expectStatus().isOk() // Expect 200 + .expectBody(TokenResponse.class) + .value(TokenResponse::getAccessToken, equalTo("access-123")) + .value(TokenResponse::getRefreshToken, equalTo("refresh-456")); + } - @Test - void login_ServiceThrowsAuthException_ReturnsUnauthorized() { - LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass"); - // Mock the service to throw the specific exception handled by GlobalExceptionHandler - when(identityManagerService.login("wronguser", "wrongpass")).thenReturn(Mono.error(new AuthenticationException("Invalid credentials"))); + @Test + void login_ServiceThrowsAuthException_ReturnsUnauthorized() { + LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass"); + // Mock the service to throw the specific exception handled by GlobalExceptionHandler + when(this.identityManagerService.login("wronguser", "wrongpass")) + .thenReturn(Mono.error(new AuthenticationException("Invalid credentials"))); - webTestClient.post().uri("/login") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(loginRequest) - .exchange() - .expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler) - } + this. webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest).exchange().expectStatus().isUnauthorized(); // Expect 401 + } - @Test - void login_InvalidRequest_ReturnsBadRequest() { - LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request (blank username/password) + @Test + void login_InvalidRequest_ReturnsBadRequest() { + LoginRequest loginRequest = new LoginRequest("", ""); - // No need to mock service for validation errors - // The framework and GlobalExceptionHandler (via parent) should handle this + // No need to mock service for validation errors + // The framework and GlobalExceptionHandler (via parent) should handle this - webTestClient.post().uri("/login") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(loginRequest) - .exchange() - .expectStatus().isBadRequest(); // Expect 400 due to @Valid and WebExchangeBindException handling - } + this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400 + } + // --- /verify Tests --- - // --- /verify Tests --- + @Test + void verify_Success_ReturnsVerificationResponse() { + String validToken = "valid-token-123"; + when(this.identityManagerService.verifyToken(validToken)) + .thenReturn(Mono.just(this.mockVerificationResponse)); - @Test - void verify_Success_ReturnsVerificationResponse() { - String validToken = "valid-token-123"; - when(identityManagerService.verifyToken(validToken)).thenReturn(Mono.just(mockVerificationResponse)); + this.webTestClient.post().uri("/verify") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus() + .isOk() // Expect 200 + .expectBody(VerificationResponse.class) + .value(VerificationResponse::isActive, equalTo(true)) + .value(VerificationResponse::getUsername, equalTo("testuser")); + } - webTestClient.post().uri("/verify") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken) - .exchange() - .expectStatus().isOk() // Expect 200 - .expectBody(VerificationResponse.class) - .value(VerificationResponse::isActive, equalTo(true)) - .value(VerificationResponse::getUsername, equalTo("testuser")); - } + @Test + void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() { + String invalidToken = "invalid-token-456"; + // Mock the service to throw the specific exception + when(this.identityManagerService.verifyToken(invalidToken)) + .thenReturn(Mono.error(new TokenVerificationException("Token expired"))); - @Test - void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() { - String invalidToken = "invalid-token-456"; - // Mock the service to throw the specific exception - when(identityManagerService.verifyToken(invalidToken)).thenReturn(Mono.error(new TokenVerificationException("Token expired"))); + this.webTestClient.post().uri("/verify") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange() + .expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler) + } - webTestClient.post().uri("/verify") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken) - .exchange() - .expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler) - } + @Test + void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity + // No service mocking needed, framework handles missing required header + this.webTestClient.post().uri("/verify").exchange().expectStatus().isBadRequest(); + } - @Test - void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity - // No service mocking needed, framework handles missing required header - webTestClient.post().uri("/verify") - .exchange() - .expectStatus().isBadRequest(); // Expect 400 BAD_REQUEST for missing required header - } + @Test + void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() { + // No service mocking needed, controller handles this directly + this.webTestClient.post().uri("/verify") + .header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme + .exchange().expectStatus().isUnauthorized(); // Expect 401 + } - @Test - void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() { - // No service mocking needed, controller handles this directly - webTestClient.post().uri("/verify") - .header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme - .exchange() - .expectStatus().isUnauthorized(); // Expect 401 (handled by controller logic) - } + // --- /logout Tests --- + @Test + void logout_Success_ReturnsNoContent() { + LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke"); + when(this.identityManagerService.logout(logoutRequest.getRefreshToken())) + .thenReturn(Mono.empty()); // Mono completes successfully - // --- /logout Tests --- + this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) + .bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204 + } - @Test - void logout_Success_ReturnsNoContent() { - LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke"); - when(identityManagerService.logout(logoutRequest.getRefreshToken())).thenReturn(Mono.empty()); // Mono completes successfully + @Test + void logout_ServiceThrowsException_ReturnsInternalServerError() { + LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error"); + // Mock service to throw the specific exception + when(this.identityManagerService.logout(logoutRequest.getRefreshToken())) + .thenReturn(Mono.error(new LogoutException("Keycloak unavailable"))); - webTestClient.post().uri("/logout") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(logoutRequest) - .exchange() - .expectStatus().isNoContent(); // Expect 204 - } + this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) + .bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError(); + } - @Test - void logout_ServiceThrowsException_ReturnsInternalServerError() { - LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error"); - // Mock service to throw the specific exception - when(identityManagerService.logout(logoutRequest.getRefreshToken())).thenReturn(Mono.error(new LogoutException("Keycloak unavailable"))); + @Test + void logout_InvalidRequest_ReturnsBadRequest() { + LogoutRequest logoutRequest = new LogoutRequest(""); + // Invalid request (blank token) + // No service mocking needed - webTestClient.post().uri("/logout") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(logoutRequest) - .exchange() - .expectStatus().is5xxServerError(); // Expect 500 (handled by GlobalExceptionHandler) - } - - @Test - void logout_InvalidRequest_ReturnsBadRequest() { - LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token) - - // No service mocking needed - - webTestClient.post().uri("/logout") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(logoutRequest) - .exchange() - .expectStatus().isBadRequest(); // Expect 400 due to @Valid and WebExchangeBindException handling - } + this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) + .bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); + } } - -// You might also want tests for KeycloakIdentityManagerService using MockWebServer -// See: https://rieckpil.de/test-spring-webclient-with-mockwebserver-from-okhttp3/ -- 2.52.0