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