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();
+ }
+
+}