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..a7cb2c5 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/AuthServiceApplication.java @@ -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); + } +} 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..2cd95bb --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java @@ -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> 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 + .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> 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. {}", + 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> logout(@Valid @RequestBody LogoutRequest logoutRequest) { + log.info("Received logout request"); + return this.identityManagerService.logout(logoutRequest.getRefreshToken()) + .thenReturn(ResponseEntity.noContent().build()) + .doOnError(e -> log.error("Logout failed: {}", e.getMessage())); + } +} 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..5b1d4d2 --- /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.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; +} \ 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..7c6f44f --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/LogoutRequest.java @@ -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; +} 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..502643a --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/TokenResponse.java @@ -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; + +} 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..02cbac1 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java @@ -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 + +} 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..ff5c378 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/AuthenticationException.java @@ -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); + } +} \ 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..b2e313a --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java @@ -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; + } +} 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..db1d2d6 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/LogoutException.java @@ -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); + } +} 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..2ddd8b2 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/exception/TokenVerificationException.java @@ -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); + } +} 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..eb4064e --- /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..a5f5ed6 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java @@ -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 login(String username, String password) { + log.debug("Attempting login for user: {}", username); + MultiValueMap 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 verifyToken(String accessToken) { + log.debug("Verifying access token"); + MultiValueMap 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 logout(String refreshToken) { + log.debug("Attempting logout (token revocation)"); + MultiValueMap 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())); + } +} 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..a673d6c --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java @@ -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 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(); + } + +}