From 0aeb9af6960fd64cd015014c55234299e2d282ee Mon Sep 17 00:00:00 2001 From: Rui Hu Date: Fri, 13 Jun 2025 15:13:05 +0800 Subject: [PATCH 1/3] refactor(General): replace role-based auth with UMA ticket add config properties, adapt setup from clevermicro/identity-management#9 replace role-based forward auth with UMA ticket add pass-through config implement unit tests for the change. ISSUES CLOSED: clevermicro/user-management#28 --- pom.xml | 6 + .../config/KeycloakClientConfig.java | 15 + .../KeycloakClientConfigProperties.java | 71 ++++ ...=> PermissionMappingConfigProperties.java} | 38 ++- .../controller/AuthController.java | 303 +++++++----------- .../authservice/dto/VerificationResponse.java | 2 + .../exception/GlobalExceptionHandler.java | 4 +- .../service/IdentityManagerService.java | 20 +- .../service/PermissionMapLookupService.java | 12 +- .../FallbackPermissionMapLookupService.java | 37 ++- .../service/impl/KeycloakAdminClient.java | 72 +++-- .../impl/KeycloakIdentityManagerService.java | 163 +++++----- src/main/resources/application.yml | 14 +- .../authservice/AuthControllerTest.java | 134 ++------ .../KeycloakClientConfigPropertiesTest.java | 33 ++ .../config/KeycloakClientConfigTest.java | 12 + .../config/WebClientConfigTest.java | 15 + .../exception/GlobalExceptionHandlerTest.java | 145 +++++++++ ...allbackPermissionMapLookupServiceTest.java | 20 +- .../service/impl/KeycloakAdminClientTest.java | 191 +++++++++++ .../KeycloakIdentityManagerServiceTest.java | 249 ++++++++++++++ 21 files changed, 1099 insertions(+), 457 deletions(-) create mode 100644 src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java create mode 100644 src/main/java/com/cleverthis/authservice/config/KeycloakClientConfigProperties.java rename src/main/java/com/cleverthis/authservice/config/{PermissionMappingConfig.java => PermissionMappingConfigProperties.java} (57%) create mode 100644 src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java create mode 100644 src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigTest.java create mode 100644 src/test/java/com/cleverthis/authservice/config/WebClientConfigTest.java create mode 100644 src/test/java/com/cleverthis/authservice/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java create mode 100644 src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java 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..5f4e0f9 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,46 @@ 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(HttpHeaders.AUTHORIZATION) 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()); + } + // 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 +208,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 +322,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 +362,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..9b054b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,18 +16,12 @@ 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 + 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 -- 2.52.0 From 8f8b507e01c320eeec1ee9c44a9c3f56d95d8404 Mon Sep 17 00:00:00 2001 From: Rui Hu Date: Fri, 20 Jun 2025 16:18:25 +0800 Subject: [PATCH 2/3] fix(General): fix application yaml --- src/main/resources/application.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9b054b6..98d06bd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,7 +15,8 @@ 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) + 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:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!) -- 2.52.0 From bcd6c8dccf77ea230be0b0c0c90357e313fd0e9b Mon Sep 17 00:00:00 2001 From: Rui Hu Date: Fri, 20 Jun 2025 17:55:24 +0800 Subject: [PATCH 3/3] fix(General): make auth header optional for forward auth endpoint --- .../cleverthis/authservice/controller/AuthController.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/cleverthis/authservice/controller/AuthController.java b/src/main/java/com/cleverthis/authservice/controller/AuthController.java index 5f4e0f9..90a1804 100644 --- a/src/main/java/com/cleverthis/authservice/controller/AuthController.java +++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java @@ -179,7 +179,8 @@ public class AuthController { RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.PATCH}) public Mono> forwardAuth( - @RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader, + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) + String authorizationHeader, @RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod, @RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString ) { @@ -199,6 +200,9 @@ public class AuthController { 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) { -- 2.52.0