diff --git a/pom.xml b/pom.xml
index 5c316b4..6f1bd84 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,12 @@
org.springframework.cloud
spring-cloud-starter-consul-config
+
+ com.squareup.okhttp3
+ mockwebserver
+ 4.12.0
+ test
+
diff --git a/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java b/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java
new file mode 100644
index 0000000..fcd73dd
--- /dev/null
+++ b/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java
@@ -0,0 +1,15 @@
+package com.cleverthis.authservice.config;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Config for keycloak clients:
+ * {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService}
+ * and {@link com.cleverthis.authservice.service.impl.KeycloakAdminClient}.
+ */
+@Configuration
+@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
+public class KeycloakClientConfig {
+ // empty for now
+}
diff --git a/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfigProperties.java b/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfigProperties.java
new file mode 100644
index 0000000..a9b7961
--- /dev/null
+++ b/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfigProperties.java
@@ -0,0 +1,71 @@
+package com.cleverthis.authservice.config;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Config model for {@link KeycloakClientConfig}.
+ */
+@ConfigurationProperties(prefix = "keycloak")
+@Getter
+@Setter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class KeycloakClientConfigProperties {
+
+ /**
+ * Host name of your Keycloak instance.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak server URL cannot be blank")
+ private String keycloakHost = "http://keycloak.dev.localhost";
+
+
+ /**
+ * Admin host name of your Keycloak instance.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak server URL cannot be blank")
+ private String keycloakAdminHost = "http://keycloak-admin.dev.localhost";
+
+ /**
+ * The realm name.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak realm cannot be blank")
+ private String realm = "clevermicro-dev";
+
+ /**
+ * The client id for calling OIDC endpoints.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak client ID cannot be blank")
+ private String clientId = "auth-service";
+
+ /**
+ * The client secret.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak client secret cannot be blank")
+ private String clientSecret = "";
+
+ /**
+ * The admin account for accessing the admin endpoints.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak admin account username cannot be blank")
+ private String adminAccountUsername = "auth-service-account";
+
+ /**
+ * The admin account password.
+ */
+ @Builder.Default
+ @NotBlank(message = "Keycloak admin account password cannot be blank")
+ private String adminAccountPassword = "";
+}
diff --git a/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfig.java b/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java
similarity index 57%
rename from src/main/java/com/cleverthis/authservice/config/PermissionMappingConfig.java
rename to src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java
index c904fa9..4f2598b 100644
--- a/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfig.java
+++ b/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java
@@ -20,10 +20,10 @@ import org.springframework.validation.annotation.Validated;
@Component
@ConfigurationProperties(prefix = "auth-service.permission-mapping")
@Data
-@Validated
-public class PermissionMappingConfig {
+@Validated
+public class PermissionMappingConfigProperties {
- @Valid
+ @Valid
private List rules = new ArrayList<>();
private String defaultKeycloakClientId; // Optional default
@@ -39,5 +39,37 @@ public class PermissionMappingConfig {
private String pathPrefix;
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
private String keycloakClientId;
+
+ private List passthroughs = new ArrayList<>();
+ }
+
+ /**
+ * Define the pass through rules.
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class PassThrough {
+ @NotBlank(message = "Type must not be blank")
+ private Type type;
+ @NotBlank(message = "The value of the rule must not be blank")
+ private String value;
+
+ /**
+ * Supported pass-through types.
+ */
+ public enum Type {
+ /**
+ * Use regex defined in value to match the path.
+ * Example value: {@code ^/path/(a-zA-Z)+}.
+ */
+ PATH_REGEX,
+ /**
+ * Use the prefix defined in value to match the path.
+ * Example value: {@code /path/}.
+ */
+ PATH_PREFIX
+ }
}
}
\ No newline at end of file
diff --git a/src/main/java/com/cleverthis/authservice/controller/AuthController.java b/src/main/java/com/cleverthis/authservice/controller/AuthController.java
index 82c7277..90a1804 100644
--- a/src/main/java/com/cleverthis/authservice/controller/AuthController.java
+++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java
@@ -1,6 +1,6 @@
package com.cleverthis.authservice.controller;
-import com.cleverthis.authservice.config.PermissionMappingConfig;
+import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
import com.cleverthis.authservice.dto.TokenResponse;
@@ -11,12 +11,8 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import jakarta.validation.Valid;
import java.net.URI;
import java.net.URISyntaxException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.LinkedHashSet;
-import java.util.List;
+import java.util.Map;
import java.util.Optional;
-import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
@@ -165,24 +161,50 @@ public class AuthController {
return headers;
}
+ private static final Map HTTP_METHOD_TO_SCOPE = Map.of(
+ "get", "rest:read",
+ "post", "rest:create",
+ "put", "rest:update",
+ "delete", "rest:delete"
+ );
+
/**
* Handles forward authentication requests from Traefik. Expects the access token in the
* Authorization header (Bearer scheme), and X-Forwarded-Method, X-Forwarded-Uri from Traefik.
* On success, returns 200 OK with user metadata in headers. On failure, returns 401
* Unauthorized or 403 Forbidden.
*/
- @RequestMapping(value = "/auth", method = { RequestMethod.GET, RequestMethod.POST,
- RequestMethod.PUT, RequestMethod.DELETE,
- RequestMethod.PATCH })
- public Mono> forwardAuth(
- @RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader,
- @RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
- @RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString) {
-
+ @RequestMapping(value = "/auth", method = {
+ RequestMethod.GET, RequestMethod.POST,
+ RequestMethod.PUT, RequestMethod.DELETE,
+ RequestMethod.PATCH})
+ public Mono> forwardAuth(
+ @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false)
+ String authorizationHeader,
+ @RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
+ @RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString
+ ) {
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
- originalUriString);
-
- String accessToken = extractBearerToken(authorizationHeader);
+ originalUriString);
+ // first detect the client id and service relative path
+ final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString);
+ if (targetServiceClientIdOpt.isEmpty()) {
+ log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
+ originalUriString);
+ return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
+ }
+ final var targetServiceClientId = targetServiceClientIdOpt.get();
+ final var serviceRelativePath =
+ extractServiceRelativePath(originalUriString, targetServiceClientId);
+ // then check pass-through rule
+ if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) {
+ return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
+ }
+ if (authorizationHeader == null) {
+ return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST).build());
+ }
+ // no pass-through, check permission
+ final var accessToken = extractBearerToken(authorizationHeader);
if (accessToken == null) {
log.warn("ForwardAuth: Missing or invalid Bearer token format.");
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
@@ -190,81 +212,92 @@ public class AuthController {
// Step 1: Validate the user's token and get user details (especially userSub)
return this.identityManagerService.verifyToken(accessToken)
- .flatMap(verificationResponse -> {
- // Token is valid and active, proceed to permission check
- String userSub = verificationResponse.getSub();
- if (userSub == null || userSub.isBlank()) {
+ .flatMap(verificationResponse -> {
+ // Token is valid and active, proceed to permission check
+ final var userSub = verificationResponse.getSub();
+ if (userSub == null || userSub.isBlank()) {
+ log.error(
+ "ForwardAuth: User subject (sub) "
+ + "is missing from token after verification.");
+ return Mono.just(
+ ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
+ }
+
+ // Step 2: Figure out the scope
+ final var scope = HTTP_METHOD_TO_SCOPE.get(originalMethod.toLowerCase());
+ if (scope == null) { // reject if method not allowed
+ return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
+ }
+
+ // Step 3: Check permission
+ return this.identityManagerService.checkResourcePermission(
+ accessToken, targetServiceClientId, serviceRelativePath, scope
+ )
+ .flatMap(hasPermission -> {
+ if (hasPermission) {
+ log.info(
+ "ForwardAuth: GRANTED for User='{}',"
+ + " Resource='{}', Scope='{}', Client='{}'",
+ verificationResponse.getUsername(),
+ serviceRelativePath, scope,
+ targetServiceClientId);
+ final var responseHeaders = buildAuthHeaders(verificationResponse);
+ return Mono.just(ResponseEntity.ok().headers(responseHeaders)
+ .build());
+ } else {
+ log.warn(
+ "ForwardAuth: DENIED for User='{}',"
+ + " Resource='{}', Scope='{}', Client='{}'",
+ verificationResponse.getUsername(),
+ serviceRelativePath, scope,
+ targetServiceClientId);
+ return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .build());
+ }
+ });
+ }).onErrorResume(TokenVerificationException.class, e -> {
+ log.warn("ForwardAuth: Token verification failed: {}", e.getMessage());
+ return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
+ }).onErrorResume(throwable -> !(throwable instanceof TokenVerificationException),
+ t -> {
log.error(
- "ForwardAuth: User subject (sub) "
- + "is missing from token after verification.");
- return Mono.just(
- ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
- }
-
- // Step 2: Determine target Keycloak Client ID from original URI
- Optional targetServiceClientIdOpt =
- getTargetServiceClientId(originalUriString);
- if (targetServiceClientIdOpt.isEmpty()) {
- log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
- originalUriString);
- return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
- }
- String targetServiceClientId = targetServiceClientIdOpt.get();
-
- // Step 3: Construct the required Keycloak Client Role name
- // Path for role construction should be relative to the service prefix
- String serviceRelativePath =
- extractServiceRelativePath(originalUriString, targetServiceClientId);
- List candidateRoleNames =
- generateCandidateRoleNames(originalMethod, serviceRelativePath);
-
- if (candidateRoleNames.isEmpty()) {
- log.warn(
- "ForwardAuth: No candidate roles generated for Method='{}',"
- + " RelativePath='{}'. Denying access.",
- originalMethod, serviceRelativePath);
- return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
- }
-
- // Step 4: Check permission
- return this.identityManagerService.checkUserHasAnyClientRole(userSub,
- targetServiceClientId, candidateRoleNames).flatMap(hasPermission -> {
- if (hasPermission) {
- log.info(
- "ForwardAuth: GRANTED for UserSub='{}',"
- + " Role='{}', Client='{}'",
- userSub, candidateRoleNames, targetServiceClientId);
- HttpHeaders responseHeaders =
- buildAuthHeaders(verificationResponse);
- return Mono.just(ResponseEntity.ok().headers(responseHeaders)
- .build());
- } else {
- log.warn(
- "ForwardAuth: DENIED for UserSub='{}',"
- + " Role='{}', Client='{}'",
- userSub, candidateRoleNames, targetServiceClientId);
- return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
- .build());
- }
- });
- }).onErrorResume(TokenVerificationException.class, e -> {
- log.warn("ForwardAuth: Token verification failed: {}", e.getMessage());
- return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
- }).onErrorResume(throwable -> !(throwable instanceof TokenVerificationException),
- t -> {
- log.error(
- "ForwardAuth: Generic error/exception during"
+ "ForwardAuth: Generic error/exception during"
+ " permission processing: {}",
- t.getMessage(), t);
- return Mono.just(
- ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
- })
- .onErrorResume(Exception.class, e -> { // Catch other unexpected errors
- log.error("ForwardAuth: Internal error during permission check: {}",
- e.getMessage(), e);
- return Mono
- .just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
- });
+ t.getMessage(), t);
+ return Mono.just(
+ ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
+ })
+ .onErrorResume(Exception.class, e -> { // Catch other unexpected errors
+ log.error("ForwardAuth: Internal error during permission check: {}",
+ e.getMessage(), e);
+ return Mono
+ .just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
+ });
+ }
+
+ private boolean shouldPassThrough(
+ final String serviceClientId, final String serviceRelativePath
+ ) {
+ final var rules = this.permissionMapLookupService
+ .getPassThoughRulesByClientId(serviceClientId);
+ try {
+ return rules.stream()
+ .anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
+ } catch (final Throwable throwable) {
+ log.error("Error executing pass-through rule for client {} : {}",
+ serviceClientId, throwable.getMessage(), throwable);
+ return false;
+ }
+ }
+
+ private boolean executePassThroughRule(
+ final PermissionMappingConfigProperties.PassThrough rule,
+ final String serviceRelativePath
+ ) {
+ return switch (rule.getType()) {
+ case PATH_REGEX -> serviceRelativePath.matches(rule.getValue());
+ case PATH_PREFIX -> serviceRelativePath.startsWith(rule.getValue());
+ };
}
private Optional getTargetServiceClientId(String fullUriString) {
@@ -293,7 +326,7 @@ public class AuthController {
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
- Optional matchedRule =
+ Optional matchedRule =
this.permissionMapLookupService.matchRuleByClientIdAndPath(
mappedKeycloakClientId, fullUriString
);
@@ -333,96 +366,4 @@ public class AuthController {
return ""; // Or throw
}
}
-
- /**
- * Generates a list of candidate Keycloak Client Role names
- * based on HTTP method and relative path,
- * ordered from most specific to most general.
- * Examples for GET /api/items/123e4567-e89b-12d3-a456-426614174000
- * (normalized to /api/items/BY_ID):
- * - GET_API_ITEMS_BY_ID
- * - ALL_API_ITEMS_BY_ID
- * - GET_API_ITEMS_STAR
- * - ALL_API_ITEMS_STAR
- * - GET_API_STAR
- * - ALL_API_STAR
- * - GET_STAR
- * - ALL_STAR
- */
- private List generateCandidateRoleNames(String httpMethod, String relativePath) {
- final String httpMethodUpper = httpMethod.toUpperCase();
- final Set candidates = new LinkedHashSet<>();
-
- // Normalize path: remove leading slash for segment processing, handle root
- String normalizedPath = relativePath.trim();
- if (normalizedPath.startsWith("/")) {
- normalizedPath = normalizedPath.substring(1);
- }
- if (normalizedPath.endsWith("/")) { // Remove trailing slash if not root
- if (normalizedPath.length() > 1) {
- normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1);
- } else if (normalizedPath.length() == 1 && normalizedPath.equals("/")) {
- normalizedPath = ""; // Treat as root
- }
- }
-
- // Replace path parameters and UUIDs
- String processedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID");
- processedPath = processedPath.replaceAll(
- "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
- "BY_ID");
-
- List segments = new ArrayList<>();
- if (!processedPath.isEmpty()) {
- segments.addAll(Arrays.asList(processedPath.split("/")));
- }
-
- // Generate path-specific role parts
- // For path /p1/p2/p3: P1_P2_P3, P1_P2_STAR, P1_STAR, STAR
- if (!segments.isEmpty()) {
- for (int i = segments.size(); i >= 0; i--) {
- StringBuilder pathPartSb = new StringBuilder();
- for (int j = 0; j < i; j++) {
- pathPartSb.append(segments.get(j).toUpperCase());
- if (j < i - 1) {
- pathPartSb.append("_");
- }
- }
- if (i < segments.size()) { // Add STAR if not the full specific path
- if (pathPartSb.length() > 0) {
- pathPartSb.append("_");
- }
- pathPartSb.append("STAR");
- }
- String pathRoleSuffix = pathPartSb.toString();
- if (pathRoleSuffix.isEmpty() && segments.size() > 0) {
- pathRoleSuffix = "STAR";
- } else if (pathRoleSuffix.isEmpty() && segments.isEmpty()) { // for root path "/"
- pathRoleSuffix = "ROOT"; // or STAR depending on convention
- }
-
- // Avoid double STAR for METHOD_STAR_STAR etc.
- if (!pathRoleSuffix.isEmpty() && !pathRoleSuffix.equals("STAR")) {
- candidates.add(httpMethodUpper + "_" + pathRoleSuffix);
- candidates.add("ALL_" + pathRoleSuffix);
- // for root path "/" generating METHOD_ROOT and ALL_ROOT
- } else if (pathRoleSuffix.equals("STAR") && segments.isEmpty()) {
- candidates.add(httpMethodUpper + "_ROOT");
- candidates.add("ALL_ROOT");
- }
- }
- } else { // Root path "/"
- candidates.add(httpMethodUpper + "_ROOT"); // e.g. GET_ROOT
- candidates.add("ALL_ROOT"); // e.g. ALL_ROOT
- }
-
- // Add method-wide wildcard and client-wide wildcard
- candidates.add(httpMethodUpper + "_STAR");
- candidates.add("ALL_STAR");
-
- List orderedCandidates = new ArrayList<>(candidates);
- log.debug("Generated candidate roles for method '{}', relative path '{}': {}", httpMethod,
- relativePath, orderedCandidates);
- return orderedCandidates;
- }
}
diff --git a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java
index b88d624..cb83a06 100644
--- a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java
+++ b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java
@@ -3,6 +3,7 @@ package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import lombok.AllArgsConstructor;
+import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -13,6 +14,7 @@ import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
+@Builder
public class VerificationResponse {
private boolean active; // Standard introspection field: is the token active?
@JsonProperty("client_id")
diff --git a/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java
index bf3c5ad..3f257b7 100644
--- a/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/cleverthis/authservice/exception/GlobalExceptionHandler.java
@@ -68,7 +68,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ServiceUnavailableException.class) // New Handler
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
// Error communicating with Keycloak Admin API or other critical backend
- logger.error("Service unavailable exception: {}", ex); // Log with stack trace
+ this.logger.error("Service unavailable exception: {}", ex); // Log with stack trace
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"An external service required for authorization is currently unavailable.");
@@ -104,7 +104,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
*/
@ExceptionHandler(Exception.class)
public ProblemDetail handleGenericException(Exception ex) {
- logger.error("An internal error occurred.: ", ex); // Log the full stack trace
+ this.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");
diff --git a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java
index 30958fb..6240925 100644
--- a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java
+++ b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java
@@ -4,8 +4,7 @@ package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
-import java.util.List;
-import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
+import reactor.core.publisher.Mono;
/**
* Interface defining operations for interacting with an identity provider. This abstraction allows
@@ -41,19 +40,6 @@ public interface IdentityManagerService {
* revocation fails.
*/
Mono logout(String refreshToken);
-
- /**
- * Checks if a user has a specific client role for a target service (Keycloak client).
- *
- * @param userSub The subject (ID) of the user.
- * @param targetServiceClientId The public client ID of the target service/application
- * in Keycloak.
- * @param candidateRoleNames The name of the client role to check for.
- * @return A Mono emitting true if the user has the role, false otherwise. Emits an error if the
- * check cannot be performed (e.g., Keycloak unavailable).
- */
- Mono checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
- List candidateRoleNames);
/**
* Registers a new user in the identity provider.
@@ -66,4 +52,8 @@ public interface IdentityManagerService {
* Emits an error (e.g., UserAlreadyExistsException) if registration fails.
*/
Mono registerUser(RegistrationRequest registrationRequest);
+
+ Mono checkResourcePermission(
+ String reqAccessToken, String clientId, String resourceUri, String scope
+ );
}
diff --git a/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java b/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java
index 4b15363..6ad18ef 100644
--- a/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java
+++ b/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java
@@ -1,6 +1,7 @@
package com.cleverthis.authservice.service;
-import com.cleverthis.authservice.config.PermissionMappingConfig;
+import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
+import java.util.List;
import java.util.Optional;
/**
@@ -22,5 +23,12 @@ public interface PermissionMapLookupService {
* Given the client id and path, find the rule that both matches the client id exactly,
* and match the path prefix.
*/
- Optional matchRuleByClientIdAndPath(String clientId, String path);
+ Optional matchRuleByClientIdAndPath(
+ String clientId, String path);
+
+ /**
+ * Return the list of pass-through rules by client id.
+ */
+ List getPassThoughRulesByClientId(
+ String clientId);
}
diff --git a/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java b/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java
index c3575e2..4e6ffbc 100644
--- a/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java
+++ b/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java
@@ -1,10 +1,11 @@
package com.cleverthis.authservice.service.impl;
-import com.cleverthis.authservice.config.PermissionMappingConfig;
+import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
@@ -29,30 +30,31 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
*/
static final String PERMISSION_MAPPING_CONSUL_KEY =
"cleverthis/clevermicro/auth-service/config/permission-mapping";
- private final PermissionMappingConfig permissionMappingConfig;
+ private final PermissionMappingConfigProperties permissionMappingConfigProperties;
private final ConsulClient consulClient;
private final ObjectMapper objectMapper;
/**
* Create an instance of {@link FallbackPermissionMapLookupService}.
*
- * @param permissionMappingConfig the local config, used as fallback.
- * @param consulClient the consul client, used for query consul value.
- * @param objectMapper for deserializing the value from consul.
+ * @param permissionMappingConfigProperties the local config, used as fallback.
+ * @param consulClient the consul client, used for query consul value.
+ * @param objectMapper for deserializing the value from consul.
*/
@Autowired
public FallbackPermissionMapLookupService(
- final PermissionMappingConfig permissionMappingConfig,
+ final PermissionMappingConfigProperties permissionMappingConfigProperties,
final ConsulClient consulClient,
final ObjectMapper objectMapper
) {
- this.permissionMappingConfig = permissionMappingConfig;
+ this.permissionMappingConfigProperties = permissionMappingConfigProperties;
this.consulClient = consulClient;
this.objectMapper = objectMapper;
refreshConsul(); // initial refresh
}
- private final AtomicReference consulValue = new AtomicReference<>();
+ private final AtomicReference consulValue =
+ new AtomicReference<>();
@Scheduled(initialDelay = 1000, fixedRate = 1000)
void refreshConsul() {
@@ -71,7 +73,7 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
try { // try decoding
final var config = this.objectMapper.readValue(
- kvValue.getDecodedValue(), PermissionMappingConfig.class);
+ kvValue.getDecodedValue(), PermissionMappingConfigProperties.class);
this.consulValue.set(config);
} catch (final JsonProcessingException ex) {
// got a corrupted value, leaving the cache empty
@@ -85,12 +87,12 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
}
- PermissionMappingConfig getConfig() {
+ PermissionMappingConfigProperties getConfig() {
final var consulValue = this.consulValue.get();
if (consulValue != null) {
return consulValue;
} else {
- return this.permissionMappingConfig;
+ return this.permissionMappingConfigProperties;
}
}
@@ -113,7 +115,7 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
@Override
- public Optional matchRuleByClientIdAndPath(
+ public Optional matchRuleByClientIdAndPath(
final String clientId, final String path
) {
final var config = getConfig();
@@ -123,4 +125,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
&& path.startsWith(rule.getPathPrefix()))
.findFirst();
}
+
+ @Override
+ public List getPassThoughRulesByClientId(
+ final String clientId
+ ) {
+ final var config = getConfig();
+ return config.getRules().stream()
+ .filter(rule -> rule.getKeycloakClientId().equals(clientId))
+ .flatMap(rule -> rule.getPassthroughs().stream())
+ .toList();
+ }
}
diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java
index 7cd7979..b81d246 100644
--- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java
+++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java
@@ -1,5 +1,6 @@
package com.cleverthis.authservice.service.impl;
+import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse;
import com.cleverthis.authservice.dto.KeycloakClientRepresentation;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
@@ -10,7 +11,6 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
@@ -27,41 +27,52 @@ import reactor.util.retry.Retry;
* auth-service and making admin calls.
*/
@Service
-@Slf4j public class KeycloakAdminClient {
+@Slf4j
+public class KeycloakAdminClient {
private final WebClient webClient;
private final AtomicReference adminAccessToken = new AtomicReference<>();
private final AtomicReference tokenExpiryTime = new AtomicReference<>(0L);
- @Value("${keycloak.server-url}")
- private String keycloakServerUrl;
- @Value("${keycloak.realm}")
- private String realm;
+ private final String keycloakAdminHost;
+ private final String realm;
+ private final String adminAccountUsername;
+ private final String adminAccountPassword;
- // Credentials for auth-service's own client to call Admin API
- @Value("${auth-service.admin-client.client-id}")
- private String adminServiceClientId;
- @Value("${auth-service.admin-client.client-secret}")
- private String adminServiceClientSecret;
-
- public KeycloakAdminClient(@Qualifier("keycloakWebClient") WebClient webClient) {
+ /**
+ * Create a keycloak admin client.
+ */
+ public KeycloakAdminClient(
+ @Qualifier("keycloakWebClient") final WebClient webClient,
+ final KeycloakClientConfigProperties configProperties
+ ) {
this.webClient = webClient;
+ this.keycloakAdminHost = configProperties.getKeycloakAdminHost();
+ this.realm = configProperties.getRealm();
+ this.adminAccountUsername = configProperties.getAdminAccountUsername();
+ this.adminAccountPassword = configProperties.getAdminAccountPassword();
}
private String getAdminTokenEndpoint() {
- // Ensure no double slashes if keycloakServerUrl itself has a trailing slash (it shouldn't)
- return String.format("%s/realms/%s/protocol/openid-connect/token",
- this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
+ return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
+ .path("/realms/{realm}/protocol/openid-connect/token")
+ .buildAndExpand(this.realm)
+ .encode().toUriString();
}
private String getClientsEndpoint() {
- return String.format("%s/admin/realms/%s/clients",
- this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
+ return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
+ .path("/admin/realms/{realm}/clients")
+ .buildAndExpand(this.realm)
+ .encode().toUriString();
}
private String getUserClientRolesEndpoint(String userId, String clientInternalId) {
- return String.format("%s/admin/realms/%s/users/%s/role-mappings/clients/%s/composite",
- this.keycloakServerUrl.replaceAll("/+$", ""), this.realm, userId, clientInternalId);
+ return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
+ .path("/admin/realms/{realm}/users/{userId}"
+ + "/role-mappings/clients/{clientId}/composite")
+ .buildAndExpand(this.realm, this.realm, userId, clientInternalId)
+ .encode().toUriString();
}
/**
@@ -69,7 +80,6 @@ import reactor.util.retry.Retry;
* credentials grant.
*
* @return Mono emitting the admin access token.
- *
*/
Mono getAdminAccessToken() {
if (System.currentTimeMillis() < this.tokenExpiryTime.get()
@@ -78,12 +88,13 @@ import reactor.util.retry.Retry;
return Mono.just(this.adminAccessToken.get());
}
- log.info("Fetching new admin access token for auth-service using client_id: {}",
- this.adminServiceClientId);
+ log.info("Fetching new admin access token for auth-service using account: {}",
+ this.adminAccountUsername);
MultiValueMap formData = new LinkedMultiValueMap<>();
- formData.add("client_id", this.adminServiceClientId);
- formData.add("client_secret", this.adminServiceClientSecret);
- formData.add("grant_type", "client_credentials");
+ formData.add("client_id", "admin-cli");
+ formData.add("username", this.adminAccountUsername);
+ formData.add("password", this.adminAccountPassword);
+ formData.add("grant_type", "password");
return this.webClient.post().uri(getAdminTokenEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
@@ -150,14 +161,15 @@ import reactor.util.retry.Retry;
/**
* Fetches the effective client roles for a given user and a specific client (internal ID).
*
- * @param userId the Keycloak user's 'sub' (subject ID).
+ * @param userId the Keycloak user's 'sub' (subject ID).
* @param clientInternalId The internal UUID of the Keycloak client.
* @return Mono emitting a List of KeycloakRoleRepresentation.
*/
- public Mono> getUserEffectiveClientRoles(String userId,
- String clientInternalId) {
+ public Mono> getUserEffectiveClientRoles(
+ String userId, String clientInternalId
+ ) {
log.debug("Fetching effective client roles for user '{}' on client internal ID '{}'",
- userId, clientInternalId);
+ userId, clientInternalId);
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getUserClientRolesEndpoint(userId, clientInternalId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java
index 861d8e6..606eeeb 100644
--- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java
+++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java
@@ -1,24 +1,25 @@
package com.cleverthis.authservice.service.impl;
-import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
+import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.RegistrationRequest;
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.PermissionCheckException;
import com.cleverthis.authservice.exception.RegistrationException;
+import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
@@ -26,6 +27,7 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
+import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
/**
@@ -33,49 +35,60 @@ import reactor.core.publisher.Mono;
* Keycloak's REST API endpoints.
*/
@Service
-@Slf4j
+@Slf4j
public class KeycloakIdentityManagerService implements IdentityManagerService {
private final WebClient webClient;
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
- @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 final String keycloakServerUrl;
+ private final String realm;
+ private final String clientId;
+ private final String clientSecret;
+
private String getTokenEndpoint() {
- return String.format("%s/realms/%s/protocol/openid-connect/token", this.keycloakServerUrl,
- this.realm);
+ return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
+ .path("/realms/{realm}/protocol/openid-connect/token")
+ .buildAndExpand(this.realm)
+ .encode().toUriString();
}
private String getIntrospectionEndpoint() {
- return String.format("%s/realms/%s/protocol/openid-connect/token/introspect",
- this.keycloakServerUrl, this.realm);
+ return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
+ .path("/realms/{realm}/protocol/openid-connect/token/introspect")
+ .buildAndExpand(this.realm)
+ .encode().toUriString();
}
private String getRevocationEndpoint() {
- return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl,
- this.realm);
+ return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
+ .path("/realms/{realm}/protocol/openid-connect/revoke")
+ .buildAndExpand(this.realm)
+ .encode().toUriString();
}
-
+
private String getUsersAdminEndpoint() {
- return String.format("%s/admin/realms/%s/users",
- this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
+ return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
+ .path("/admin/realms/{realm}/users")
+ .buildAndExpand(this.realm)
+ .encode().toUriString();
}
-
+ /**
+ * Create a keycloak OIDC client.
+ */
@Autowired
- public KeycloakIdentityManagerService(WebClient keycloakWebClient,
- KeycloakAdminClient keycloakAdminClient) {
+ public KeycloakIdentityManagerService(
+ final WebClient keycloakWebClient,
+ final KeycloakAdminClient keycloakAdminClient,
+ final KeycloakClientConfigProperties configProperties
+ ) {
this.webClient = keycloakWebClient;
this.keycloakAdminClient = keycloakAdminClient;
+ this.keycloakServerUrl = configProperties.getKeycloakHost();
+ this.realm = configProperties.getRealm();
+ this.clientId = configProperties.getClientId();
+ this.clientSecret = configProperties.getClientSecret();
}
@Override
@@ -84,7 +97,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
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("grant_type", "password"); // Use configured grant type (password)
formData.add("username", username);
formData.add("password", password);
formData.add("scope", "openid email profile"); // Request standard scopes
@@ -177,54 +190,6 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
.doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
}
- @Override
- public Mono checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
- List candidateRoleNames) {
- log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'",
- userSub, targetServiceClientId, candidateRoleNames);
-
- // Step 1: Get the internal UUID of the target Keycloak client
- return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
- .flatMap(clientInternalId -> {
- // Step 2: Get the user's effective client roles for that client's internal ID
- log.debug("Found internal client ID: {}. Fetching roles for userSub: {}",
- clientInternalId, userSub);
- return this.keycloakAdminClient.getUserEffectiveClientRoles(userSub,
- clientInternalId);
- }).map(roles -> {
- // Step 3: Check if the required role name is present in the list of roles
- boolean hasRole = roles.stream()
- .anyMatch(role -> candidateRoleNames.contains(role.getName()));
- if (hasRole) {
- log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'"
- + ". User roles: {}",
- userSub, candidateRoleNames, targetServiceClientId,
- roles.stream().map(KeycloakRoleRepresentation::getName).toList());
- } else {
- log.warn(
- "Permission DENIED for userSub '{}' to role '{}' on"
- + " client '{}'. User roles: {}",
- userSub, candidateRoleNames, targetServiceClientId,
- roles.stream().map(KeycloakRoleRepresentation::getName).toList());
- }
- return hasRole;
- })
- .doOnError(e -> log.error(
- "Error during permission check for userSub '{}', "
- + "client '{}', role '{}': {}",
- userSub, targetServiceClientId, candidateRoleNames, e.getMessage(), e))
- // If any error occurs during the admin calls (client not found, user roles fetch
- // fail), treat as permission denied.
- // Or, you could throw a specific PermissionCheckException to be handled by
- // GlobalExceptionHandler.
- .onErrorResume(e -> {
- log.error("Permission check failed due to an error, defaulting to DENIED: {}",
- e.getMessage());
- return Mono.just(false); // Default to false on error to be safe
- });
- }
-
-
@Override
public Mono registerUser(RegistrationRequest registrationRequest) {
log.info("Registering new user with email: {}", registrationRequest.getEmail());
@@ -300,4 +265,50 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
registrationRequest.getEmail(), e.getMessage()))
.then();
}
+
+ @Override
+ public Mono checkResourcePermission(
+ final String reqAccessToken, final String clientId,
+ final String resourceUri, final String scope
+ ) {
+ MultiValueMap formData = new LinkedMultiValueMap<>();
+ formData.add("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket");
+ formData.add("permission_resource_format", "uri");
+ formData.add("permission_resource_matching_uri", "true");
+ formData.add("audience", clientId);
+ formData.add("permission", resourceUri + "#" + scope);
+
+ return this.webClient.post().uri(getTokenEndpoint())
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .header("Authorization", "Bearer " + reqAccessToken)
+ .body(BodyInserters.fromFormData(formData)).retrieve()
+ .onStatus(HttpStatusCode::is4xxClientError,
+ clientResponse -> clientResponse.bodyToMono(String.class)
+ .flatMap(errBody -> {
+ log.debug("UMA ticket returns status {} with response: {}",
+ clientResponse.statusCode(), errBody);
+ return Mono.error(new PermissionCheckException(
+ "UMA ticket verification failed. Status: "
+ + clientResponse.statusCode()));
+ }))
+ .onStatus(HttpStatusCode::is5xxServerError,
+ clientResponse -> clientResponse.bodyToMono(String.class)
+ .flatMap(errorBody -> {
+ log.error(
+ "Keycloak uma ticket verification failed with"
+ + " status {}: {}",
+ clientResponse.statusCode(), errorBody);
+ return Mono.error(new ServiceUnavailableException(
+ "UMA ticket verification failed. Status: "
+ + clientResponse.statusCode()));
+ }))
+ .bodyToMono(String.class)
+ .flatMap(response -> {
+ log.debug("UMA ticket verification successful for access token {}: {}",
+ reqAccessToken, response);
+ return Mono.just(true);
+ }).doOnError(error -> log.error("Error during uma ticket verification: {}",
+ error.getMessage()))
+ .onErrorResume(PermissionCheckException.class, e -> Mono.just(false));
+ }
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 0efb49e..98d06bd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -15,19 +15,14 @@ spring:
# 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
+ keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
+ keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
+ realm: ${KEYCLOAK_AUTH_REALM:clevermicro-dev} # The realm name you are using
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # 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
-
- # Configuration for auth-service's OWN client to call Keycloak Admin API
- # This client needs service account roles like 'view-clients', 'view-users', 'query-groups'
+ client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!)
+ admin-account-username: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-account} # an admin account for admin operations
+ admin-account-password: ${KEYCLOAK_AUTH_ADMIN_SECRET:6B8KWFQDPOoj88V7xnMYoQIgj9vVfuvw} # Secret for this admin account
auth-service:
- admin-client:
- client-id: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-admin} # A SEPARATE client ID in Keycloak for admin operations
- client-secret: ${KEYCLOAK_AUTH_ADMIN_SECRET:qDqbVqfmCmG6SSrAW3pyqXEpu0bOxWJj} # Secret for this admin client
permission-mapping:
# Rules to map incoming request path prefixes to Keycloak Client IDs (representing services)
# The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
diff --git a/src/test/java/com/cleverthis/authservice/AuthControllerTest.java b/src/test/java/com/cleverthis/authservice/AuthControllerTest.java
index 5f91933..d67daef 100644
--- a/src/test/java/com/cleverthis/authservice/AuthControllerTest.java
+++ b/src/test/java/com/cleverthis/authservice/AuthControllerTest.java
@@ -1,12 +1,9 @@
package com.cleverthis.authservice;
-import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
-import static org.hamcrest.Matchers.hasItems;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
-import com.cleverthis.authservice.config.PermissionMappingConfig;
+import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.controller.AuthController;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
@@ -23,9 +20,6 @@ import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Captor;
-import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
@@ -41,8 +35,8 @@ import reactor.core.publisher.Mono;
*/
@WebFluxTest(AuthController.class)
@Import({GlobalExceptionHandler.class,
- PermissionMappingConfig.class,
- FallbackPermissionMapLookupService.class})
+ PermissionMappingConfigProperties.class,
+ FallbackPermissionMapLookupService.class})
class AuthControllerTest {
@Autowired
@@ -52,10 +46,7 @@ class AuthControllerTest {
private IdentityManagerService identityManagerService;
@MockitoBean
- private PermissionMappingConfig permissionMappingConfig;
-
- @Captor
- private ArgumentCaptor> candidateRolesCaptor;
+ private PermissionMappingConfigProperties permissionMappingConfigProperties;
// required by the fallback permission mapping lookup service
// provide a mocked one
@@ -86,22 +77,22 @@ class AuthControllerTest {
"test@example.com", true, "testuser", List.of("groupA"));
// Setup mock PermissionMappingConfig - Default to having some rules
- PermissionMappingConfig.Rule rule1 = new PermissionMappingConfig.Rule();
+ PermissionMappingConfigProperties.Rule rule1 = new PermissionMappingConfigProperties.Rule();
rule1.setPathPrefix("/serviceA/");
rule1.setKeycloakClientId("serviceA-client");
- PermissionMappingConfig.Rule rule2 = new PermissionMappingConfig.Rule();
+ PermissionMappingConfigProperties.Rule rule2 = new PermissionMappingConfigProperties.Rule();
rule2.setPathPrefix("/serviceB/items/");
rule2.setKeycloakClientId("serviceB-client");
- List rules = new ArrayList<>();
+ List rules = new ArrayList<>();
rules.add(rule1);
rules.add(rule2);
// Mock getRules() to return the list
- when(this.permissionMappingConfig.getRules()).thenReturn(rules);
+ when(this.permissionMappingConfigProperties.getRules()).thenReturn(rules);
// Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
// specific tests
- when(this.permissionMappingConfig.getDefaultKeycloakClientId()).thenReturn(null);
+ when(this.permissionMappingConfigProperties.getDefaultKeycloakClientId()).thenReturn(null);
}
// --- /login Tests ---
@@ -195,9 +186,9 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
- when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
- eq("serviceA-client"), this.candidateRolesCaptor.capture()))
- .thenReturn(Mono.just(true));
+ when(this.identityManagerService.checkResourcePermission(
+ userToken, "serviceA-client", "/api/data", "rest:read"
+ )).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -208,37 +199,6 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
}
-
- @Test
- void forwardAuth_PermissionGranted_SpecificRoleMatch() {
- String userToken = "user-token-granted";
- String originalMethod = "GET";
- // Will be normalized to /api/items/BY_ID
- String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
-
- when(this.identityManagerService.verifyToken(userToken))
- .thenReturn(Mono.just(this.mockUserVerificationResponse));
- // Expect checkUserHasAnyClientRole to be called with a list of candidates
- // For GET /api/items/BY_ID, candidates would be [GET_API_ITEMS_BY_ID, ALL_API_ITEMS_BY_ID,
- // GET_API_ITEMS_STAR, ..., ALL_STAR]
- when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
- eq("serviceA-client"), this.candidateRolesCaptor.capture()))
- .thenReturn(Mono.just(true));
-
- this.webTestClient.post().uri("/auth")
- .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
- .header(HEADER_X_FORWARDED_METHOD, originalMethod)
- .header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
- .expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
-
- // Verify the captured candidate roles
- List capturedRoles = this.candidateRolesCaptor.getValue();
- assertThat(capturedRoles,
- hasItems("GET_API_ITEMS_BY_ID", "ALL_API_ITEMS_BY_ID", "GET_API_ITEMS_STAR",
- "ALL_API_ITEMS_STAR", "GET_API_STAR", "ALL_API_STAR", "GET_STAR",
- "ALL_STAR"));
- // Check order if important, or just presence
- }
@Test
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
@@ -250,9 +210,10 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups));
- when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
- eq("serviceA-client"), this.candidateRolesCaptor.capture()))
- .thenReturn(Mono.just(true));
+ when(this.identityManagerService.checkResourcePermission(
+ validToken, "serviceA-client",
+ "/api/nogroups", "rest:read"
+ )).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
@@ -263,36 +224,6 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
}
-
- @Test
- void forwardAuth_PermissionGranted_WildcardRoleMatch_All_Star() {
- String userToken = "user-token-admin";
- String originalMethod = "DELETE";
- String originalUri = "/serviceA/admin/config"; // -> /admin/config
-
- when(this.identityManagerService.verifyToken(userToken))
- .thenReturn(Mono.just(this.mockUserVerificationResponse));
- // User has ALL_STAR, so this should pass
- when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
- eq("serviceA-client"), anyStringList() // Match any list of strings
- )).thenAnswer(invocation -> {
- List candidates = invocation.getArgument(2);
- // Simulate Keycloak returning true if ALL_STAR is among candidates and user has it
- // For this test, we assume the service method checkUserHasAnyClientRole correctly finds
- // ALL_STAR
- // if the user has it and it's in the candidate list.
- // Here, we just return true because the test name implies it should be granted.
- // A more robust mock would check if "ALL_STAR" is in `candidates` if the user had
- // ALL_STAR.
- return Mono.just(true);
- });
-
- this.webTestClient.post().uri("/auth")
- .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
- .header(HEADER_X_FORWARDED_METHOD, originalMethod)
- .header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
- .expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
- }
@Test
void forwardAuth_PermissionDenied_NoMatchingRole() {
@@ -302,9 +233,10 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
- when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
- eq("serviceA-client"), anyStringList() // Match any list of strings
- )).thenReturn(Mono.just(false)); // Service reports no matching role found
+ when(this.identityManagerService.checkResourcePermission(
+ userToken, "serviceA-client",
+ "/restricted/action", "rest:create"
+ )).thenReturn(Mono.just(false));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -313,27 +245,6 @@ class AuthControllerTest {
.expectBody().isEmpty();
}
- @Test
- void forwardAuth_RootPath_PermissionGranted() {
- String userToken = "user-token-root-access";
- String originalMethod = "GET";
- String originalUri = "/serviceA/"; // Root path for serviceA
-
- when(this.identityManagerService.verifyToken(userToken))
- .thenReturn(Mono.just(this.mockUserVerificationResponse));
- when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"),
- eq("serviceA-client"), this.candidateRolesCaptor.capture()))
- .thenReturn(Mono.just(true));
-
- this.webTestClient.post().uri("/auth")
- .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
- .header(HEADER_X_FORWARDED_METHOD, originalMethod)
- .header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk();
-
- List capturedRoles = this.candidateRolesCaptor.getValue();
- assertThat(capturedRoles, hasItems("GET_ROOT", "ALL_ROOT", "GET_STAR", "ALL_STAR"));
- }
-
@Test
void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
String invalidUserToken = "invalid-user-token";
@@ -456,9 +367,4 @@ class AuthControllerTest {
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
-
- // Helper to match any List in Mockito
- private static List anyStringList() {
- return Mockito.anyList();
- }
}
diff --git a/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java b/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java
new file mode 100644
index 0000000..f077fc6
--- /dev/null
+++ b/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java
@@ -0,0 +1,33 @@
+package com.cleverthis.authservice.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest(properties = {
+ "keycloak.keycloak-host=https://keycloak.example.com",
+ "keycloak.keycloak-admin-host=https://keycloak-admin.example.com",
+ "keycloak.realm=cm-test",
+ "keycloak.client-id=test-service",
+ "keycloak.client-secret=secret-here",
+ "keycloak.admin-account-username=admin",
+ "keycloak.admin-account-password=admin-passwd"
+})
+class KeycloakClientConfigPropertiesTest {
+
+ @Autowired
+ private KeycloakClientConfigProperties config;
+
+ @Test
+ void testConfigParsing() {
+ assertEquals("https://keycloak.example.com", this.config.getKeycloakHost());
+ assertEquals("https://keycloak-admin.example.com", this.config.getKeycloakAdminHost());
+ assertEquals("cm-test", this.config.getRealm());
+ assertEquals("test-service", this.config.getClientId());
+ assertEquals("secret-here", this.config.getClientSecret());
+ assertEquals("admin", this.config.getAdminAccountUsername());
+ assertEquals("admin-passwd", this.config.getAdminAccountPassword());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigTest.java b/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigTest.java
new file mode 100644
index 0000000..a447dde
--- /dev/null
+++ b/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigTest.java
@@ -0,0 +1,12 @@
+package com.cleverthis.authservice.config;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+import org.junit.jupiter.api.Test;
+
+class KeycloakClientConfigTest {
+ @Test
+ void testConstructor() {
+ assertDoesNotThrow(KeycloakClientConfig::new);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/cleverthis/authservice/config/WebClientConfigTest.java b/src/test/java/com/cleverthis/authservice/config/WebClientConfigTest.java
new file mode 100644
index 0000000..ae83538
--- /dev/null
+++ b/src/test/java/com/cleverthis/authservice/config/WebClientConfigTest.java
@@ -0,0 +1,15 @@
+package com.cleverthis.authservice.config;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+
+public class WebClientConfigTest {
+
+ private final WebClientConfig config = new WebClientConfig();
+
+ @Test
+ public void testKeycloakWebClientNotNull() {
+ assertNotNull(this.config.keycloakWebClient(), "WebClient bean should not be null");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/cleverthis/authservice/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/cleverthis/authservice/exception/GlobalExceptionHandlerTest.java
new file mode 100644
index 0000000..858d651
--- /dev/null
+++ b/src/test/java/com/cleverthis/authservice/exception/GlobalExceptionHandlerTest.java
@@ -0,0 +1,145 @@
+package com.cleverthis.authservice.exception;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+
+class GlobalExceptionHandlerTest {
+
+ private GlobalExceptionHandler globalExceptionHandler;
+
+ @BeforeEach
+ void setUp() {
+ this.globalExceptionHandler = new GlobalExceptionHandler();
+ }
+
+ @Test
+ void testHandleAuthenticationException_ReturnsProblemDetail() {
+ // Arrange
+ final var errorMessage = "Authentication failed";
+ final var exception = new AuthenticationException(errorMessage);
+
+ // Act
+ final var result = this.globalExceptionHandler.handleAuthenticationException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
+ assertEquals(errorMessage, result.getDetail());
+ assertEquals("Authentication Failed", result.getTitle());
+ }
+
+ @Test
+ void testHandleTokenVerificationException_ReturnsProblemDetail() {
+ // Arrange
+ final var errorMessage = "Token verification failed";
+ final var exception = new TokenVerificationException(errorMessage);
+
+ // Act
+ final var result = this.globalExceptionHandler.handleTokenVerificationException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
+ assertEquals(errorMessage, result.getDetail());
+ assertEquals("Token Verification Failed", result.getTitle());
+ }
+
+ @Test
+ void testHandleLogoutException_ReturnsProblemDetail() {
+ // Arrange
+ final var errorMessage = "Logout failed";
+ final var exception = new LogoutException(errorMessage);
+
+ // Act
+ final var result = this.globalExceptionHandler.handleLogoutException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
+ assertEquals(errorMessage, result.getDetail());
+ assertEquals("Logout Failed", result.getTitle());
+ }
+
+ @Test
+ void testHandlePermissionCheckException_ReturnsProblemDetail() {
+ // Arrange
+ final var errorMessage = "Access denied";
+ final var exception = new PermissionCheckException(errorMessage);
+
+ // Act
+ final var result = this.globalExceptionHandler.handlePermissionCheckException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.FORBIDDEN.value(), result.getStatus());
+ assertEquals(errorMessage, result.getDetail());
+ assertEquals("Access Denied", result.getTitle());
+ }
+
+ @Test
+ void testHandleServiceUnavailableException_ReturnsProblemDetail() {
+ // Arrange
+ final var exception = new ServiceUnavailableException("test");
+
+ // Act
+ final var result = this.globalExceptionHandler.handleServiceUnavailableException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.SERVICE_UNAVAILABLE.value(), result.getStatus());
+ assertEquals("An external service required for authorization is currently unavailable.",
+ result.getDetail());
+ assertEquals("Service Unavailable", result.getTitle());
+ }
+
+ @Test
+ void testHandleUserAlreadyExistsException_ReturnsProblemDetail() {
+ // Arrange
+ final var errorMessage = "User already exists";
+ final var exception = new UserAlreadyExistsException(errorMessage);
+
+ // Act
+ final var result = this.globalExceptionHandler.handleUserAlreadyExistsException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.CONFLICT.value(), result.getStatus());
+ assertEquals(errorMessage, result.getDetail());
+ assertEquals("User Registration Conflict", result.getTitle());
+ }
+
+ @Test
+ void testHandleRegistrationException_ReturnsProblemDetail() {
+ // Arrange
+ final var errorMessage = "Registration failed";
+ final var exception = new RegistrationException(errorMessage);
+
+ // Act
+ final var result = this.globalExceptionHandler.handleRegistrationException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
+ assertEquals(errorMessage, result.getDetail());
+ assertEquals("User Registration Failed", result.getTitle());
+ }
+
+ @Test
+ void testHandleGenericException_ReturnsProblemDetail() {
+ // Arrange
+ final var exception = new Exception("Internal error");
+
+ // Act
+ final var result = this.globalExceptionHandler.handleGenericException(exception);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
+ assertEquals("An internal error occurred.", result.getDetail());
+ assertEquals("Internal Server Error", result.getTitle());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java b/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java
index 6dc3e92..c11cd36 100644
--- a/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java
+++ b/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java
@@ -4,7 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.when;
-import com.cleverthis.authservice.config.PermissionMappingConfig;
+import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.ecwid.consul.v1.ConsulClient;
import com.ecwid.consul.v1.Response;
import com.ecwid.consul.v1.kv.model.GetValue;
@@ -55,9 +55,9 @@ class FallbackPermissionMapLookupServiceTest {
assertNull(service.getConfig()); // fallback is null
// has json content
when(value.getDecodedValue())
- .thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfig()));
+ .thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul();
- assertEquals(new PermissionMappingConfig(), service.getConfig());
+ assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
}
/**
@@ -66,7 +66,7 @@ class FallbackPermissionMapLookupServiceTest {
*/
@Test
void testGetDefaultKeycloakClientId() {
- final var defaultConfig = new PermissionMappingConfig();
+ final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setDefaultKeycloakClientId("default-client-id");
final var service = new FallbackPermissionMapLookupService(
@@ -77,11 +77,11 @@ class FallbackPermissionMapLookupServiceTest {
@Test
void testMatchClientIdByPath() {
- final var defaultConfig = new PermissionMappingConfig();
+ final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of(
- PermissionMappingConfig.Rule.builder()
+ PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
- PermissionMappingConfig.Rule.builder()
+ PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
final var service = new FallbackPermissionMapLookupService(
@@ -97,11 +97,11 @@ class FallbackPermissionMapLookupServiceTest {
@Test
void testMatchRuleByClientIdAndPath() {
- final var defaultConfig = new PermissionMappingConfig();
+ final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of(
- PermissionMappingConfig.Rule.builder()
+ PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
- PermissionMappingConfig.Rule.builder()
+ PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
final var service = new FallbackPermissionMapLookupService(
diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java
new file mode 100644
index 0000000..232fbbe
--- /dev/null
+++ b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java
@@ -0,0 +1,191 @@
+package com.cleverthis.authservice.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
+import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
+import com.cleverthis.authservice.exception.ServiceUnavailableException;
+import java.io.IOException;
+import java.util.List;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+class KeycloakAdminClientTest {
+ private MockWebServer mockBackEnd;
+
+ private KeycloakAdminClient adminClient;
+
+ @BeforeEach
+ void setup() throws IOException {
+ // isolate requests by creating one mock server per test
+ this.mockBackEnd = new MockWebServer();
+ this.mockBackEnd.start();
+
+ final var webClient = WebClient.builder().build();
+ final var configProperties = KeycloakClientConfigProperties.builder()
+ .keycloakAdminHost("http://localhost:" + this.mockBackEnd.getPort() + "/")
+ .realm("test-realm")
+ .adminAccountUsername("admin")
+ .adminAccountPassword("admin-password")
+ .build();
+
+ this.adminClient = Mockito.spy(new KeycloakAdminClient(webClient, configProperties));
+
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ this.mockBackEnd.shutdown();
+ }
+
+
+ @Test
+ void getAdminAccessToken_shouldReturnToken_whenRequestIsSuccessful()
+ throws InterruptedException {
+ // Arrange
+ final var tokenResponse = """
+ {
+ "access_token": "mock-access-token",
+ "expires_in": 3600
+ }
+ """;
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setBody(tokenResponse)
+ .addHeader("Content-Type", "application/json")
+ );
+
+ // Act
+ final var result = this.adminClient.getAdminAccessToken();
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNext("mock-access-token")
+ .verifyComplete();
+
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
+ }
+
+ @Test
+ void getClientInternalId_shouldReturnInternalId_whenClientExists() throws InterruptedException {
+ // Arrange
+ final var publicClientId = "test-client";
+ final var internalClientId = "mock-internal-id";
+ Mockito.when(this.adminClient.getAdminAccessToken())
+ .thenReturn(Mono.just("mock-access-token"));
+
+ final var responseBody = """
+ [
+ {
+ "id": "mock-internal-id",
+ "clientId": "test-client"
+ }
+ ]
+ """;
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setBody(responseBody)
+ .addHeader("Content-Type", "application/json")
+ );
+
+ // Act
+ final var result = this.adminClient.getClientInternalId(publicClientId);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNext(internalClientId)
+ .verifyComplete();
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals("/admin/realms/test-realm/clients?clientId=test-client&max=1",
+ recordedRequest.getPath());
+ }
+
+ @Test
+ void getClientInternalId_shouldThrowError_whenClientDoesNotExist() {
+ // Arrange
+ final var publicClientId = "nonexistent-client";
+ final var responseBody = "[]";
+ Mockito.when(this.adminClient.getAdminAccessToken())
+ .thenReturn(Mono.just("mock-access-token"));
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setBody(responseBody)
+ .addHeader("Content-Type", "application/json")
+ );
+
+ // Act
+ final var result = this.adminClient.getClientInternalId(publicClientId);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectErrorMatches(throwable -> throwable instanceof ServiceUnavailableException &&
+ throwable.getMessage().contains("Client not found in Keycloak"))
+ .verify();
+ }
+
+ @Test
+ void getUserEffectiveClientRoles_shouldReturnRoles_whenRequestIsSuccessful()
+ throws InterruptedException {
+ // Arrange
+ final var userId = "test-user";
+ final var clientInternalId = "mock-client-id";
+ Mockito.when(this.adminClient.getAdminAccessToken())
+ .thenReturn(Mono.just("mock-access-token"));
+
+ final var rolesResponse = """
+ [
+ {"name": "role1"},
+ {"name": "role2"}
+ ]
+ """;
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setBody(rolesResponse)
+ .addHeader("Content-Type", "application/json")
+ );
+
+ // Act
+ final var result = this.adminClient.getUserEffectiveClientRoles(userId, clientInternalId);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNextMatches(roles -> roles.stream().map(KeycloakRoleRepresentation::getName)
+ .toList().containsAll(List.of("role1", "role2")))
+ .verifyComplete();
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals(
+ "/admin/realms/test-realm/users/test-realm/role-mappings/clients/test-user/composite",
+ recordedRequest.getPath());
+ }
+
+ @Test
+ void getUserEffectiveClientRoles_shouldReturnEmptyList_whenRolesNotFound() {
+ // Arrange
+ final var userId = "test-user";
+ final var clientInternalId = "mock-client-id";
+ Mockito.when(this.adminClient.getAdminAccessToken())
+ .thenReturn(Mono.just("mock-access-token"));
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setBody("[]")
+ .addHeader("Content-Type", "application/json")
+ );
+
+ // Act
+ final var result = this.adminClient.getUserEffectiveClientRoles(userId, clientInternalId);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNextMatches(List::isEmpty)
+ .verifyComplete();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java
new file mode 100644
index 0000000..c84fa4a
--- /dev/null
+++ b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java
@@ -0,0 +1,249 @@
+package com.cleverthis.authservice.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
+import com.cleverthis.authservice.dto.TokenResponse;
+import com.cleverthis.authservice.dto.VerificationResponse;
+import com.cleverthis.authservice.exception.AuthenticationException;
+import com.cleverthis.authservice.exception.TokenVerificationException;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.test.StepVerifier;
+
+class KeycloakIdentityManagerServiceTest {
+ private MockWebServer mockBackEnd;
+ private KeycloakAdminClient adminClient;
+ private KeycloakIdentityManagerService service;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @BeforeEach
+ void setup() throws IOException {
+ // isolate requests by creating one mock server per test
+ this.mockBackEnd = new MockWebServer();
+ this.mockBackEnd.start();
+
+ final var webClient = WebClient.builder().build();
+ final var configProperties = KeycloakClientConfigProperties.builder()
+ .keycloakHost("http://localhost:" + this.mockBackEnd.getPort() + "/")
+ .realm("test-realm")
+ .clientId("test-client")
+ .clientSecret("client-password")
+ .build();
+ this.adminClient = Mockito.mock(KeycloakAdminClient.class);
+ this.service = Mockito.spy(new KeycloakIdentityManagerService(
+ webClient, this.adminClient, configProperties));
+
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ this.mockBackEnd.shutdown();
+ }
+
+ @Test
+ void login_success() throws JsonProcessingException, InterruptedException {
+ // Arrange
+ final var username = "test-user";
+ final var password = "test-password";
+ final var mockTokenResponse = new TokenResponse("access-token", 3600, 3600,
+ "refresh-token", "bearer", "id-token", 0, "session-id", "openid");
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setBody(this.objectMapper.writeValueAsString(mockTokenResponse))
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result = this.service.login(username, password);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNextMatches(response -> response.getAccessToken().equals("access-token") &&
+ response.getRefreshToken().equals("refresh-token"))
+ .verifyComplete();
+
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
+ }
+
+ @Test
+ void login_failure_invalidCredentials() {
+ // Arrange
+ final var username = "wrong-user";
+ final var password = "wrong-password";
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(401)
+ .setBody(
+ "{\"error\":\"invalid_grant\",\"error_description\":\"Invalid user credentials\"}")
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result = this.service.login(username, password);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectErrorMatches(throwable -> throwable instanceof AuthenticationException &&
+ throwable.getMessage()
+ .contains("Login failed: Invalid credentials or Keycloak error."))
+ .verify();
+ }
+
+ @Test
+ void verifyToken_success() throws JsonProcessingException, InterruptedException {
+ // Arrange
+ final var accessToken = "valid-access-token";
+ final var mockVerificationResponse = VerificationResponse.builder()
+ .clientId("test-client-id")
+ .active(true)
+ .emailVerified(true)
+ .preferredUsername("test-user")
+ .sub("user-sub")
+ .build();
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result = this.service.verifyToken(accessToken);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNextMatches(response -> "test-client-id".equals(response.getClientId()) &&
+ "test-user".equals(response.getPreferredUsername()) &&
+ Boolean.TRUE.equals(response.getEmailVerified()))
+ .verifyComplete();
+
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals("/realms/test-realm/protocol/openid-connect/token/introspect",
+ recordedRequest.getPath());
+ }
+
+ @Test
+ void verifyToken_failure_inactiveToken() throws JsonProcessingException {
+ // Arrange
+ final var accessToken = "inactive-access-token";
+ final var mockVerificationResponse = VerificationResponse.builder()
+ .sub("user-sub")
+ .active(false)
+ .username("test-user")
+ .build();
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result = this.service.verifyToken(accessToken);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectErrorMatches(throwable -> throwable instanceof TokenVerificationException &&
+ throwable.getMessage().contains("Token is invalid or expired."))
+ .verify();
+ }
+
+ @Test
+ void logout_success() throws InterruptedException {
+ // Arrange
+ final var refreshToken = "valid-refresh-token";
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result = this.service.logout(refreshToken);
+
+ // Assert
+ StepVerifier.create(result)
+ .verifyComplete();
+
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals("/realms/test-realm/protocol/openid-connect/revoke",
+ recordedRequest.getPath());
+ }
+
+ @Test
+ void logout_failure_invalidToken() {
+ // Arrange
+ final var refreshToken = "invalid-refresh-token";
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(400)
+ .setBody(
+ "{\"error\":\"invalid_request\",\"error_description\":\"Invalid token or token has been revoked\"}")
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result = this.service.logout(refreshToken);
+
+ // Assert
+ StepVerifier.create(result)
+ .verifyComplete(); // Expecting no error due to handling of 400 as a soft failure
+ }
+
+ @Test
+ void checkResourcePermission_success() throws InterruptedException {
+ // Arrange
+ final var accessToken = "valid-token";
+ final var clientId = "resource-client";
+ final var resourceUri = "/resources/123";
+ final var scope = "read";
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setBody("{\"response\":\"success\"}")
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result =
+ this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNext(true)
+ .verifyComplete();
+
+ RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
+ assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
+ assertEquals("Bearer valid-token", recordedRequest.getHeader("Authorization"));
+ }
+
+ @Test
+ void checkResourcePermission_permissionDenied() {
+ // Arrange
+ final var accessToken = "valid-token";
+ final var clientId = "resource-client";
+ final var resourceUri = "/resources/123";
+ final var scope = "read";
+
+ this.mockBackEnd.enqueue(new MockResponse()
+ .setResponseCode(403)
+ .setBody("{\"error\":\"permission_denied\"}")
+ .addHeader("Content-Type", "application/json"));
+
+ // Act
+ final var result =
+ this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
+
+ // Assert
+ StepVerifier.create(result)
+ .expectNext(false)
+ .verifyComplete();
+ }
+}
\ No newline at end of file