Compare commits
3 Commits
93e03bb6fc
...
465c7fd8ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
465c7fd8ec
|
|||
|
6f0af690f2
|
|||
| 0fc59eef0e |
+14
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<fileset-config file-format-version="1.2.0" simple-config="false" sync-formatter="false">
|
||||
<local-check-config name="maven-checkstyle-plugin validate" location="file:/D:/vmware/micro-code/user-management/checkstyle.xml" type="remote" description="maven-checkstyle-plugin configuration validate">
|
||||
<property name="checkstyle.header.file" value="D:\vmware\micro-code\.metadata\.plugins\org.eclipse.core.resources\.projects\user-management\com.basistech.m2e.code.quality.checkstyleConfigurator\checkstyle-header-validate.txt"/>
|
||||
<property name="checkstyle.cache.file" value="${project_loc}/target/checkstyle-cachefile"/>
|
||||
</local-check-config>
|
||||
<fileset name="java-sources-validate" enabled="true" check-config-name="maven-checkstyle-plugin validate" local="true">
|
||||
<file-match-pattern match-pattern="^src/main/java/.*\.java" include-pattern="true"/>
|
||||
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
|
||||
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
|
||||
<file-match-pattern match-pattern="^src/test/resources.*\.properties" include-pattern="true"/>
|
||||
</fileset>
|
||||
</fileset-config>
|
||||
@@ -72,6 +72,12 @@
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-consul-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>4.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<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 = "";
|
||||
}
|
||||
+35
-3
@@ -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<Rule> 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<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;
|
||||
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfig;
|
||||
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||
import com.cleverthis.authservice.dto.LoginRequest;
|
||||
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
@@ -11,12 +11,8 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -165,24 +161,50 @@ public class AuthController {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static final Map<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
|
||||
* 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<ResponseEntity<? extends Object>> 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<ResponseEntity<?>> forwardAuth(
|
||||
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false)
|
||||
String authorizationHeader,
|
||||
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
|
||||
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString
|
||||
) {
|
||||
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
|
||||
originalUriString);
|
||||
|
||||
String accessToken = extractBearerToken(authorizationHeader);
|
||||
originalUriString);
|
||||
// first detect the client id and service relative path
|
||||
final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString);
|
||||
if (targetServiceClientIdOpt.isEmpty()) {
|
||||
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
|
||||
originalUriString);
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
|
||||
}
|
||||
final var targetServiceClientId = targetServiceClientIdOpt.get();
|
||||
final var serviceRelativePath =
|
||||
extractServiceRelativePath(originalUriString, targetServiceClientId);
|
||||
// then check pass-through rule
|
||||
if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) {
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
|
||||
}
|
||||
if (authorizationHeader == null) {
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST).build());
|
||||
}
|
||||
// no pass-through, check permission
|
||||
final var accessToken = extractBearerToken(authorizationHeader);
|
||||
if (accessToken == null) {
|
||||
log.warn("ForwardAuth: Missing or invalid Bearer token format.");
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||
@@ -190,81 +212,92 @@ public class AuthController {
|
||||
|
||||
// Step 1: Validate the user's token and get user details (especially userSub)
|
||||
return this.identityManagerService.verifyToken(accessToken)
|
||||
.flatMap(verificationResponse -> {
|
||||
// Token is valid and active, proceed to permission check
|
||||
String userSub = verificationResponse.getSub();
|
||||
if (userSub == null || userSub.isBlank()) {
|
||||
.flatMap(verificationResponse -> {
|
||||
// Token is valid and active, proceed to permission check
|
||||
final var userSub = verificationResponse.getSub();
|
||||
if (userSub == null || userSub.isBlank()) {
|
||||
log.error(
|
||||
"ForwardAuth: User subject (sub) "
|
||||
+ "is missing from token after verification.");
|
||||
return Mono.just(
|
||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||
}
|
||||
|
||||
// Step 2: Figure out the scope
|
||||
final var scope = HTTP_METHOD_TO_SCOPE.get(originalMethod.toLowerCase());
|
||||
if (scope == null) { // reject if method not allowed
|
||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
|
||||
}
|
||||
|
||||
// Step 3: Check permission
|
||||
return this.identityManagerService.checkResourcePermission(
|
||||
accessToken, targetServiceClientId, serviceRelativePath, scope
|
||||
)
|
||||
.flatMap(hasPermission -> {
|
||||
if (hasPermission) {
|
||||
log.info(
|
||||
"ForwardAuth: GRANTED for User='{}',"
|
||||
+ " Resource='{}', Scope='{}', Client='{}'",
|
||||
verificationResponse.getUsername(),
|
||||
serviceRelativePath, scope,
|
||||
targetServiceClientId);
|
||||
final var responseHeaders = buildAuthHeaders(verificationResponse);
|
||||
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
|
||||
.<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(
|
||||
"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<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"
|
||||
"ForwardAuth: Generic error/exception during"
|
||||
+ " permission processing: {}",
|
||||
t.getMessage(), t);
|
||||
return Mono.just(
|
||||
ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>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).<Void>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<String> getTargetServiceClientId(String fullUriString) {
|
||||
@@ -293,7 +326,7 @@ public class AuthController {
|
||||
|
||||
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
|
||||
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
|
||||
Optional<PermissionMappingConfig.Rule> matchedRule =
|
||||
Optional<PermissionMappingConfigProperties.Rule> matchedRule =
|
||||
this.permissionMapLookupService.matchRuleByClientIdAndPath(
|
||||
mappedKeycloakClientId, fullUriString
|
||||
);
|
||||
@@ -333,96 +366,4 @@ public class AuthController {
|
||||
return ""; // Or throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of candidate Keycloak Client Role names
|
||||
* based on HTTP method and relative path,
|
||||
* ordered from most specific to most general.
|
||||
* Examples for GET /api/items/123e4567-e89b-12d3-a456-426614174000
|
||||
* (normalized to /api/items/BY_ID):
|
||||
* - GET_API_ITEMS_BY_ID
|
||||
* - ALL_API_ITEMS_BY_ID
|
||||
* - GET_API_ITEMS_STAR
|
||||
* - ALL_API_ITEMS_STAR
|
||||
* - GET_API_STAR
|
||||
* - ALL_API_STAR
|
||||
* - GET_STAR
|
||||
* - ALL_STAR
|
||||
*/
|
||||
private List<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;
|
||||
}
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
|
||||
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* REST Controller for managing fine-grained groups (departments, teams) within
|
||||
* the context of a parent organization.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/groups")
|
||||
@Slf4j
|
||||
public class IntraOrganizationGroupController {
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
|
||||
/**
|
||||
* Constructs the controller with the required service dependency.
|
||||
*
|
||||
* @param identityManagerService The service handling identity management logic.
|
||||
*/
|
||||
@Autowired
|
||||
public IntraOrganizationGroupController(final IdentityManagerService identityManagerService) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sub-group within a parent organization.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param request The request body containing details for the new group.
|
||||
* @return A Mono emitting the created group's representation.
|
||||
*/
|
||||
@PostMapping("/{orgId}/children")
|
||||
public Mono<ResponseEntity<GroupResponse>> createSubGroup(@PathVariable final String orgId,
|
||||
@Valid @RequestBody final CreateGroupRequest request) {
|
||||
log.info("Received request to create sub-group with name '{}' in organization '{}'",
|
||||
request.getName(), orgId);
|
||||
return this.identityManagerService.createSubGroup(orgId, request)
|
||||
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all sub-groups within a parent organization.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @return A Mono emitting a list of group representations.
|
||||
*/
|
||||
@GetMapping("/{orgId}/children")
|
||||
public Mono<ResponseEntity<List<GroupResponse>>> listSubGroups(
|
||||
@PathVariable final String orgId) {
|
||||
log.info("Received request to list sub-groups for organization '{}'", orgId);
|
||||
return this.identityManagerService.listSubGroups(orgId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single sub-group by its ID.
|
||||
*
|
||||
* @param orgId The ID of the parent organization (for context).
|
||||
* @param groupId The ID of the sub-group to retrieve.
|
||||
* @return A Mono emitting the group's representation.
|
||||
*/
|
||||
@GetMapping("/{orgId}/children/{groupId}")
|
||||
public Mono<ResponseEntity<GroupResponse>> getSubGroupById(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId) {
|
||||
log.info("Received request to get sub-group '{}' from organization '{}'", groupId, orgId);
|
||||
return this.identityManagerService.getGroupById(groupId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization (for context).
|
||||
* @param groupId The ID of the sub-group to update.
|
||||
* @param request The request body with fields to update.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@PutMapping("/{orgId}/children/{groupId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> updateSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId,
|
||||
@Valid @RequestBody final UpdateGroupRequest request) {
|
||||
log.info("Received request to update sub-group '{}' in organization '{}'", groupId, orgId);
|
||||
return this.identityManagerService.updateGroup(groupId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization (for context).
|
||||
* @param groupId The ID of the sub-group to delete.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}/children/{groupId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> deleteSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId) {
|
||||
log.info("Received request to delete sub-group '{}' from organization '{}'",
|
||||
groupId, orgId);
|
||||
return this.identityManagerService.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
// --- Sub-Group Membership Management ---
|
||||
|
||||
/**
|
||||
* Adds an existing organization member to a sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param groupId The ID of the sub-group.
|
||||
* @param request The request body containing the user's ID.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@PostMapping("/{orgId}/children/{groupId}/members")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> addMemberToSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId,
|
||||
@Valid @RequestBody final AddGroupMemberRequest request) {
|
||||
log.info("Received request to add user '{}' to sub-group '{}' in organization '{}'",
|
||||
request.getUserId(), groupId, orgId);
|
||||
return this.identityManagerService.addMemberToGroup(groupId, request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of a specific sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param groupId The ID of the sub-group.
|
||||
* @return A Mono emitting a list of organization member representations.
|
||||
*/
|
||||
@GetMapping("/{orgId}/children/{groupId}/members")
|
||||
public Mono<ResponseEntity<List<OrganizationMemberResponse>>>
|
||||
listSubGroupMembers(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId) {
|
||||
log.info("Received request to list members of sub-group '{}' in organization '{}'",
|
||||
groupId, orgId);
|
||||
return this.identityManagerService.getGroupMembers(groupId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from a sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param groupId The ID of the sub-group.
|
||||
* @param userId The ID of the user to remove.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}/children/{groupId}/members/{userId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> removeMemberFromSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId, @PathVariable final String userId) {
|
||||
log.info("Received request to remove user '{}' from sub-group '{}' in organization '{}'",
|
||||
userId, groupId, orgId);
|
||||
return this.identityManagerService.removeMemberFromGroup(groupId, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
|
||||
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* REST Controller for handling Organization related requests.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/organizations")
|
||||
@Slf4j
|
||||
public class OrganizationController {
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
|
||||
@Autowired
|
||||
public OrganizationController(IdentityManagerService identityManagerService) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Organization. Requires administrative privileges, which should be enforced by
|
||||
* the /auth endpoint.
|
||||
*/
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<OrganizationResponse>> createOrganization(
|
||||
@Valid @RequestBody CreateOrganizationRequest request) {
|
||||
log.info("Received request to create organization with name: {}", request.getName());
|
||||
return this.identityManagerService.createOrganization(request)
|
||||
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of all Organizations.
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<List<OrganizationResponse>>> getOrganizations() {
|
||||
log.info("Received request to list all organizations");
|
||||
return this.identityManagerService.getOrganizations().map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single Organization by its ID.
|
||||
*/
|
||||
@GetMapping("/{orgId}")
|
||||
public Mono<ResponseEntity<OrganizationResponse>> getOrganizationById(
|
||||
@PathVariable String orgId) {
|
||||
log.info("Received request to get organization by ID: {}", orgId);
|
||||
return this.identityManagerService.getOrganizationById(orgId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing Organization.
|
||||
*/
|
||||
@PutMapping("/{orgId}")
|
||||
public Mono<ResponseEntity<Void>> updateOrganization(@PathVariable String orgId,
|
||||
@Valid @RequestBody UpdateOrganizationRequest request) {
|
||||
log.info("Received request to update organization ID: {}", orgId);
|
||||
return this.identityManagerService.updateOrganization(orgId, request)
|
||||
.thenReturn(ResponseEntity.noContent().<Void>build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an Organization.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> deleteOrganization(@PathVariable String orgId) {
|
||||
log.info("Received request to delete organization ID: {}", orgId);
|
||||
return this.identityManagerService.deleteOrganization(orgId);
|
||||
}
|
||||
|
||||
// --- Membership Management ---
|
||||
|
||||
/**
|
||||
* Invites a user to join an Organization via email.
|
||||
*/
|
||||
@PostMapping("/{orgId}/invitations")
|
||||
public Mono<ResponseEntity<Void>> inviteUserToOrganization(@PathVariable String orgId,
|
||||
@Valid @RequestBody InviteUserRequest request) {
|
||||
log.info("Received request to invite user with email {} to organization ID: {}",
|
||||
request.getEmail(), orgId);
|
||||
return this.identityManagerService.inviteUserToOrganization(orgId, request)
|
||||
.thenReturn(ResponseEntity.ok().<Void>build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all members of an Organization.
|
||||
*/
|
||||
@GetMapping("/{orgId}/members")
|
||||
public Mono<ResponseEntity<List<OrganizationMemberResponse>>> getOrganizationMembers(
|
||||
@PathVariable String orgId) {
|
||||
log.info("Received request to get members of organization ID: {}", orgId);
|
||||
return this.identityManagerService.getOrganizationMembers(orgId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an existing user to an Organization.
|
||||
*/
|
||||
@PostMapping("/{orgId}/members")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> addMemberToOrganization(@PathVariable String orgId,
|
||||
@Valid @RequestBody AddMemberRequest request) {
|
||||
log.info("Received request to add user ID {} to organization ID: {}", request.getUserId(),
|
||||
orgId);
|
||||
return this.identityManagerService.addMemberToOrganization(orgId, request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from an Organization.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}/members/{userId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> removeMemberFromOrganization(@PathVariable String orgId,
|
||||
@PathVariable String userId) {
|
||||
log.info("Received request to remove user ID {} from organization ID: {}", userId, orgId);
|
||||
return this.identityManagerService.removeMemberFromOrganization(orgId, userId);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cleverthis.authservice.dto.group;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for adding an existing user to a group.
|
||||
* This can be reused from the organization membership logic.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AddGroupMemberRequest {
|
||||
@NotBlank(message = "User ID cannot be blank")
|
||||
private String userId; // The Keycloak user ID (sub)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cleverthis.authservice.dto.group; // New package for group-related DTOs
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
|
||||
/**
|
||||
* DTO for the request body when creating a new sub-group (e.g., department, team)
|
||||
* within an organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateGroupRequest {
|
||||
@NotBlank(message = "Group name cannot be blank")
|
||||
private String name;
|
||||
|
||||
private String description;
|
||||
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cleverthis.authservice.dto.group;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for representing a group in API responses.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GroupResponse {
|
||||
private String id;
|
||||
private String name;
|
||||
private String path; // The full path of the group (e.g., /tenant-org/departments/engineering)
|
||||
private String description;
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cleverthis.authservice.dto.group;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the request body when updating a sub-group.
|
||||
* The name of a group is typically not updatable via this DTO; it's part of the path.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UpdateGroupRequest {
|
||||
private String description;
|
||||
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
|
||||
/**
|
||||
* Data Transfer Object for representing a Keycloak Group when interacting
|
||||
* with the Keycloak Admin API.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class KeycloakGroupRepresentation {
|
||||
private String id;
|
||||
private String name;
|
||||
private String path;
|
||||
private String description; // Note: Description is often stored in attributes
|
||||
private Map<String, List<String>> attributes;
|
||||
private List<KeycloakGroupRepresentation> subGroups;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for creating an invitation request to join an organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakInvitationRequest {
|
||||
|
||||
private String email;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
// You can also specify roles to be redirected to after accepting
|
||||
// private List<String> redirectRoles;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
|
||||
* Admin API.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
// Include only non-null fields during serialization, useful for update operations
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class KeycloakOrganizationRepresentation {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private Set<OrganizationDomainRepresentation> domains;
|
||||
private String description;
|
||||
private Boolean enabled;
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for representing a Keycloak User, typically when listing members of an
|
||||
* organization. It includes only the most relevant fields.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakUserRepresentation {
|
||||
|
||||
private String id;
|
||||
private String username;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private boolean enabled;
|
||||
private Long createdTimestamp;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Representation of an organization's internet domain within Keycloak.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class OrganizationDomainRepresentation {
|
||||
private String name;
|
||||
private Boolean verified;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for adding an existing user to an Organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class AddMemberRequest {
|
||||
|
||||
@NotBlank(message = "User ID cannot be blank")
|
||||
private String userId; // The Keycloak user ID (sub)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the request body when creating a new Organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateOrganizationRequest {
|
||||
|
||||
@NotBlank(message = "Organization name cannot be blank")
|
||||
private String name; // This will become the internal name in Keycloak
|
||||
|
||||
@NotEmpty(message = "At least one organization domain must be provided")
|
||||
private List<String> domains;
|
||||
|
||||
private String description;
|
||||
|
||||
private Map<String, List<String>> attributes; // For custom metadata like displayName
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for inviting a new or existing user to an Organization via email.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class InviteUserRequest {
|
||||
|
||||
@NotBlank(message = "Email cannot be blank")
|
||||
@Email(message = "A valid email address is required")
|
||||
private String email;
|
||||
|
||||
// Optional fields if inviting a brand new user
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
|
||||
// You could also add a list of roles/groups to assign upon joining
|
||||
// private List<String> rolesToAssign;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for representing a member of an Organization. This mirrors Keycloak's UserRepresentation for
|
||||
* relevant fields.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class OrganizationMemberResponse {
|
||||
|
||||
private String id;
|
||||
private String username;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private boolean enabled;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for representing an Organization in API responses.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class OrganizationResponse {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
private Set<OrganizationDomainRepresentation> domains;
|
||||
private String description;
|
||||
private Boolean enabled;
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the request body when updating an Organization.
|
||||
* Name is typically not updatable, but description and attributes are.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UpdateOrganizationRequest {
|
||||
private List<String> domains;
|
||||
private String description;
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -99,12 +99,26 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ResourceNotFoundException and translates it to a 404 Not Found response.
|
||||
*
|
||||
* @param ex The ResourceNotFoundException that was thrown.
|
||||
* @return A ProblemDetail object for a 404 response.
|
||||
*/
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||
problemDetail.setTitle("Resource Not Found");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link Exception} to HTTP 500, Catch-all for other Generic exceptions.
|
||||
*/
|
||||
@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");
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
/**
|
||||
* Custom exception to be thrown when a requested resource is not found.
|
||||
* The @ResponseStatus annotation tells Spring to automatically return a 404 Not Found
|
||||
* status when this exception is thrown from a controller and not handled by an
|
||||
* exception handler. This can simplify the GlobalExceptionHandler.
|
||||
*/
|
||||
@ResponseStatus(value = HttpStatus.NOT_FOUND)
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* Constructs a new ResourceNotFoundException with the specified detail message.
|
||||
*
|
||||
* @param message the detail message.
|
||||
*/
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.cleverthis.authservice.service;
|
||||
|
||||
|
||||
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||
import com.cleverthis.authservice.dto.TokenResponse;
|
||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||
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 +48,6 @@ public interface IdentityManagerService {
|
||||
* revocation fails.
|
||||
*/
|
||||
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.
|
||||
@@ -66,4 +60,152 @@ public interface IdentityManagerService {
|
||||
* Emits an error (e.g., UserAlreadyExistsException) if registration fails.
|
||||
*/
|
||||
Mono<Void> registerUser(RegistrationRequest registrationRequest);
|
||||
|
||||
Mono<Boolean> checkResourcePermission(
|
||||
String reqAccessToken, String clientId, String resourceUri, String scope
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new Organization in Keycloak.
|
||||
*
|
||||
* @param request DTO with details for the new organization.
|
||||
* @return A Mono emitting the newly created Organization's representation.
|
||||
*/
|
||||
Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request);
|
||||
|
||||
/**
|
||||
* Retrieves all Organizations from Keycloak.
|
||||
*
|
||||
* @return A Mono emitting a list of Organization representations.
|
||||
*/
|
||||
Mono<List<OrganizationResponse>> getOrganizations();
|
||||
|
||||
/**
|
||||
* Retrieves a single Organization by its ID from Keycloak.
|
||||
*
|
||||
* @param orgId The ID of the organization to retrieve.
|
||||
* @return A Mono emitting the Organization's representation.
|
||||
*/
|
||||
Mono<OrganizationResponse> getOrganizationById(String orgId);
|
||||
|
||||
/**
|
||||
* Updates an existing Organization in Keycloak.
|
||||
*
|
||||
* @param orgId The ID of the organization to update.
|
||||
* @param request DTO with the fields to update.
|
||||
* @return A Mono completing when the update is successful.
|
||||
*/
|
||||
Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request);
|
||||
|
||||
/**
|
||||
* Deletes an Organization from Keycloak.
|
||||
*
|
||||
* @param orgId The ID of the organization to delete.
|
||||
* @return A Mono completing when the deletion is successful.
|
||||
*/
|
||||
Mono<Void> deleteOrganization(String orgId);
|
||||
|
||||
/**
|
||||
* Invites a user to join an Organization. Keycloak handles the email sending.
|
||||
*
|
||||
* @param orgId The ID of the organization to invite the user to.
|
||||
* @param request DTO containing the user's email and other details.
|
||||
* @return A Mono completing when the invitation is successfully sent.
|
||||
*/
|
||||
Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request);
|
||||
|
||||
/**
|
||||
* Retrieves all members of an Organization.
|
||||
*
|
||||
* @param orgId The ID of the organization.
|
||||
* @return A Mono emitting a list of members.
|
||||
*/
|
||||
Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId);
|
||||
|
||||
/**
|
||||
* Adds an existing user to an Organization.
|
||||
*
|
||||
* @param orgId The ID of the organization.
|
||||
* @param userId The ID of the user to add.
|
||||
* @return A Mono completing when the user is successfully added.
|
||||
*/
|
||||
Mono<Void> addMemberToOrganization(String orgId, String userId);
|
||||
|
||||
/**
|
||||
* Removes a user from an Organization.
|
||||
*
|
||||
* @param orgId The ID of the organization.
|
||||
* @param userId The ID of the user to remove.
|
||||
* @return A Mono completing when the user is successfully removed.
|
||||
*/
|
||||
Mono<Void> removeMemberFromOrganization(String orgId, String userId);
|
||||
|
||||
/**
|
||||
* Creates a new sub-group as a child of a specified parent group.
|
||||
*
|
||||
* @param parentGroupId The ID of the parent group.
|
||||
* @param request DTO containing the details for the new sub-group.
|
||||
* @return A Mono emitting the representation of the created group.
|
||||
*/
|
||||
Mono<GroupResponse> createSubGroup(String parentGroupId, CreateGroupRequest request);
|
||||
|
||||
/**
|
||||
* Retrieves a single group by its unique ID.
|
||||
*
|
||||
* @param groupId The ID of the group to retrieve.
|
||||
* @return A Mono emitting the group's representation.
|
||||
*/
|
||||
Mono<GroupResponse> getGroupById(String groupId);
|
||||
|
||||
/**
|
||||
* Lists all direct sub-groups (children) of a given parent group.
|
||||
*
|
||||
* @param parentGroupId The ID of the parent group.
|
||||
* @return A Mono emitting a list of group representations.
|
||||
*/
|
||||
Mono<List<GroupResponse>> listSubGroups(String parentGroupId);
|
||||
|
||||
/**
|
||||
* Updates an existing group's details.
|
||||
*
|
||||
* @param groupId The ID of the group to update.
|
||||
* @param request DTO containing the fields to update.
|
||||
* @return A Mono that completes when the update is successful.
|
||||
*/
|
||||
Mono<Void> updateGroup(String groupId, UpdateGroupRequest request);
|
||||
|
||||
/**
|
||||
* Deletes a group by its ID.
|
||||
*
|
||||
* @param groupId The ID of the group to delete.
|
||||
* @return A Mono that completes when the deletion is successful.
|
||||
*/
|
||||
Mono<Void> deleteGroup(String groupId);
|
||||
|
||||
/**
|
||||
* Adds an existing user to a group.
|
||||
*
|
||||
* @param groupId The ID of the group.
|
||||
* @param userId The ID of the user to add.
|
||||
* @return A Mono that completes when the user is successfully added.
|
||||
*/
|
||||
Mono<Void> addMemberToGroup(String groupId, String userId);
|
||||
|
||||
/**
|
||||
* Retrieves all members of a specific group.
|
||||
*
|
||||
* @param groupId The ID of the group.
|
||||
* @return A Mono emitting a list of member representations.
|
||||
*/
|
||||
Mono<List<OrganizationMemberResponse>> getGroupMembers(String groupId);
|
||||
|
||||
/**
|
||||
* Removes a user from a specific group.
|
||||
*
|
||||
* @param groupId The ID of the group.
|
||||
* @param userId The ID of the user to remove.
|
||||
* @return A Mono that completes when the user is successfully removed.
|
||||
*/
|
||||
Mono<Void> removeMemberFromGroup(String groupId, String userId);
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
+25
-12
@@ -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<PermissionMappingConfig> consulValue = new AtomicReference<>();
|
||||
private final AtomicReference<PermissionMappingConfigProperties> 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<PermissionMappingConfig.Rule> matchRuleByClientIdAndPath(
|
||||
public Optional<PermissionMappingConfigProperties.Rule> 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<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,8 +1,13 @@
|
||||
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;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation;
|
||||
import com.cleverthis.authservice.exception.ServiceUnavailableException;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
@@ -10,7 +15,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 +31,119 @@ 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<String> adminAccessToken = new AtomicReference<>();
|
||||
private final AtomicReference<Long> 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();
|
||||
}
|
||||
|
||||
private String getOrganizationByIdEndpoint(String orgId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/organizations/{orgId}")
|
||||
.buildAndExpand(this.realm, orgId)
|
||||
.encode().toUriString();
|
||||
}
|
||||
|
||||
private String getOrganizationsEndpoint() {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/organizations")
|
||||
.buildAndExpand(this.realm)
|
||||
.encode().toUriString();
|
||||
}
|
||||
|
||||
private String getOrganizationMembersEndpoint(String orgId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/organizations/{orgId}/members")
|
||||
.buildAndExpand(this.realm, orgId)
|
||||
.encode().toUriString();
|
||||
}
|
||||
|
||||
private String getOrganizationMemberByIdEndpoint(String orgId, String userId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/organizations/{orgId}/members/{userId}")
|
||||
.buildAndExpand(this.realm, orgId, userId)
|
||||
.encode().toUriString();
|
||||
}
|
||||
|
||||
private String getOrganizationInvitationEndpoint(String orgId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/organizations/{orgId}/members/invite-user")
|
||||
.buildAndExpand(this.realm, orgId)
|
||||
.encode().toUriString();
|
||||
}
|
||||
|
||||
private String getGroupsEndpoint() {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/groups")
|
||||
.buildAndExpand(this.realm).toUriString();
|
||||
}
|
||||
|
||||
private String getGroupByIdEndpoint(String groupId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/groups/{groupId}")
|
||||
.buildAndExpand(this.realm, groupId).encode().toUriString();
|
||||
}
|
||||
|
||||
private String getGroupChildrenEndpoint(String parentGroupId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/groups/{parentGroupId}/children")
|
||||
.buildAndExpand(this.realm, parentGroupId)
|
||||
.encode().toUriString();
|
||||
}
|
||||
|
||||
private String getGroupMembersEndpoint(String groupId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/groups/{groupId}/members")
|
||||
.buildAndExpand(this.realm, groupId).encode()
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
private String getGroupMemberByIdEndpoint(String groupId, String userId) {
|
||||
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
|
||||
.path("/admin/realms/{realm}/users/{userId}/groups/{groupId}")
|
||||
.buildAndExpand(this.realm, userId, groupId).encode().toUriString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +151,6 @@ import reactor.util.retry.Retry;
|
||||
* credentials grant.
|
||||
*
|
||||
* @return Mono emitting the admin access token.
|
||||
*
|
||||
*/
|
||||
Mono<String> getAdminAccessToken() {
|
||||
if (System.currentTimeMillis() < this.tokenExpiryTime.get()
|
||||
@@ -78,12 +159,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<String, String> 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 +232,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<List<KeycloakRoleRepresentation>> getUserEffectiveClientRoles(String userId,
|
||||
String clientInternalId) {
|
||||
public Mono<List<KeycloakRoleRepresentation>> 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()
|
||||
@@ -175,4 +258,274 @@ import reactor.util.retry.Retry;
|
||||
"Found {} effective client roles for user '{}' on client internal ID '{}'",
|
||||
roles.size(), userId, clientInternalId)));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new organization in Keycloak.
|
||||
*
|
||||
* @param orgToCreate A representation of the organization to be created.
|
||||
* @return A Mono emitting the representation of the newly created organization, including its
|
||||
* ID.
|
||||
*/
|
||||
public Mono<KeycloakOrganizationRepresentation> createOrganization(
|
||||
KeycloakOrganizationRepresentation orgToCreate) {
|
||||
return getAdminAccessToken()
|
||||
.flatMap(token -> this.webClient.post().uri(getOrganizationsEndpoint())
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(orgToCreate).retrieve()
|
||||
// Add error handling here for 409 Conflict, etc.
|
||||
.bodyToMono(KeycloakOrganizationRepresentation.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all organizations within the realm.
|
||||
*
|
||||
* @return A Mono emitting a List of all organization representations.
|
||||
*/
|
||||
public Mono<List<KeycloakOrganizationRepresentation>> getOrganizations() {
|
||||
return getAdminAccessToken()
|
||||
.flatMap(token -> this.webClient.get().uri(getOrganizationsEndpoint())
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.bodyToFlux(KeycloakOrganizationRepresentation.class).collectList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single organization by its unique ID.
|
||||
*
|
||||
* @param orgId The ID of the organization to retrieve.
|
||||
* @return A Mono emitting the representation of the found organization.
|
||||
*/
|
||||
public Mono<KeycloakOrganizationRepresentation> getOrganizationById(String orgId) {
|
||||
return getAdminAccessToken()
|
||||
.flatMap(token -> this.webClient.get().uri(getOrganizationByIdEndpoint(orgId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.bodyToMono(KeycloakOrganizationRepresentation.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing organization's details in Keycloak.
|
||||
*
|
||||
* @param orgId The ID of the organization to update.
|
||||
* @param orgToUpdate A representation containing the updated fields for the organization.
|
||||
* @return A Mono that completes when the update is successful.
|
||||
*/
|
||||
public Mono<Void> updateOrganization(String orgId,
|
||||
KeycloakOrganizationRepresentation orgToUpdate) {
|
||||
return getAdminAccessToken()
|
||||
.flatMap(token -> this.webClient.put().uri(getOrganizationByIdEndpoint(orgId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(orgToUpdate).retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an organization from Keycloak.
|
||||
*
|
||||
* @param orgId The ID of the organization to delete.
|
||||
* @return A Mono that completes when the deletion is successful.
|
||||
*/
|
||||
public Mono<Void> deleteOrganization(String orgId) {
|
||||
return getAdminAccessToken()
|
||||
.flatMap(token -> this.webClient.delete().uri(getOrganizationByIdEndpoint(orgId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invitation for a user to join an organization. Keycloak handles the email delivery.
|
||||
*
|
||||
* @param orgId The ID of the organization to invite the user to.
|
||||
* @param invitation An object containing the invitee's details (email, name).
|
||||
* @return A Mono that completes when the invitation has been successfully sent.
|
||||
*/
|
||||
public Mono<Void> inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) {
|
||||
// Create form data from the invitation DTO
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("email", invitation.getEmail());
|
||||
if (invitation.getFirstName() != null) {
|
||||
formData.add("firstName", invitation.getFirstName());
|
||||
}
|
||||
if (invitation.getLastName() != null) {
|
||||
formData.add("lastName", invitation.getLastName());
|
||||
}
|
||||
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.post()
|
||||
.uri(getOrganizationInvitationEndpoint(orgId)) // Points to /invitations
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
// UPDATED: Use FORM_URLENCODED content type
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
// UPDATED: Use fromFormData to send the body
|
||||
.body(BodyInserters.fromFormData(formData)).retrieve().bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of all members of a specific organization.
|
||||
*
|
||||
* @param orgId The ID of the organization.
|
||||
* @return A Mono emitting a List of user representations for the members.
|
||||
*/
|
||||
public Mono<List<KeycloakUserRepresentation>> getOrganizationMembers(String orgId) {
|
||||
return getAdminAccessToken()
|
||||
.flatMap(token -> this.webClient.get().uri(getOrganizationMembersEndpoint(orgId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.bodyToFlux(KeycloakUserRepresentation.class).collectList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an existing Keycloak user to an organization.
|
||||
*
|
||||
* @param orgId The ID of the organization.
|
||||
* @param userId The ID of the user to add as a member.
|
||||
* @return A Mono that completes when the user is successfully added.
|
||||
*/
|
||||
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
|
||||
// Keycloak's API to add an existing user to an Organization is a POST
|
||||
// to the /members collection endpoint, with the user's ID in the body.
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.post()
|
||||
// Use the endpoint for the members collection, NOT the specific member
|
||||
.uri(getOrganizationMembersEndpoint(orgId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
// The body should be a representation containing the user's ID
|
||||
.bodyValue(userId).retrieve().bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a member from an organization.
|
||||
*
|
||||
* @param orgId The ID of the organization.
|
||||
* @param userId The ID of the user to remove.
|
||||
* @return A Mono that completes when the user is successfully removed.
|
||||
*/
|
||||
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
|
||||
.uri(getOrganizationMemberByIdEndpoint(orgId, userId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sub-group as a child of a parent group.
|
||||
*
|
||||
* @param parentGroupId The ID of the parent group.
|
||||
* @param group A representation of the sub-group to create.
|
||||
* @return A Mono that completes when the group is created.
|
||||
* Keycloak returns location header, not body.
|
||||
*/
|
||||
public Mono<Void> createSubGroup(String parentGroupId, KeycloakGroupRepresentation group) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.post()
|
||||
.uri(getGroupChildrenEndpoint(parentGroupId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(group)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a group by its unique ID.
|
||||
*
|
||||
* @param groupId The ID of the group to retrieve.
|
||||
* @return A Mono emitting the group representation.
|
||||
*/
|
||||
public Mono<KeycloakGroupRepresentation> getGroupById(String groupId) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.get()
|
||||
.uri(getGroupByIdEndpoint(groupId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToMono(KeycloakGroupRepresentation.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the direct children (sub-groups) of a parent group.
|
||||
*
|
||||
* @param parentGroupId The ID of the parent group.
|
||||
* @return A Mono emitting a list of sub-group representations.
|
||||
*/
|
||||
public Mono<List<KeycloakGroupRepresentation>> listSubGroups(String parentGroupId) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.get()
|
||||
.uri(getGroupChildrenEndpoint(parentGroupId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToFlux(KeycloakGroupRepresentation.class)
|
||||
.collectList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing group.
|
||||
*
|
||||
* @param groupId The ID of the group to update.
|
||||
* @param group A representation containing the updated fields.
|
||||
* @return A Mono that completes on successful update.
|
||||
*/
|
||||
public Mono<Void> updateGroup(String groupId, KeycloakGroupRepresentation group) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.put()
|
||||
.uri(getGroupByIdEndpoint(groupId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(group)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a group by its ID.
|
||||
*
|
||||
* @param groupId The ID of the group to delete.
|
||||
* @return A Mono that completes on successful deletion.
|
||||
*/
|
||||
public Mono<Void> deleteGroup(String groupId) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
|
||||
.uri(getGroupByIdEndpoint(groupId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a user to a group.
|
||||
*
|
||||
* @param groupId The ID of the group.
|
||||
* @param userId The ID of the user.
|
||||
* @return A Mono that completes when the user is added to the group.
|
||||
*/
|
||||
public Mono<Void> addMemberToGroup(String groupId, String userId) {
|
||||
// Keycloak API for adding a user to a group is a PUT on the user's groups link
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.put()
|
||||
.uri(getGroupMemberByIdEndpoint(groupId, userId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all members of a specific group.
|
||||
*
|
||||
* @param groupId The ID of the group.
|
||||
* @return A Mono emitting a list of user representations.
|
||||
*/
|
||||
public Mono<List<KeycloakUserRepresentation>> getGroupMembers(String groupId) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.get()
|
||||
.uri(getGroupMembersEndpoint(groupId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToFlux(KeycloakUserRepresentation.class)
|
||||
.collectList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from a group.
|
||||
*
|
||||
* @param groupId The ID of the group.
|
||||
* @param userId The ID of the user.
|
||||
* @return A Mono that completes when the user is removed from the group.
|
||||
*/
|
||||
public Mono<Void> removeMemberFromGroup(String groupId, String userId) {
|
||||
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
|
||||
.uri(getGroupMemberByIdEndpoint(groupId, userId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToMono(Void.class));
|
||||
}
|
||||
|
||||
}
|
||||
+276
-75
@@ -1,12 +1,27 @@
|
||||
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.dto.group.CreateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
|
||||
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||
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;
|
||||
@@ -14,11 +29,13 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
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 +43,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 +51,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 +113,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||
MultiValueMap<String, String> 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 +206,6 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||
.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
|
||||
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
|
||||
log.info("Registering new user with email: {}", registrationRequest.getEmail());
|
||||
@@ -300,4 +281,224 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||
registrationRequest.getEmail(), e.getMessage()))
|
||||
.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));
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
|
||||
KeycloakOrganizationRepresentation newOrg = new KeycloakOrganizationRepresentation();
|
||||
newOrg.setName(request.getName());
|
||||
newOrg.setDescription(request.getDescription());
|
||||
newOrg.setAttributes(request.getAttributes());
|
||||
newOrg.setEnabled(true); // Default to enabled
|
||||
|
||||
if (request.getDomains() != null) {
|
||||
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
||||
.stream()
|
||||
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
|
||||
.collect(Collectors.toSet());
|
||||
newOrg.setDomains(domainRepresentations);
|
||||
}
|
||||
return this.keycloakAdminClient.createOrganization(newOrg)
|
||||
.map(this::toOrganizationResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<OrganizationResponse>> getOrganizations() {
|
||||
return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream()
|
||||
.map(this::toOrganizationResponse).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<OrganizationResponse> getOrganizationById(String orgId) {
|
||||
return this.keycloakAdminClient.getOrganizationById(orgId)
|
||||
.map(this::toOrganizationResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request) {
|
||||
return this.keycloakAdminClient.getOrganizationById(orgId).flatMap(existingOrg -> {
|
||||
// Update fields from the request
|
||||
existingOrg.setDescription(request.getDescription());
|
||||
existingOrg.setAttributes(request.getAttributes());
|
||||
|
||||
// Update domains based on the new list of names
|
||||
if (request.getDomains() != null) {
|
||||
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
||||
.stream()
|
||||
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
|
||||
.collect(Collectors.toSet());
|
||||
existingOrg.setDomains(domainRepresentations);
|
||||
}
|
||||
return this.keycloakAdminClient.updateOrganization(orgId, existingOrg);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteOrganization(String orgId) {
|
||||
return this.keycloakAdminClient.deleteOrganization(orgId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request) {
|
||||
KeycloakInvitationRequest invitation = new KeycloakInvitationRequest();
|
||||
invitation.setEmail(request.getEmail());
|
||||
invitation.setFirstName(request.getFirstName());
|
||||
invitation.setLastName(request.getLastName());
|
||||
// Keycloak handles creating the user if they don't exist.
|
||||
return this.keycloakAdminClient.inviteUserToOrganization(orgId, invitation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId) {
|
||||
return this.keycloakAdminClient.getOrganizationMembers(orgId).map(userList -> userList
|
||||
.stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
|
||||
return this.keycloakAdminClient.addMemberToOrganization(orgId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
|
||||
return this.keycloakAdminClient.removeMemberFromOrganization(orgId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupResponse> createSubGroup(final String parentGroupId,
|
||||
final CreateGroupRequest request) {
|
||||
KeycloakGroupRepresentation newGroup = new KeycloakGroupRepresentation();
|
||||
newGroup.setName(request.getName());
|
||||
newGroup.setAttributes(request.getAttributes());
|
||||
|
||||
return this.keycloakAdminClient.createSubGroup(parentGroupId, newGroup)
|
||||
.thenReturn(new GroupResponse(null, request.getName(),
|
||||
null, null, request.getAttributes()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupResponse> getGroupById(final String groupId) {
|
||||
return this.keycloakAdminClient.getGroupById(groupId).map(this::toGroupResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<GroupResponse>> listSubGroups(final String parentGroupId) {
|
||||
return this.keycloakAdminClient.listSubGroups(parentGroupId)
|
||||
.map(groupList -> groupList.stream()
|
||||
.map(this::toGroupResponse)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
|
||||
KeycloakGroupRepresentation groupUpdate = new KeycloakGroupRepresentation();
|
||||
groupUpdate.setAttributes(request.getAttributes());
|
||||
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteGroup(final String groupId) {
|
||||
return this.keycloakAdminClient.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> addMemberToGroup(final String groupId, final String userId) {
|
||||
return this.keycloakAdminClient.addMemberToGroup(groupId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<OrganizationMemberResponse>> getGroupMembers(final String groupId) {
|
||||
return this.keycloakAdminClient.getGroupMembers(groupId)
|
||||
.map(userList -> userList.stream()
|
||||
.map(this::toOrganizationMemberResponse)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> removeMemberFromGroup(final String groupId, final String userId) {
|
||||
return this.keycloakAdminClient.removeMemberFromGroup(groupId, userId);
|
||||
}
|
||||
|
||||
// --- Add DTO Mapper for Group ---
|
||||
private GroupResponse toGroupResponse(final KeycloakGroupRepresentation keycloakGroup) {
|
||||
if (keycloakGroup == null) {
|
||||
return null;
|
||||
}
|
||||
return new GroupResponse(
|
||||
keycloakGroup.getId(),
|
||||
keycloakGroup.getName(),
|
||||
keycloakGroup.getPath(),
|
||||
keycloakGroup.getDescription(),
|
||||
keycloakGroup.getAttributes()
|
||||
);
|
||||
}
|
||||
|
||||
// --- DTO Mapper Helper Methods ---
|
||||
|
||||
private OrganizationResponse toOrganizationResponse(
|
||||
KeycloakOrganizationRepresentation keycloakOrg) {
|
||||
if (keycloakOrg == null) {
|
||||
return null;
|
||||
}
|
||||
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
|
||||
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.getEnabled(),
|
||||
keycloakOrg.getAttributes());
|
||||
}
|
||||
|
||||
private OrganizationMemberResponse toOrganizationMemberResponse(
|
||||
KeycloakUserRepresentation keycloakUser) {
|
||||
if (keycloakUser == null) {
|
||||
return null;
|
||||
}
|
||||
return new OrganizationMemberResponse(keycloakUser.getId(), keycloakUser.getUsername(),
|
||||
keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(),
|
||||
keycloakUser.isEnabled());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,19 +15,14 @@ spring:
|
||||
|
||||
# Keycloak Configuration (adjust values as needed)
|
||||
keycloak:
|
||||
server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
|
||||
realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using
|
||||
keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
|
||||
keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
|
||||
realm: ${KEYCLOAK_AUTH_REALM:clevermicro-dev} # The realm name you are using
|
||||
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service
|
||||
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!)
|
||||
# Grant type specific settings (ROPC for /login)
|
||||
grant-type: password
|
||||
|
||||
# Configuration for auth-service's OWN client to call Keycloak Admin API
|
||||
# This client needs service account roles like 'view-clients', 'view-users', 'query-groups'
|
||||
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!)
|
||||
admin-account-username: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-account} # an admin account for admin operations
|
||||
admin-account-password: ${KEYCLOAK_AUTH_ADMIN_SECRET:6B8KWFQDPOoj88V7xnMYoQIgj9vVfuvw} # Secret for this admin account
|
||||
auth-service:
|
||||
admin-client:
|
||||
client-id: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-admin} # A SEPARATE client ID in Keycloak for admin operations
|
||||
client-secret: ${KEYCLOAK_AUTH_ADMIN_SECRET:qDqbVqfmCmG6SSrAW3pyqXEpu0bOxWJj} # Secret for this admin client
|
||||
permission-mapping:
|
||||
# Rules to map incoming request path prefixes to Keycloak Client IDs (representing services)
|
||||
# The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
|
||||
|
||||
@@ -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<List<String>> 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<PermissionMappingConfig.Rule> rules = new ArrayList<>();
|
||||
List<PermissionMappingConfigProperties.Rule> 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<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
|
||||
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<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
|
||||
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<String> 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<String> in Mockito
|
||||
private static List<String> anyStringList() {
|
||||
return Mockito.anyList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.cleverthis.authservice;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cleverthis.authservice.controller.IntraOrganizationGroupController;
|
||||
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
|
||||
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||
import com.cleverthis.authservice.exception.ResourceNotFoundException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Unit tests for the IntraOrganizationGroupController. This class tests the API
|
||||
* layer for sub-group management, mocking the service layer.
|
||||
*/
|
||||
@WebFluxTest(IntraOrganizationGroupController.class)
|
||||
@Import(GlobalExceptionHandler.class)
|
||||
public class IntraOrganizationGroupControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@MockitoBean
|
||||
private IdentityManagerService identityManagerService;
|
||||
|
||||
private GroupResponse mockGroupResponse;
|
||||
|
||||
/**
|
||||
* Sets up mock data before each test.
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockGroupResponse = new GroupResponse("group-id-eng", "engineering",
|
||||
"/tenant-id-123/engineering",
|
||||
"Engineering Department", Map.of("cost-center", List.of("ENG-101")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful creation of a sub-group.
|
||||
*/
|
||||
@Test
|
||||
void createSubGroup_Success_ReturnsCreated() {
|
||||
String parentGroupId = "org-id-123";
|
||||
CreateGroupRequest request = new CreateGroupRequest("engineering",
|
||||
"Engineering Department", null);
|
||||
|
||||
when(identityManagerService.createSubGroup(eq(parentGroupId), any(CreateGroupRequest.class)))
|
||||
.thenReturn(Mono.just(mockGroupResponse));
|
||||
|
||||
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
|
||||
.exchange().expectStatus().isCreated()
|
||||
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests creation of a sub-group with invalid input.
|
||||
*/
|
||||
@Test
|
||||
void createSubGroup_InvalidInput_ReturnsBadRequest() {
|
||||
String parentGroupId = "org-id-123";
|
||||
// Blank name is invalid
|
||||
CreateGroupRequest request = new CreateGroupRequest("", null, null);
|
||||
|
||||
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||
.expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful listing of sub-groups.
|
||||
*/
|
||||
@Test
|
||||
void listSubGroups_Success_ReturnsListOfGroups() {
|
||||
String parentGroupId = "org-id-123";
|
||||
when(identityManagerService.listSubGroups(parentGroupId))
|
||||
.thenReturn(Mono.just(Collections.singletonList(mockGroupResponse)));
|
||||
|
||||
webTestClient.get().uri("/groups/{parentGroupId}/children", parentGroupId).exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBodyList(GroupResponse.class).hasSize(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful retrieval of a group by its ID.
|
||||
*/
|
||||
@Test
|
||||
void getGroupById_Exists_ReturnsGroup() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "group-id-eng";
|
||||
when(identityManagerService.getGroupById(groupId)).thenReturn(Mono.just(mockGroupResponse));
|
||||
|
||||
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}", parentGroupId, groupId)
|
||||
.exchange().expectStatus().isOk()
|
||||
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests retrieval of a non-existent group.
|
||||
*/
|
||||
@Test
|
||||
void getGroupById_NotFound_ReturnsNotFound() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "non-existent-group";
|
||||
when(identityManagerService.getGroupById(groupId))
|
||||
.thenReturn(Mono.error(new ResourceNotFoundException("Group not found")));
|
||||
|
||||
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}",
|
||||
parentGroupId, groupId).exchange()
|
||||
.expectStatus().isNotFound();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful update of a group.
|
||||
*/
|
||||
@Test
|
||||
void updateGroup_Success_ReturnsNoContent() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "group-id-eng";
|
||||
UpdateGroupRequest request = new UpdateGroupRequest("An updated description", null);
|
||||
|
||||
when(identityManagerService.updateGroup(eq(groupId), any(UpdateGroupRequest.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
webTestClient.put().uri("/groups/{parentGroupId}/children/{groupId}",
|
||||
parentGroupId, groupId)
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
|
||||
.exchange().expectStatus().isNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful deletion of a group.
|
||||
*/
|
||||
@Test
|
||||
void deleteGroup_Success_ReturnsNoContent() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "group-id-eng";
|
||||
when(identityManagerService.deleteGroup(groupId)).thenReturn(Mono.empty());
|
||||
|
||||
webTestClient.delete().uri("/groups/{parentGroupId}/children/{groupId}",
|
||||
parentGroupId, groupId).exchange()
|
||||
.expectStatus().isNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successfully adding a member to a group.
|
||||
*/
|
||||
@Test
|
||||
void addMemberToGroup_Success_ReturnsNoContent() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "group-id-eng";
|
||||
AddGroupMemberRequest request = new AddGroupMemberRequest("user-id-to-add");
|
||||
when(identityManagerService.addMemberToGroup(groupId, request.getUserId())).thenReturn(Mono.empty());
|
||||
|
||||
webTestClient.post().uri("/groups/{parentGroupId}/children/{groupId}/members",
|
||||
parentGroupId, groupId).contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful listing of group members.
|
||||
*/
|
||||
@Test
|
||||
void listGroupMembers_Success_ReturnsListOfMembers() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "group-id-eng";
|
||||
List<OrganizationMemberResponse> members = List.of(
|
||||
new OrganizationMemberResponse("user-id-123", "abed.spring",
|
||||
"Abed", "Spring", "abed@test.com", true));
|
||||
when(identityManagerService.getGroupMembers(groupId)).thenReturn(Mono.just(members));
|
||||
|
||||
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}/members",
|
||||
parentGroupId, groupId).exchange()
|
||||
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class).isEqualTo(members);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successfully removing a member from a group.
|
||||
*/
|
||||
@Test
|
||||
void removeMemberFromGroup_Success_ReturnsNoContent() {
|
||||
String parentGroupId = "org-id-123";
|
||||
String groupId = "group-id-eng";
|
||||
String userId = "user-to-remove";
|
||||
when(identityManagerService.removeMemberFromGroup(groupId, userId)).thenReturn(Mono.empty());
|
||||
|
||||
webTestClient.delete()
|
||||
.uri("/groups/{parentGroupId}/children/{groupId}/members/{userId}",
|
||||
parentGroupId, groupId, userId)
|
||||
.exchange().expectStatus().isNoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.cleverthis.authservice;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cleverthis.authservice.controller.OrganizationController;
|
||||
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
|
||||
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
|
||||
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||
import com.cleverthis.authservice.exception.ResourceNotFoundException;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Unit tests for the OrganizationController. This class tests the API layer for organization
|
||||
* management, mocking the service layer.
|
||||
*/
|
||||
@WebFluxTest(OrganizationController.class)
|
||||
@Import(GlobalExceptionHandler.class)
|
||||
public class OrganizationControllerTest {
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@MockitoBean
|
||||
private IdentityManagerService identityManagerService;
|
||||
|
||||
private OrganizationResponse mockOrgResponse;
|
||||
private List<OrganizationResponse> mockOrgResponseList;
|
||||
|
||||
/**
|
||||
* Sets up mock data before each test.
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Create the correct domain representation object
|
||||
OrganizationDomainRepresentation domain =
|
||||
new OrganizationDomainRepresentation("test-org.com", true);
|
||||
this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain),
|
||||
"A test organization", true, Map.of("displayName", List.of("Test Org")));
|
||||
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful creation of an organization.
|
||||
*/
|
||||
@Test
|
||||
void createOrganization_Success_ReturnsCreatedWithBody() {
|
||||
CreateOrganizationRequest request = new CreateOrganizationRequest("test-org",
|
||||
List.of("test-org.com"), "A test org", null);
|
||||
|
||||
when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class)))
|
||||
.thenReturn(Mono.just(this.mockOrgResponse));
|
||||
|
||||
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isCreated()
|
||||
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests organization creation with invalid input, expecting a Bad Request.
|
||||
*/
|
||||
@Test
|
||||
void createOrganization_InvalidInput_ReturnsBadRequest() {
|
||||
CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null);
|
||||
|
||||
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful retrieval of all organizations.
|
||||
*/
|
||||
@Test
|
||||
void getOrganizations_Success_ReturnsListOfOrgs() {
|
||||
when(this.identityManagerService.getOrganizations())
|
||||
.thenReturn(Mono.just(this.mockOrgResponseList));
|
||||
|
||||
this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk()
|
||||
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful retrieval of a single organization by its ID.
|
||||
*/
|
||||
@Test
|
||||
void getOrganizationById_Exists_ReturnsOrg() {
|
||||
when(this.identityManagerService.getOrganizationById("org-id-123"))
|
||||
.thenReturn(Mono.just(this.mockOrgResponse));
|
||||
|
||||
this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange()
|
||||
.expectStatus().isOk().expectBody(OrganizationResponse.class)
|
||||
.isEqualTo(this.mockOrgResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests retrieval of a non-existent organization, expecting Not Found.
|
||||
*/
|
||||
@Test
|
||||
void getOrganizationById_NotFound_ReturnsNotFound() {
|
||||
when(this.identityManagerService.getOrganizationById("non-existent-id"))
|
||||
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
|
||||
|
||||
this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange()
|
||||
.expectStatus().isNotFound();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful update of an organization.
|
||||
*/
|
||||
@Test
|
||||
void updateOrganization_Success_ReturnsNoContent() {
|
||||
UpdateOrganizationRequest request =
|
||||
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
|
||||
when(this.identityManagerService.updateOrganization(eq("org-id-123"),
|
||||
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
|
||||
|
||||
this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123")
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||
.expectStatus().isNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful deletion of an organization.
|
||||
*/
|
||||
@Test
|
||||
void deleteOrganization_Success_ReturnsNoContent() {
|
||||
when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty());
|
||||
|
||||
this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange()
|
||||
.expectStatus().isNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful invitation of a user to an organization.
|
||||
*/
|
||||
@Test
|
||||
void inviteUserToOrganization_Success_ReturnsOk() {
|
||||
InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User");
|
||||
when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"),
|
||||
any(InviteUserRequest.class))).thenReturn(Mono.empty());
|
||||
|
||||
this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123")
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||
.expectStatus().isOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successful retrieval of organization members.
|
||||
*/
|
||||
@Test
|
||||
void getOrganizationMembers_Success_ReturnsListOfMembers() {
|
||||
List<OrganizationMemberResponse> members =
|
||||
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
|
||||
"test@test.com", true));
|
||||
when(this.identityManagerService.getOrganizationMembers("org-id-123"))
|
||||
.thenReturn(Mono.just(members));
|
||||
|
||||
this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange()
|
||||
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
|
||||
.isEqualTo(members);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successfully adding a member to an organization.
|
||||
*/
|
||||
@Test
|
||||
void addMemberToOrganization_Success_ReturnsNoContent() {
|
||||
AddMemberRequest request = new AddMemberRequest("user-to-add-id");
|
||||
when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id"))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123")
|
||||
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||
.expectStatus().isNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests successfully removing a member from an organization.
|
||||
*/
|
||||
@Test
|
||||
void removeMemberFromOrganization_Success_ReturnsNoContent() {
|
||||
when(this.identityManagerService.removeMemberFromOrganization("org-id-123",
|
||||
"user-to-remove-id")).thenReturn(Mono.empty());
|
||||
|
||||
this.webTestClient.delete()
|
||||
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
|
||||
.exchange().expectStatus().isNoContent();
|
||||
}
|
||||
}
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+249
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user