Implement UMA ticket for forward-auth #31

Merged
hurui200320 merged 3 commits from feat/28 into develop 2025-06-30 09:05:16 +00:00
21 changed files with 1105 additions and 458 deletions
+6
View File
@@ -72,6 +72,12 @@
<groupId>org.springframework.cloud</groupId> <groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId> <artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -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
}
@@ -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 = "";
}
@@ -20,10 +20,10 @@ import org.springframework.validation.annotation.Validated;
@Component @Component
@ConfigurationProperties(prefix = "auth-service.permission-mapping") @ConfigurationProperties(prefix = "auth-service.permission-mapping")
@Data @Data
@Validated @Validated
public class PermissionMappingConfig { public class PermissionMappingConfigProperties {
@Valid @Valid
private List<Rule> rules = new ArrayList<>(); private List<Rule> rules = new ArrayList<>();
private String defaultKeycloakClientId; // Optional default private String defaultKeycloakClientId; // Optional default
@@ -39,5 +39,37 @@ public class PermissionMappingConfig {
private String pathPrefix; private String pathPrefix;
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule") @NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
private String keycloakClientId; private String keycloakClientId;
private List<PassThrough> 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
}
} }
} }
@@ -1,6 +1,6 @@
package com.cleverthis.authservice.controller; 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.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest; import com.cleverthis.authservice.dto.LogoutRequest;
import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.TokenResponse;
@@ -11,12 +11,8 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.ArrayList; import java.util.Map;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -165,24 +161,50 @@ public class AuthController {
return headers; return headers;
} }
private static final Map<String, String> 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 * 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. * 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 * On success, returns 200 OK with user metadata in headers. On failure, returns 401
* Unauthorized or 403 Forbidden. * Unauthorized or 403 Forbidden.
*/ */
@RequestMapping(value = "/auth", method = { RequestMethod.GET, RequestMethod.POST, @RequestMapping(value = "/auth", method = {
RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.GET, RequestMethod.POST,
RequestMethod.PATCH }) RequestMethod.PUT, RequestMethod.DELETE,
public Mono<ResponseEntity<? extends Object>> forwardAuth( RequestMethod.PATCH})
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader, public Mono<ResponseEntity<?>> forwardAuth(
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod, @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false)
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString) { String authorizationHeader,
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString
) {
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod, log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
originalUriString); originalUriString);
// first detect the client id and service relative path
String accessToken = extractBearerToken(authorizationHeader); 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) { if (accessToken == null) {
log.warn("ForwardAuth: Missing or invalid Bearer token format."); log.warn("ForwardAuth: Missing or invalid Bearer token format.");
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); 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) // Step 1: Validate the user's token and get user details (especially userSub)
return this.identityManagerService.verifyToken(accessToken) return this.identityManagerService.verifyToken(accessToken)
.flatMap(verificationResponse -> { .flatMap(verificationResponse -> {
// Token is valid and active, proceed to permission check // Token is valid and active, proceed to permission check
String userSub = verificationResponse.getSub(); final var userSub = verificationResponse.getSub();
if (userSub == null || userSub.isBlank()) { 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)
.<Void>build());
} else {
log.warn(
"ForwardAuth: DENIED for User='{}',"
+ " Resource='{}', Scope='{}', Client='{}'",
verificationResponse.getUsername(),
serviceRelativePath, scope,
targetServiceClientId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<Void>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( log.error(
"ForwardAuth: User subject (sub) " "ForwardAuth: Generic error/exception during"
+ "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<String> 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<String> 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).<Void>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)
.<Void>build());
} else {
log.warn(
"ForwardAuth: DENIED for UserSub='{}',"
+ " Role='{}', Client='{}'",
userSub, candidateRoleNames, targetServiceClientId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<Void>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"
+ " permission processing: {}", + " permission processing: {}",
t.getMessage(), t); t.getMessage(), t);
return Mono.just( return Mono.just(
ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>build()); ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>build());
}) })
.onErrorResume(Exception.class, e -> { // Catch other unexpected errors .onErrorResume(Exception.class, e -> { // Catch other unexpected errors
log.error("ForwardAuth: Internal error during permission check: {}", log.error("ForwardAuth: Internal error during permission check: {}",
e.getMessage(), e); e.getMessage(), e);
return Mono return Mono
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build()); .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<String> getTargetServiceClientId(String fullUriString) { private Optional<String> getTargetServiceClientId(String fullUriString) {
@@ -293,7 +326,7 @@ public class AuthController {
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) { private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix // Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
Optional<PermissionMappingConfig.Rule> matchedRule = Optional<PermissionMappingConfigProperties.Rule> matchedRule =
this.permissionMapLookupService.matchRuleByClientIdAndPath( this.permissionMapLookupService.matchRuleByClientIdAndPath(
mappedKeycloakClientId, fullUriString mappedKeycloakClientId, fullUriString
); );
@@ -333,96 +366,4 @@ public class AuthController {
return ""; // Or throw 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<String> generateCandidateRoleNames(String httpMethod, String relativePath) {
final String httpMethodUpper = httpMethod.toUpperCase();
final Set<String> 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<String> 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<String> orderedCandidates = new ArrayList<>(candidates);
log.debug("Generated candidate roles for method '{}', relative path '{}': {}", httpMethod,
relativePath, orderedCandidates);
return orderedCandidates;
}
} }
@@ -3,6 +3,7 @@ package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -13,6 +14,7 @@ import lombok.NoArgsConstructor;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder
public class VerificationResponse { public class VerificationResponse {
private boolean active; // Standard introspection field: is the token active? private boolean active; // Standard introspection field: is the token active?
@JsonProperty("client_id") @JsonProperty("client_id")
@@ -68,7 +68,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ServiceUnavailableException.class) // New Handler @ExceptionHandler(ServiceUnavailableException.class) // New Handler
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) { public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
// Error communicating with Keycloak Admin API or other critical backend // 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 problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"An external service required for authorization is currently unavailable."); "An external service required for authorization is currently unavailable.");
@@ -104,7 +104,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
*/ */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ProblemDetail handleGenericException(Exception ex) { 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( ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred."); HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
problemDetail.setTitle("Internal Server Error"); problemDetail.setTitle("Internal Server Error");
@@ -4,8 +4,7 @@ package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest; import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse; import com.cleverthis.authservice.dto.VerificationResponse;
import java.util.List; import reactor.core.publisher.Mono;
import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
/** /**
* Interface defining operations for interacting with an identity provider. This abstraction allows * Interface defining operations for interacting with an identity provider. This abstraction allows
@@ -41,19 +40,6 @@ public interface IdentityManagerService {
* revocation fails. * revocation fails.
*/ */
Mono<Void> logout(String refreshToken); Mono<Void> 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<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
List<String> candidateRoleNames);
/** /**
* Registers a new user in the identity provider. * Registers a new user in the identity provider.
@@ -66,4 +52,8 @@ public interface IdentityManagerService {
* Emits an error (e.g., UserAlreadyExistsException) if registration fails. * Emits an error (e.g., UserAlreadyExistsException) if registration fails.
*/ */
Mono<Void> registerUser(RegistrationRequest registrationRequest); Mono<Void> registerUser(RegistrationRequest registrationRequest);
Mono<Boolean> checkResourcePermission(
String reqAccessToken, String clientId, String resourceUri, String scope
);
} }
@@ -1,6 +1,7 @@
package com.cleverthis.authservice.service; 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; 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, * Given the client id and path, find the rule that both matches the client id exactly,
* and match the path prefix. * and match the path prefix.
*/ */
Optional<PermissionMappingConfig.Rule> matchRuleByClientIdAndPath(String clientId, String path); Optional<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
String clientId, String path);
/**
* Return the list of pass-through rules by client id.
*/
List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
String clientId);
} }
@@ -1,10 +1,11 @@
package com.cleverthis.authservice.service.impl; 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.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient; import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -29,30 +30,31 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
*/ */
static final String PERMISSION_MAPPING_CONSUL_KEY = static final String PERMISSION_MAPPING_CONSUL_KEY =
"cleverthis/clevermicro/auth-service/config/permission-mapping"; "cleverthis/clevermicro/auth-service/config/permission-mapping";
private final PermissionMappingConfig permissionMappingConfig; private final PermissionMappingConfigProperties permissionMappingConfigProperties;
private final ConsulClient consulClient; private final ConsulClient consulClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** /**
* Create an instance of {@link FallbackPermissionMapLookupService}. * Create an instance of {@link FallbackPermissionMapLookupService}.
* *
* @param permissionMappingConfig the local config, used as fallback. * @param permissionMappingConfigProperties the local config, used as fallback.
* @param consulClient the consul client, used for query consul value. * @param consulClient the consul client, used for query consul value.
* @param objectMapper for deserializing the value from consul. * @param objectMapper for deserializing the value from consul.
*/ */
@Autowired @Autowired
public FallbackPermissionMapLookupService( public FallbackPermissionMapLookupService(
final PermissionMappingConfig permissionMappingConfig, final PermissionMappingConfigProperties permissionMappingConfigProperties,
final ConsulClient consulClient, final ConsulClient consulClient,
final ObjectMapper objectMapper final ObjectMapper objectMapper
) { ) {
this.permissionMappingConfig = permissionMappingConfig; this.permissionMappingConfigProperties = permissionMappingConfigProperties;
this.consulClient = consulClient; this.consulClient = consulClient;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
refreshConsul(); // initial refresh refreshConsul(); // initial refresh
} }
private final AtomicReference<PermissionMappingConfig> consulValue = new AtomicReference<>(); private final AtomicReference<PermissionMappingConfigProperties> consulValue =
new AtomicReference<>();
@Scheduled(initialDelay = 1000, fixedRate = 1000) @Scheduled(initialDelay = 1000, fixedRate = 1000)
void refreshConsul() { void refreshConsul() {
@@ -71,7 +73,7 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
} }
try { // try decoding try { // try decoding
final var config = this.objectMapper.readValue( final var config = this.objectMapper.readValue(
kvValue.getDecodedValue(), PermissionMappingConfig.class); kvValue.getDecodedValue(), PermissionMappingConfigProperties.class);
this.consulValue.set(config); this.consulValue.set(config);
} catch (final JsonProcessingException ex) { } catch (final JsonProcessingException ex) {
// got a corrupted value, leaving the cache empty // 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(); final var consulValue = this.consulValue.get();
if (consulValue != null) { if (consulValue != null) {
return consulValue; return consulValue;
} else { } else {
return this.permissionMappingConfig; return this.permissionMappingConfigProperties;
} }
} }
@@ -113,7 +115,7 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
} }
@Override @Override
public Optional<PermissionMappingConfig.Rule> matchRuleByClientIdAndPath( public Optional<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
final String clientId, final String path final String clientId, final String path
) { ) {
final var config = getConfig(); final var config = getConfig();
@@ -123,4 +125,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
&& path.startsWith(rule.getPathPrefix())) && path.startsWith(rule.getPathPrefix()))
.findFirst(); .findFirst();
} }
@Override
public List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
final String clientId
) {
final var config = getConfig();
return config.getRules().stream()
.filter(rule -> rule.getKeycloakClientId().equals(clientId))
.flatMap(rule -> rule.getPassthroughs().stream())
.toList();
}
} }
@@ -1,5 +1,6 @@
package com.cleverthis.authservice.service.impl; package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse; import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse;
import com.cleverthis.authservice.dto.KeycloakClientRepresentation; import com.cleverthis.authservice.dto.KeycloakClientRepresentation;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation; import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
@@ -10,7 +11,6 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -27,41 +27,52 @@ import reactor.util.retry.Retry;
* auth-service and making admin calls. * auth-service and making admin calls.
*/ */
@Service @Service
@Slf4j public class KeycloakAdminClient { @Slf4j
public class KeycloakAdminClient {
private final WebClient webClient; private final WebClient webClient;
private final AtomicReference<String> adminAccessToken = new AtomicReference<>(); private final AtomicReference<String> adminAccessToken = new AtomicReference<>();
private final AtomicReference<Long> tokenExpiryTime = new AtomicReference<>(0L); private final AtomicReference<Long> tokenExpiryTime = new AtomicReference<>(0L);
@Value("${keycloak.server-url}") private final String keycloakAdminHost;
private String keycloakServerUrl; private final String realm;
@Value("${keycloak.realm}") private final String adminAccountUsername;
private String realm; private final String adminAccountPassword;
// Credentials for auth-service's own client to call Admin API /**
@Value("${auth-service.admin-client.client-id}") * Create a keycloak admin client.
private String adminServiceClientId; */
@Value("${auth-service.admin-client.client-secret}") public KeycloakAdminClient(
private String adminServiceClientSecret; @Qualifier("keycloakWebClient") final WebClient webClient,
final KeycloakClientConfigProperties configProperties
public KeycloakAdminClient(@Qualifier("keycloakWebClient") WebClient webClient) { ) {
this.webClient = webClient; this.webClient = webClient;
this.keycloakAdminHost = configProperties.getKeycloakAdminHost();
this.realm = configProperties.getRealm();
this.adminAccountUsername = configProperties.getAdminAccountUsername();
this.adminAccountPassword = configProperties.getAdminAccountPassword();
} }
private String getAdminTokenEndpoint() { private String getAdminTokenEndpoint() {
// Ensure no double slashes if keycloakServerUrl itself has a trailing slash (it shouldn't) return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
return String.format("%s/realms/%s/protocol/openid-connect/token", .path("/realms/{realm}/protocol/openid-connect/token")
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm); .buildAndExpand(this.realm)
.encode().toUriString();
} }
private String getClientsEndpoint() { private String getClientsEndpoint() {
return String.format("%s/admin/realms/%s/clients", return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm); .path("/admin/realms/{realm}/clients")
.buildAndExpand(this.realm)
.encode().toUriString();
} }
private String getUserClientRolesEndpoint(String userId, String clientInternalId) { private String getUserClientRolesEndpoint(String userId, String clientInternalId) {
return String.format("%s/admin/realms/%s/users/%s/role-mappings/clients/%s/composite", return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm, userId, clientInternalId); .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. * credentials grant.
* *
* @return Mono emitting the admin access token. * @return Mono emitting the admin access token.
*
*/ */
Mono<String> getAdminAccessToken() { Mono<String> getAdminAccessToken() {
if (System.currentTimeMillis() < this.tokenExpiryTime.get() if (System.currentTimeMillis() < this.tokenExpiryTime.get()
@@ -78,12 +88,13 @@ import reactor.util.retry.Retry;
return Mono.just(this.adminAccessToken.get()); return Mono.just(this.adminAccessToken.get());
} }
log.info("Fetching new admin access token for auth-service using client_id: {}", log.info("Fetching new admin access token for auth-service using account: {}",
this.adminServiceClientId); this.adminAccountUsername);
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>(); MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.adminServiceClientId); formData.add("client_id", "admin-cli");
formData.add("client_secret", this.adminServiceClientSecret); formData.add("username", this.adminAccountUsername);
formData.add("grant_type", "client_credentials"); formData.add("password", this.adminAccountPassword);
formData.add("grant_type", "password");
return this.webClient.post().uri(getAdminTokenEndpoint()) return this.webClient.post().uri(getAdminTokenEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED) .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). * 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. * @param clientInternalId The internal UUID of the Keycloak client.
* @return Mono emitting a List of KeycloakRoleRepresentation. * @return Mono emitting a List of KeycloakRoleRepresentation.
*/ */
public Mono<List<KeycloakRoleRepresentation>> getUserEffectiveClientRoles(String userId, public Mono<List<KeycloakRoleRepresentation>> getUserEffectiveClientRoles(
String clientInternalId) { String userId, String clientInternalId
) {
log.debug("Fetching effective client roles for user '{}' on client internal ID '{}'", log.debug("Fetching effective client roles for user '{}' on client internal ID '{}'",
userId, clientInternalId); userId, clientInternalId);
return getAdminAccessToken().flatMap(token -> this.webClient.get() return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getUserClientRolesEndpoint(userId, clientInternalId)) .uri(getUserClientRolesEndpoint(userId, clientInternalId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
@@ -1,24 +1,25 @@
package com.cleverthis.authservice.service.impl; 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.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse; import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.exception.AuthenticationException; import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.LogoutException; import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.PermissionCheckException;
import com.cleverthis.authservice.exception.RegistrationException; import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException; import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService; import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap; 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.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
/** /**
@@ -33,49 +35,60 @@ import reactor.core.publisher.Mono;
* Keycloak's REST API endpoints. * Keycloak's REST API endpoints.
*/ */
@Service @Service
@Slf4j @Slf4j
public class KeycloakIdentityManagerService implements IdentityManagerService { public class KeycloakIdentityManagerService implements IdentityManagerService {
private final WebClient webClient; private final WebClient webClient;
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
@Value("${keycloak.server-url}") private final String keycloakServerUrl;
private String keycloakServerUrl; private final String realm;
@Value("${keycloak.realm}") private final String clientId;
private String realm; private final String clientSecret;
@Value("${keycloak.client-id}")
private String clientId;
@Value("${keycloak.client-secret}")
private String clientSecret;
@Value("${keycloak.grant-type}")
private String grantType; // Should be 'password' for ROPC
private String getTokenEndpoint() { private String getTokenEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/token", this.keycloakServerUrl, return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
this.realm); .path("/realms/{realm}/protocol/openid-connect/token")
.buildAndExpand(this.realm)
.encode().toUriString();
} }
private String getIntrospectionEndpoint() { private String getIntrospectionEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/token/introspect", return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
this.keycloakServerUrl, this.realm); .path("/realms/{realm}/protocol/openid-connect/token/introspect")
.buildAndExpand(this.realm)
.encode().toUriString();
} }
private String getRevocationEndpoint() { private String getRevocationEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl, return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
this.realm); .path("/realms/{realm}/protocol/openid-connect/revoke")
.buildAndExpand(this.realm)
.encode().toUriString();
} }
private String getUsersAdminEndpoint() { private String getUsersAdminEndpoint() {
return String.format("%s/admin/realms/%s/users", return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm); .path("/admin/realms/{realm}/users")
.buildAndExpand(this.realm)
.encode().toUriString();
} }
/**
* Create a keycloak OIDC client.
*/
@Autowired @Autowired
public KeycloakIdentityManagerService(WebClient keycloakWebClient, public KeycloakIdentityManagerService(
KeycloakAdminClient keycloakAdminClient) { final WebClient keycloakWebClient,
final KeycloakAdminClient keycloakAdminClient,
final KeycloakClientConfigProperties configProperties
) {
this.webClient = keycloakWebClient; this.webClient = keycloakWebClient;
this.keycloakAdminClient = keycloakAdminClient; this.keycloakAdminClient = keycloakAdminClient;
this.keycloakServerUrl = configProperties.getKeycloakHost();
this.realm = configProperties.getRealm();
this.clientId = configProperties.getClientId();
this.clientSecret = configProperties.getClientSecret();
} }
@Override @Override
@@ -84,7 +97,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>(); MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.clientId); formData.add("client_id", this.clientId);
formData.add("client_secret", this.clientSecret); 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("username", username);
formData.add("password", password); formData.add("password", password);
formData.add("scope", "openid email profile"); // Request standard scopes 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())); .doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
} }
@Override
public Mono<Boolean> checkUserHasAnyClientRole(String userSub, String targetServiceClientId,
List<String> 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 @Override
public Mono<Void> registerUser(RegistrationRequest registrationRequest) { public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
log.info("Registering new user with email: {}", registrationRequest.getEmail()); log.info("Registering new user with email: {}", registrationRequest.getEmail());
@@ -300,4 +265,50 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
registrationRequest.getEmail(), e.getMessage())) registrationRequest.getEmail(), e.getMessage()))
.then(); .then();
} }
@Override
public Mono<Boolean> checkResourcePermission(
final String reqAccessToken, final String clientId,
final String resourceUri, final String scope
) {
MultiValueMap<String, String> 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));
}
} }
+6 -11
View File
@@ -15,19 +15,14 @@ spring:
# Keycloak Configuration (adjust values as needed) # Keycloak Configuration (adjust values as needed)
keycloak: 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)
realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using 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-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!) client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!)
# Grant type specific settings (ROPC for /login) admin-account-username: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-account} # an admin account for admin operations
grant-type: password admin-account-password: ${KEYCLOAK_AUTH_ADMIN_SECRET:6B8KWFQDPOoj88V7xnMYoQIgj9vVfuvw} # Secret for this admin account
# 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'
auth-service: 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: permission-mapping:
# Rules to map incoming request path prefixes to Keycloak Client IDs (representing services) # 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") # The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
@@ -1,12 +1,9 @@
package com.cleverthis.authservice; package com.cleverthis.authservice;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo; 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 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.controller.AuthController;
import com.cleverthis.authservice.dto.LoginRequest; import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest; import com.cleverthis.authservice.dto.LogoutRequest;
@@ -23,9 +20,6 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
@@ -41,8 +35,8 @@ import reactor.core.publisher.Mono;
*/ */
@WebFluxTest(AuthController.class) @WebFluxTest(AuthController.class)
@Import({GlobalExceptionHandler.class, @Import({GlobalExceptionHandler.class,
PermissionMappingConfig.class, PermissionMappingConfigProperties.class,
FallbackPermissionMapLookupService.class}) FallbackPermissionMapLookupService.class})
class AuthControllerTest { class AuthControllerTest {
@Autowired @Autowired
@@ -52,10 +46,7 @@ class AuthControllerTest {
private IdentityManagerService identityManagerService; private IdentityManagerService identityManagerService;
@MockitoBean @MockitoBean
private PermissionMappingConfig permissionMappingConfig; private PermissionMappingConfigProperties permissionMappingConfigProperties;
@Captor
private ArgumentCaptor<List<String>> candidateRolesCaptor;
// required by the fallback permission mapping lookup service // required by the fallback permission mapping lookup service
// provide a mocked one // provide a mocked one
@@ -86,22 +77,22 @@ class AuthControllerTest {
"test@example.com", true, "testuser", List.of("groupA")); "test@example.com", true, "testuser", List.of("groupA"));
// Setup mock PermissionMappingConfig - Default to having some rules // Setup mock PermissionMappingConfig - Default to having some rules
PermissionMappingConfig.Rule rule1 = new PermissionMappingConfig.Rule(); PermissionMappingConfigProperties.Rule rule1 = new PermissionMappingConfigProperties.Rule();
rule1.setPathPrefix("/serviceA/"); rule1.setPathPrefix("/serviceA/");
rule1.setKeycloakClientId("serviceA-client"); rule1.setKeycloakClientId("serviceA-client");
PermissionMappingConfig.Rule rule2 = new PermissionMappingConfig.Rule(); PermissionMappingConfigProperties.Rule rule2 = new PermissionMappingConfigProperties.Rule();
rule2.setPathPrefix("/serviceB/items/"); rule2.setPathPrefix("/serviceB/items/");
rule2.setKeycloakClientId("serviceB-client"); rule2.setKeycloakClientId("serviceB-client");
List<PermissionMappingConfig.Rule> rules = new ArrayList<>(); List<PermissionMappingConfigProperties.Rule> rules = new ArrayList<>();
rules.add(rule1); rules.add(rule1);
rules.add(rule2); rules.add(rule2);
// Mock getRules() to return the list // 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 // Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
// specific tests // specific tests
when(this.permissionMappingConfig.getDefaultKeycloakClientId()).thenReturn(null); when(this.permissionMappingConfigProperties.getDefaultKeycloakClientId()).thenReturn(null);
} }
// --- /login Tests --- // --- /login Tests ---
@@ -195,9 +186,9 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(userToken)) when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse)); .thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"), when(this.identityManagerService.checkResourcePermission(
eq("serviceA-client"), this.candidateRolesCaptor.capture())) userToken, "serviceA-client", "/api/data", "rest:read"
.thenReturn(Mono.just(true)); )).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -208,37 +199,6 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader() .valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty(); .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<String> 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 @Test
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
@@ -250,9 +210,10 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(validToken)) when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups)); .thenReturn(Mono.just(responseWithoutGroups));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"), when(this.identityManagerService.checkResourcePermission(
eq("serviceA-client"), this.candidateRolesCaptor.capture())) validToken, "serviceA-client",
.thenReturn(Mono.just(true)); "/api/nogroups", "rest:read"
)).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
@@ -263,36 +224,6 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader() .valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty(); .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<String> 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 @Test
void forwardAuth_PermissionDenied_NoMatchingRole() { void forwardAuth_PermissionDenied_NoMatchingRole() {
@@ -302,9 +233,10 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(userToken)) when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse)); .thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserHasAnyClientRole(eq("user-sub-123"), when(this.identityManagerService.checkResourcePermission(
eq("serviceA-client"), anyStringList() // Match any list of strings userToken, "serviceA-client",
)).thenReturn(Mono.just(false)); // Service reports no matching role found "/restricted/action", "rest:create"
)).thenReturn(Mono.just(false));
this.webTestClient.post().uri("/auth") this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -313,27 +245,6 @@ class AuthControllerTest {
.expectBody().isEmpty(); .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<String> capturedRoles = this.candidateRolesCaptor.getValue();
assertThat(capturedRoles, hasItems("GET_ROOT", "ALL_ROOT", "GET_STAR", "ALL_STAR"));
}
@Test @Test
void forwardAuth_TokenInvalid_ReturnsUnauthorized() { void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
String invalidUserToken = "invalid-user-token"; String invalidUserToken = "invalid-user-token";
@@ -456,9 +367,4 @@ class AuthControllerTest {
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400 .bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
} }
// Helper to match any List<String> in Mockito
private static List<String> anyStringList() {
return Mockito.anyList();
}
} }
@@ -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());
}
}
@@ -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);
}
}
@@ -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");
}
}
@@ -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());
}
}
@@ -4,7 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.when; 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.ConsulClient;
import com.ecwid.consul.v1.Response; import com.ecwid.consul.v1.Response;
import com.ecwid.consul.v1.kv.model.GetValue; import com.ecwid.consul.v1.kv.model.GetValue;
@@ -55,9 +55,9 @@ class FallbackPermissionMapLookupServiceTest {
assertNull(service.getConfig()); // fallback is null assertNull(service.getConfig()); // fallback is null
// has json content // has json content
when(value.getDecodedValue()) when(value.getDecodedValue())
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfig())); .thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul(); service.refreshConsul();
assertEquals(new PermissionMappingConfig(), service.getConfig()); assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
} }
/** /**
@@ -66,7 +66,7 @@ class FallbackPermissionMapLookupServiceTest {
*/ */
@Test @Test
void testGetDefaultKeycloakClientId() { void testGetDefaultKeycloakClientId() {
final var defaultConfig = new PermissionMappingConfig(); final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setDefaultKeycloakClientId("default-client-id"); defaultConfig.setDefaultKeycloakClientId("default-client-id");
final var service = new FallbackPermissionMapLookupService( final var service = new FallbackPermissionMapLookupService(
@@ -77,11 +77,11 @@ class FallbackPermissionMapLookupServiceTest {
@Test @Test
void testMatchClientIdByPath() { void testMatchClientIdByPath() {
final var defaultConfig = new PermissionMappingConfig(); final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of( defaultConfig.setRules(List.of(
PermissionMappingConfig.Rule.builder() PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(), .keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfig.Rule.builder() PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build() .keycloakClientId("service-B").pathPrefix("/b/").build()
)); ));
final var service = new FallbackPermissionMapLookupService( final var service = new FallbackPermissionMapLookupService(
@@ -97,11 +97,11 @@ class FallbackPermissionMapLookupServiceTest {
@Test @Test
void testMatchRuleByClientIdAndPath() { void testMatchRuleByClientIdAndPath() {
final var defaultConfig = new PermissionMappingConfig(); final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of( defaultConfig.setRules(List.of(
PermissionMappingConfig.Rule.builder() PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(), .keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfig.Rule.builder() PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build() .keycloakClientId("service-B").pathPrefix("/b/").build()
)); ));
final var service = new FallbackPermissionMapLookupService( final var service = new FallbackPermissionMapLookupService(
@@ -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();
}
}
@@ -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();
}
}