Feat#1 creating auth-service, with JUnit test #14

Merged
stanislav.hejny merged 2 commits from Create-Auth-Microservice#1 into develop 2025-05-07 08:38:06 +00:00
16 changed files with 840 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cleverthis</groupId>
<artifactId>auth-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>auth-service</name>
<description>User management project for CleverThis</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,15 @@
package com.cleverthis.authservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Auth-Service starter
*/
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
}
@@ -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();
}
}
@@ -0,0 +1,89 @@
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 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.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.
*/
@RestController
@RequestMapping("/")
@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<ResponseEntity<TokenResponse>> login(
@Valid @RequestBody LoginRequest loginRequest) {
log.info("Received login request for user: {}", loginRequest.getUsername());
return this.identityManagerService
.login(loginRequest.getUsername(), loginRequest.getPassword())
.map(ResponseEntity::ok) // Map successful response to ResponseEntity<TokenResponse>
.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).
*
* @param authorizationHeader The full Authorization header value.
* @return ResponseEntity containing the VerificationResponse on success, or an error status
* handled globally.
*/
@PostMapping("/verify")
public Mono<ResponseEntity<VerificationResponse>> 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. {}",
stanislav.hejny marked this conversation as resolved Outdated
Outdated
Review

Please add the actual token string to the log message, this will help greatly with debugging (and invalid token leaks no security info)

Please add the actual token string to the log message, this will help greatly with debugging (and invalid token leaks no security info)
Outdated
Review

Added

Added
authorizationHeader);
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}
String accessToken = authorizationHeader.substring(7);
return this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok)
.doOnError(e -> log.error("Token verification failed: {}", e.getMessage()));
}
/**
* 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<ResponseEntity<Void>> logout(@Valid @RequestBody LogoutRequest logoutRequest) {
log.info("Received logout request");
return this.identityManagerService.logout(logoutRequest.getRefreshToken())
.thenReturn(ResponseEntity.noContent().<Void>build())
.doOnError(e -> log.error("Logout failed: {}", e.getMessage()));
}
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 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;
}
@@ -0,0 +1,18 @@
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;
}
@@ -0,0 +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.
*/
@Data
@NoArgsConstructor
@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;
}
@@ -0,0 +1,30 @@
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 clientId;
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 emailVerified; // Standard OIDC claim
private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name
}
@@ -0,0 +1,15 @@
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);
}
}
@@ -0,0 +1,53 @@
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.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;
}
// Catch-all for other unexpected exceptions
@ExceptionHandler(Exception.class)
public ProblemDetail handleGenericException(Exception ex) {
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;
}
}
@@ -0,0 +1,15 @@
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);
}
}
@@ -0,0 +1,15 @@
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);
}
}
@@ -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<TokenResponse> 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<VerificationResponse> 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<Void> logout(String refreshToken);
}
@@ -0,0 +1,157 @@
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.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;
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
public class KeycloakIdentityManagerService implements IdentityManagerService {
private final WebClient webClient;
@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
private String getTokenEndpoint() {
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, this.realm);
}
private String getRevocationEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl,
this.realm);
}
@Autowired
public KeycloakIdentityManagerService(WebClient keycloakWebClient) {
this.webClient = keycloakWebClient;
}
@Override
public Mono<TokenResponse> login(String username, String password) {
log.debug("Attempting login for user: {}", username);
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
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 this.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<VerificationResponse> verifyToken(String accessToken) {
log.debug("Verifying access token");
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.clientId);
formData.add("client_secret", this.clientSecret);
formData.add("token", accessToken);
return this.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<Void> logout(String refreshToken) {
log.debug("Attempting logout (token revocation)");
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
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 this.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()));
}
}
+31
View File
@@ -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}
@@ -0,0 +1,166 @@
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.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;
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;
/**
* Unit tests for the AuthController using WebFluxTest and Mockito. Focuses on testing the
* controller logic and request/response handling.
*/
@WebFluxTest(AuthController.class)
@Import(GlobalExceptionHandler.class) 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
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 ---
@Test
void login_Success_ReturnsTokenResponse() {
LoginRequest loginRequest = new LoginRequest("testuser", "password");
when(this.identityManagerService.login("testuser", "password"))
.thenReturn(Mono.just(this.mockTokenResponse));
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(this.identityManagerService.login("wronguser", "wrongpass"))
.thenReturn(Mono.error(new AuthenticationException("Invalid credentials")));
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("", "");
// No need to mock service for validation errors
// The framework and GlobalExceptionHandler (via parent) should handle this
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
// --- /verify Tests ---
@Test
void verify_Success_ReturnsVerificationResponse() {
String validToken = "valid-token-123";
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(this.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"));
}
@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")));
this.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_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
}
// --- /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<Void> completes successfully
this.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(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
.thenReturn(Mono.error(new LogoutException("Keycloak unavailable")));
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError();
}
@Test
void logout_InvalidRequest_ReturnsBadRequest() {
LogoutRequest logoutRequest = new LogoutRequest("");
// Invalid request (blank token)
// No service mocking needed
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest();
}
}