Implement query user endpoint for RabbitMQ (#35)
Unit test coverage / gradle-test (pull_request) Successful in 7m21s
Unit test coverage / gradle-test (push) Successful in 7m26s
CI for publishing docker image / build-and-publish (push) Successful in 7m2s

This PR implemented ticket clevermicro/user-management#34 for the user-service-v1 endpoint, along with all the infrastructures required to support the endpoints.

The changes are:
+ switch build tool to gradle for easier access to the private artifacts of clevermicro (although later Brian change the artifacts to public)
+ set up clevermicro amqp client
+ switched to official keycloak admin client instead of implementing it with webflux
+ implement the message formats for the RPC protocol, for now they are exclusive to auth-service, but in the future we will reuse (might with some changes) them for all RPC calls inside clevermicro (need a server lib for handling the message parsing and convertion)
+ implement the user query endpoint
+ implement unit test so the overall coverage is above 85%
+ fixed all issues found during the testing phase with shared env

Currently the latest docker image has been deployed to the shared env, see https://docs.cleverthis.com/en/architecture/microservices/shared-env#multi-user-support

Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
2025-07-16 16:43:00 +08:00
parent 465c7fd8ec
commit bed3de76dd
47 changed files with 3645 additions and 1312 deletions
-14
View File
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<fileset-config file-format-version="1.2.0" simple-config="false" sync-formatter="false">
<local-check-config name="maven-checkstyle-plugin validate" location="file:/D:/vmware/micro-code/user-management/checkstyle.xml" type="remote" description="maven-checkstyle-plugin configuration validate">
<property name="checkstyle.header.file" value="D:\vmware\micro-code\.metadata\.plugins\org.eclipse.core.resources\.projects\user-management\com.basistech.m2e.code.quality.checkstyleConfigurator\checkstyle-header-validate.txt"/>
<property name="checkstyle.cache.file" value="${project_loc}/target/checkstyle-cachefile"/>
</local-check-config>
<fileset name="java-sources-validate" enabled="true" check-config-name="maven-checkstyle-plugin validate" local="true">
<file-match-pattern match-pattern="^src/main/java/.*\.java" include-pattern="true"/>
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
<file-match-pattern match-pattern="^src/test/resources.*\.properties" include-pattern="true"/>
</fileset>
</fileset-config>
+7 -3
View File
@@ -31,20 +31,24 @@ jobs:
# need to setup node and git
- run: |
apt-get update
apt-get install -y nodejs git curl maven
apt-get install -y nodejs git curl
# check out code
- uses: actions/checkout@v4
- uses: https://github.com/actions/setup-java@v4
with:
distribution: "temurin"
java-version: "21"
# ensure executable
- run: chmod +x ./gradlew
# run gradle test
- run: mvn clean test jacoco:report
- run: ./gradlew check --no-daemon
env:
MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
# process jacoco report
- name: Collect coverage report
id: coverage
run: |
INPUT=$(grep -o '<counter type="LINE" [^>]*>' target/site/jacoco/jacoco.xml | tail -1)
INPUT=$(grep -o '<counter type="LINE" [^>]*>' build/reports/jacoco/test/jacocoTestReport.xml | tail -1)
MISSED=$(echo "$INPUT" | sed -n 's/.*missed="\([0-9]*\)".*/\1/p')
COVERED=$(echo "$INPUT" | sed -n 's/.*covered="\([0-9]*\)".*/\1/p')
echo "MISSED: $MISSED"
+10
View File
@@ -51,6 +51,15 @@ jobs:
with:
distribution: "temurin"
java-version: "21"
# ensure executable
- run: chmod +x ./gradlew
# run gradle test and build
# disabled since gradle doesn't require maven token anymore
# - run: |
# ./gradlew check
# ./gradlew bootJar
# env:
# MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
# setup docker
- name: set up docker cli
run: |
@@ -74,6 +83,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
# file: ci.Dockerfile
file: Dockerfile
push: true
tags: |
+32 -46
View File
@@ -1,50 +1,36 @@
# Useful examples
# https://github.com/github/gitignore
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# OS X
*.DS_Store
# Java
*.jar
*.war
*.ear
# C/C++
*.so
*.dylib
*.dSYM
*.dll
*.jnilib
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
# Directories
**/bin/
**/classes/
**/dist/
**/include/
**/nbproject/
/.libs/
/findbugs/
/target/
#intellij
.idea/
*.iml
#logs
*.log
#project specific
/src/genjava
#Eclipse che
.che/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings/
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
+3 -3
View File
@@ -1,11 +1,11 @@
FROM maven:3 AS build
FROM gradle:jdk21 AS build
WORKDIR /code
COPY . /code
RUN mvn clean package spring-boot:repackage
RUN gradle bootJar --no-daemon
FROM azul/zulu-openjdk:21-latest
EXPOSE 8099
WORKDIR /app
COPY --from=build /code/target/*.jar /app/app.jar
COPY --from=build /code/build/libs/*.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+128
View File
@@ -0,0 +1,128 @@
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.7"
jacoco
checkstyle
idea
}
group = "com.cleverthis.clevermicro"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
configurations.all {
// disable the cache, so it will fetch the latest snapshot for client api
resolutionStrategy.cacheChangingModulesFor(30, TimeUnit.SECONDS)
resolutionStrategy.cacheDynamicVersionsFor(30, TimeUnit.SECONDS)
}
repositories {
mavenCentral()
maven {
name = "cleverthis-forgejo-maven"
url = uri("https://git.cleverthis.com/api/packages/clevermicro/maven")
// disable auth since artifacts are publicly available
// if (System.getenv("MAVEN_TOKEN").isNullOrBlank()) {
// credentials(HttpHeaderCredentials::class) {
// name = "Authorization"
// val token =
// findProperty("cleverThisForgejoToken") as String? ?: error("Token not found")
// value = "token $token"
// }
// } else {
// credentials(HttpHeaderCredentials::class) {
// name = "Authorization"
// val token = System.getenv("MAVEN_TOKEN")
// value = "token $token"
// }
// }
// authentication {
// create("header", HttpHeaderAuthentication::class)
// }
}
}
extra["springCloudVersion"] = "2025.0.0"
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}
// https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
val mockitoAgent = configurations.create("mockitoAgent")
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-consul-config")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// clevermicro
implementation("com.cleverthis.clevermicro:core:0.2.0-SNAPSHOT")
// keycloak admin client
implementation("org.keycloak:keycloak-admin-client:26.0.6")
// opentelemetry
implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.11.0"))
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
mockitoAgent("org.mockito:mockito-core") { isTransitive = false }
}
tasks.withType<Test> {
jvmArgs("-javaagent:${mockitoAgent.asPath}")
useJUnitPlatform()
// generate jacoco test report
finalizedBy("jacocoTestReport")
// show full output streams so we can debug for pipelines
testLogging {
showStandardStreams = true
}
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
}
}
checkstyle {
toolVersion = "10.23.1"
configFile = file("${project.rootDir}/checkstyle.xml")
isShowViolations = true
isIgnoreFailures = false
maxWarnings = 0
maxErrors = 0
sourceSets = setOf(project.sourceSets["main"])
}
idea { // tell IDEA to always download source code and javadoc
module {
isDownloadJavadoc = true
isDownloadSources = true
}
}
+5
View File
@@ -0,0 +1,5 @@
FROM azul/zulu-openjdk:21-latest
EXPOSE 8099
WORKDIR /app
COPY build/libs/*.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-161
View File
@@ -1,161 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cleverthis</groupId>
<artifactId>auth-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>auth-service</name>
<description>User management project for CleverThis</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
<start-class>com.cleverthis.authservice.AuthServiceApplication</start-class>
<spring-cloud.version>2024.0.1</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.13</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.23.1</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<encoding>UTF-8</encoding>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
<violationSeverity>warning</violationSeverity>
<failOnViolation>true</failOnViolation>
<linkXRef>false</linkXRef>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+1
View File
@@ -0,0 +1 @@
rootProject.name = "auth-service"
@@ -0,0 +1,89 @@
package com.cleverthis.authservice.config;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpConfig;
import com.rabbitmq.client.ConnectionFactory;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeoutException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
/**
* Provide {@link CleverMicroAmqpClient} as a bean.
*/
@Configuration
@EnableConfigurationProperties(CleverMicroClientConfigProperties.class)
@Slf4j
public class CleverMicroClientConfig {
private final CleverMicroClientConfigProperties properties;
public CleverMicroClientConfig(
final CleverMicroClientConfigProperties configProperties
) {
this.properties = configProperties;
}
/**
* Post construct.
*/
@PostConstruct
public void init() {
// correct availability
if (this.properties.getInitialAvailability() <= 0) {
this.properties.setInitialAvailability(Runtime.getRuntime().availableProcessors() * 2);
}
log.info("CleverMicroClientConfig initial availability: {}",
this.properties.getInitialAvailability());
log.info("CleverMicroClient will use swarm info: serviceId={}, taskId={}",
this.properties.getSwarmServiceId(), this.properties.getSwarmTaskId());
}
/**
* Provide a RabbitMQ {@link ConnectionFactory}.
*/
@Bean
@Lazy
public ConnectionFactory connectionFactory() {
final var factory = new ConnectionFactory();
factory.setHost(this.properties.getRabbitmqHost());
factory.setPort(this.properties.getRabbitmqPort());
factory.setUsername(this.properties.getRabbitmqUsername());
factory.setPassword(this.properties.getRabbitmqPassword());
factory.setVirtualHost(this.properties.getRabbitmqVhost());
factory.setAutomaticRecoveryEnabled(true);
return factory;
}
/**
* Create a {@link CleverMicroAmqpClient} bean.
*/
@Bean
@Lazy
@ConditionalOnMissingBean
public CleverMicroAmqpClient cleverMicroAmqpClient(
final ConnectionFactory connectionFactory,
final ApplicationContext applicationContext
) throws IOException, TimeoutException {
final var client = new CleverMicroAmqpClient(
connectionFactory.newConnection(), CleverMicroAmqpConfig.builder()
.serviceName(applicationContext.getApplicationName())
.swarmServiceId(this.properties.getSwarmServiceId())
.swarmTaskId(this.properties.getSwarmTaskId())
.reportAvailabilityUsageCooldown(Duration.ofMillis(250))
.reportAvailabilityRecoveryCooldown(Duration.ofSeconds(1))
.reportAvailabilityRegularCooldown(Duration.ofSeconds(5))
.build()
);
client.getHandlerManager().getAvailability()
.release(this.properties.getInitialAvailability());
return client;
}
}
@@ -0,0 +1,65 @@
package com.cleverthis.authservice.config;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Config properties for {@link CleverMicroClientConfig}.
*/
@ConfigurationProperties(prefix = "clevermicro.client")
@Builder
@Getter
@Setter
public class CleverMicroClientConfigProperties {
/**
* Host of the rabbitmq server.
*/
@Builder.Default
private String rabbitmqHost = "localhost";
/**
* The port of the rabbitmq server.
*/
@Builder.Default
private int rabbitmqPort = 5672;
/**
* RabbitMQ username.
*/
@Builder.Default
private String rabbitmqUsername = "guest";
/**
* RabbitMQ password.
*/
@Builder.Default
private String rabbitmqPassword = "guest";
/**
* Vhost of the rabbitmq server.
*/
@Builder.Default
private String rabbitmqVhost = "/";
/**
* Initial availability.
* <p>
* Default: CPU cores * 2, since the workload for auth-service is mainly IO-intensive.
* </p>
* <p>Set to -1 to use the default value.</p>
* */
@Builder.Default
private int initialAvailability = Runtime.getRuntime().availableProcessors() * 2;
/**
* Docker swarm service id.
*/
private String swarmServiceId;
/**
* Docker swarm task id.
*/
private String swarmTaskId;
}
@@ -1,15 +1,37 @@
package com.cleverthis.authservice.config;
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
import org.keycloak.admin.client.Keycloak;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
/**
* Config for keycloak clients:
* {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService}
* and {@link com.cleverthis.authservice.service.impl.KeycloakAdminClient}.
* and {@link KeycloakAdminReactiveClient}.
*/
@Configuration
@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
public class KeycloakClientConfig {
// empty for now
/**
* Create a Keycloak admin client.
* */
@Bean
@Lazy
@ConditionalOnMissingBean
public Keycloak keycloakAdminClient(
final KeycloakClientConfigProperties configProperties
) {
return Keycloak.getInstance(
configProperties.getKeycloakAdminHost(),
configProperties.getRealm(),
configProperties.getAdminAccountUsername(),
configProperties.getAdminAccountPassword(),
"admin-cli"
);
}
}
@@ -40,6 +40,7 @@ public class PermissionMappingConfigProperties {
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
private String keycloakClientId;
@Builder.Default
private List<PassThrough> passthroughs = new ArrayList<>();
}
@@ -187,17 +187,19 @@ public class AuthController {
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
originalUriString);
// first detect the client id and service relative path
final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString);
if (targetServiceClientIdOpt.isEmpty()) {
final var optionalRule = getPermissionCheckRuleByUri(originalUriString);
if (optionalRule.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 rule = optionalRule.get();
final var targetServiceClientId = rule.getKeycloakClientId();
final var serviceRelativePath =
extractServiceRelativePath(originalUriString, targetServiceClientId);
extractServiceRelativePath(originalUriString, rule);
// then check pass-through rule
if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) {
if (shouldPassThrough(rule, serviceRelativePath)) {
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
}
if (authorizationHeader == null) {
@@ -276,16 +278,15 @@ public class AuthController {
}
private boolean shouldPassThrough(
final String serviceClientId, final String serviceRelativePath
final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath
) {
final var rules = this.permissionMapLookupService
.getPassThoughRulesByClientId(serviceClientId);
final var passthroughs = rule.getPassthroughs();
try {
return rules.stream()
return passthroughs.stream()
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
} catch (final Throwable throwable) {
log.error("Error executing pass-through rule for client {} : {}",
serviceClientId, throwable.getMessage(), throwable);
rule.getKeycloakClientId(), throwable.getMessage(), throwable);
return false;
}
}
@@ -300,13 +301,15 @@ public class AuthController {
};
}
private Optional<String> getTargetServiceClientId(String fullUriString) {
private Optional<PermissionMappingConfigProperties.Rule> getPermissionCheckRuleByUri(
String fullUriString
) {
try {
URI uri = new URI(fullUriString);
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
log.debug("Mapping URI path: {}", path);
final var pathMatch = this.permissionMapLookupService.matchClientIdByPath(path);
final var pathMatch = this.permissionMapLookupService.matchRuleByPath(path);
if (pathMatch.isPresent()) {
return pathMatch;
}
@@ -315,7 +318,9 @@ public class AuthController {
log.debug(
"No rule matched for path '{}', using default Keycloak Client ID:{}",
path, defaultValue);
return Optional.of(defaultValue);
return Optional.of(PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId(defaultValue)
.build());
}
} catch (URISyntaxException e) {
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
@@ -324,17 +329,11 @@ public class AuthController {
return Optional.empty();
}
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
Optional<PermissionMappingConfigProperties.Rule> matchedRule =
this.permissionMapLookupService.matchRuleByClientIdAndPath(
mappedKeycloakClientId, fullUriString
);
String pathPrefixToRemove = "";
if (matchedRule.isPresent()) {
pathPrefixToRemove = matchedRule.get().getPathPrefix();
} else if (mappedKeycloakClientId
private String extractServiceRelativePath(
String fullUriString, PermissionMappingConfigProperties.Rule rule
) {
String pathPrefixToRemove = rule.getPathPrefix();
if (rule.getKeycloakClientId()
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
// If it's a default mapping, there's no prefix to remove, use the whole path.
// Or, you might decide that default mappings always apply to root paths. This
@@ -0,0 +1,148 @@
package com.cleverthis.authservice.controller.rabbitmq;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.BuiltinExchangeType;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
/**
* Abstract class for endpoint of auth-service over RabbitMQ.
*/
@Slf4j
public abstract class AbstractEndpoint implements AutoCloseable {
public static final String ENDPOINT_EXCHANGE = "cleverthis.clevermicro.auth.endpoints";
public static final BuiltinExchangeType ENDPOINT_EXCHANGE_TYPE = BuiltinExchangeType.DIRECT;
private final String handlerTag;
private final CleverMicroAmqpClient client;
protected final ObjectMapper objectMapper;
private final String queueName;
/**
* Initialize this abstract class.
*/
public AbstractEndpoint(
final CleverMicroAmqpClient client,
final ObjectMapper objectMapper,
final String routingKey
) throws IOException {
this.client = client;
this.objectMapper = objectMapper;
this.queueName = ENDPOINT_EXCHANGE + "." + routingKey;
client.getHandlerManager().declareExchange(ENDPOINT_EXCHANGE, ENDPOINT_EXCHANGE_TYPE);
client.getHandlerManager().declareQueue(
this.queueName, true, false, false, Map.of()
);
client.getHandlerManager().bindQueue(
this.queueName, ENDPOINT_EXCHANGE, routingKey, Map.of()
);
this.handlerTag = client.getHandlerManager().registerHandler(
this.queueName, "", 1,
ctx -> new Thread(() -> handleRabbitMessage(ctx)).start(),
tag -> log.info("Endpoint canceled for queue {}: handlerTag={}",
this.queueName, tag)
);
}
// VisibleForTesting
void handleRabbitMessage(final HandlerContext ctx) {
try {
final var req = parseRequest(ctx);
if (req == null) {
throw new EndpointBadRequestException(
"Malformed request, cannot be parsed");
}
handleRequest(ctx, req);
} catch (final EndpointException ex) {
final var resp = EndpointResponses.error(ex);
reply(ctx, resp);
} catch (final Throwable t) {
log.error(
"Error when handling request for queue {}, only refill availability",
this.queueName, t
);
final var resp = EndpointResponses.internalServerError(
"Error when handling the request", t);
reply(ctx, resp);
}
}
// VisibleForTesting
EndpointRequest parseRequest(final HandlerContext ctx) {
InputStream reqStream = null;
if (ctx.getMessageBodySingleStream().isPresent()) {
reqStream = ctx.getMessageBodySingleStream().get();
} else if (ctx.getMessageBodyMultiStream().isPresent()) {
final var multibody = ctx.getMessageBodyMultiStream().get();
reqStream = multibody.get("request");
}
// check null
if (reqStream == null) {
return null;
}
// parsing, need to convert to final
try (final var stream = reqStream) {
return this.objectMapper.readValue(
stream,
EndpointRequest.class
);
} catch (final IOException ex) {
log.error("Error when parsing single stream request for queue: {}", this.queueName, ex);
return null;
}
}
/**
* Handle a request.
*/
protected abstract void handleRequest(
final HandlerContext ctx,
final EndpointRequest request
) throws EndpointException;
/**
* Try to reply. Refill counter if error.
*/
protected void reply(final HandlerContext ctx, final Object response) {
final byte[] bytes;
try {
bytes = this.objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsBytes(response);
} catch (final JsonProcessingException ex) {
log.error("Error when serializing response, ignore reply and refill counter. Queue {}",
this.queueName, ex);
ctx.refillAvailability();
return;
}
try {
ctx.finish(bytes);
} catch (final IOException ex) {
// the counter should be refilled before exception
log.error("Error when replying request for queue {}", this.queueName, ex);
}
}
/**
* Close the endpoint and release resources.
* Should be called in {@link jakarta.annotation.PreDestroy} annotated methods.
*/
@Override
@PreDestroy
public void close() {
log.info("Closing Endpoint for queue: {}, consumerTag={}", this.queueName, this.handlerTag);
this.client.getHandlerManager().cancelHandler(this.handlerTag);
}
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.controller.rabbitmq.exception;
/**
* Exception to signal the request is malformed or invalid.
*/
public class EndpointBadRequestException extends EndpointException {
private static final String ENDPOINT_EXCEPTION = "cleverthis.clevermicro.bad_request";
public EndpointBadRequestException(
final String message,
final Throwable cause
) {
super(ENDPOINT_EXCEPTION, message, cause);
}
public EndpointBadRequestException(
final String message
) {
super(ENDPOINT_EXCEPTION, message);
}
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.controller.rabbitmq.exception;
import lombok.Getter;
/**
* Exception for endpoints.
*/
public class EndpointException extends Exception {
@Getter
private final String endpointException;
public EndpointException(
final String endpointException,
final String message,
final Throwable cause
) {
super(message, cause);
this.endpointException = endpointException;
}
public EndpointException(
final String endpointException,
final String message
) {
super(message);
this.endpointException = endpointException;
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.controller.rabbitmq.exception;
/**
* Exception to signal the handler is failed abnormally.
*/
public class EndpointInternalServerErrorException extends EndpointException {
private static final String ENDPOINT_EXCEPTION = "cleverthis.clevermicro.server_internal_error";
public EndpointInternalServerErrorException(
final String message,
final Throwable cause
) {
super(ENDPOINT_EXCEPTION, message, cause);
}
}
@@ -0,0 +1,39 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* The model for replied response.
*/
@EqualsAndHashCode(callSuper = true)
@Getter
@Setter
@ToString
@Builder
public final class EndpointErrorResponse extends EndpointResponse {
@JsonProperty("exception")
private final String exception;
@JsonProperty("message")
private final String message;
@JsonProperty("additional_info")
private final Object additionalInfo;
/**
* Create an error response.
*/
public EndpointErrorResponse(
final String exception,
final String message,
final Object additionalInfo
) {
super("ERROR");
this.exception = exception;
this.message = message;
this.additionalInfo = additionalInfo;
}
}
@@ -0,0 +1,27 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* The model for replied response.
*/
@EqualsAndHashCode(callSuper = true)
@Getter
@Setter
@ToString
@Builder
public class EndpointOkResponse extends EndpointResponse {
@JsonProperty("result")
private final List<?> result;
public EndpointOkResponse(final List<?> result) {
super("OK");
this.result = result;
}
}
@@ -0,0 +1,19 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.LinkedHashMap;
import lombok.Builder;
import lombok.Data;
/**
* The model for incoming requests.
*/
@Data
@Builder
public class EndpointRequest {
@JsonProperty("method")
private final String method;
@JsonProperty("args")
private final LinkedHashMap<String, JsonNode> args;
}
@@ -0,0 +1,19 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
/**
* The model for replied response.
*/
@Getter
@Setter
public abstract class EndpointResponse {
@JsonProperty("type")
protected final String type;
protected EndpointResponse(final String type) {
this.type = type;
}
}
@@ -0,0 +1,84 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointInternalServerErrorException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* The model for replied response.
*/
public class EndpointResponses {
private EndpointResponses() {
}
/**
* Create an error response from endpoint exception.
*/
public static EndpointErrorResponse error(
final EndpointException ex
) {
final var sw = new StringWriter();
final var pw = new PrintWriter(sw);
ex.printStackTrace(pw);
pw.flush();
pw.close();
return EndpointErrorResponse.builder()
.exception(ex.getEndpointException())
.message(ex.getMessage())
.additionalInfo(Map.of(
"stacktrace", sw.toString()
))
.build();
}
/**
* Create an error response from general exception.
*/
public static EndpointErrorResponse internalServerError(
final String message,
final Throwable throwable
) {
return EndpointResponses.error(
new EndpointInternalServerErrorException(
message, throwable
)
);
}
/**
* Create an ok response from a result.
*/
public static <T> EndpointOkResponse ok(final T result) {
return EndpointOkResponse.builder()
.result(List.of(result))
.build();
}
/**
* Create an ok response from a list of results.
*/
public static <T> EndpointOkResponse ok(final Iterable<T> result) {
final var list = new LinkedList<T>();
result.forEach(list::add);
return EndpointOkResponse.builder()
.result(list)
.build();
}
/**
* Create an ok response from an array of results.
*/
public static <T> EndpointOkResponse ok(final T[] result) {
// here we make a copy so the changes to the input array
// will not change the result field.
final var list = new LinkedList<>(Arrays.asList(result));
return EndpointOkResponse.builder()
.result(list)
.build();
}
}
@@ -0,0 +1,70 @@
package com.cleverthis.authservice.controller.rabbitmq.v1.user;
import com.cleverthis.authservice.controller.rabbitmq.AbstractEndpoint;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses;
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.NoSuchElementException;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.stereotype.Component;
/**
* Serve endpoints on `user-service-v1`.
*/
@Component
public class UserEndpointV1 extends AbstractEndpoint {
private static final String ROUTING_KEY = "user-service-v1";
private final KeycloakAdminReactiveClient keycloakAdminClient;
public UserEndpointV1(
final CleverMicroAmqpClient client,
final ObjectMapper objectMapper,
final KeycloakAdminReactiveClient keycloakAdminClient
) throws IOException {
super(client, objectMapper, ROUTING_KEY);
this.keycloakAdminClient = keycloakAdminClient;
}
@Override
protected void handleRequest(
final HandlerContext ctx, final EndpointRequest request
) throws EndpointException {
//noinspection SwitchStatementWithTooFewBranches
final Object result = switch (request.getMethod()) {
case "queryUserById" -> queryUserById(request);
default -> throw new EndpointBadRequestException("Method not found");
};
reply(ctx, EndpointResponses.ok(result));
}
private UserRepresentation queryUserById(
final EndpointRequest request
) throws EndpointException {
final var argId = request.getArgs().get("id");
if (argId == null) {
throw new EndpointBadRequestException("Missing parameter 'id'");
}
if (!argId.isTextual()) {
throw new EndpointBadRequestException(
"Invalid type for parameter 'id', required non-null string");
}
// this should be non-null
final var id = argId.textValue();
try {
return this.keycloakAdminClient.getUserDetails(id).block();
} catch (final NoSuchElementException ex) {
throw new EndpointException(
"cleverthis.clevermicro.auth.user_not_found",
"User id " + id + "not found",
ex
);
}
}
}
@@ -8,6 +8,7 @@ import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
/**
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
@@ -1,18 +0,0 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Representation of an organization's internet domain within Keycloak.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrganizationDomainRepresentation {
private String name;
private Boolean verified;
}
@@ -1,12 +1,12 @@
package com.cleverthis.authservice.dto.organization;
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
/**
* DTO for representing an Organization in API responses.
@@ -12,6 +12,8 @@ import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import java.util.List;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import reactor.core.publisher.Mono;
/**
@@ -1,7 +1,6 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import java.util.List;
import java.util.Optional;
/**
@@ -17,18 +16,5 @@ public interface PermissionMapLookupService {
/**
* Given the path, find the client id of the first rule matching the path prefix.
*/
Optional<String> matchClientIdByPath(String path);
/**
* Given the client id and path, find the rule that both matches the client id exactly,
* and match the path prefix.
*/
Optional<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
String clientId, String path);
/**
* Return the list of pass-through rules by client id.
*/
List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
String clientId);
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(String path);
}
@@ -5,7 +5,6 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
@@ -102,38 +101,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
@Override
public Optional<String> matchClientIdByPath(final String path) {
public Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(final String path) {
final var config = getConfig();
for (final var rule : config.getRules()) {
if (path.startsWith(rule.getPathPrefix())) {
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
path, rule.getPathPrefix(), rule.getKeycloakClientId());
return Optional.of(rule.getKeycloakClientId());
return Optional.of(rule);
}
}
return Optional.empty();
}
@Override
public Optional<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
final String clientId, final String path
) {
final var config = getConfig();
return config.getRules().stream()
.filter(rule ->
rule.getKeycloakClientId().equals(clientId)
&& path.startsWith(rule.getPathPrefix()))
.findFirst();
}
@Override
public List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
final String clientId
) {
final var config = getConfig();
return config.getRules().stream()
.filter(rule -> rule.getKeycloakClientId().equals(clientId))
.flatMap(rule -> rule.getPassthroughs().stream())
.toList();
}
}
@@ -1,531 +0,0 @@
package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse;
import com.cleverthis.authservice.dto.KeycloakClientRepresentation;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
/**
* Service to interact with Keycloak Admin API. Handles obtaining an admin token for this
* auth-service and making admin calls.
*/
@Service
@Slf4j
public class KeycloakAdminClient {
private final WebClient webClient;
private final AtomicReference<String> adminAccessToken = new AtomicReference<>();
private final AtomicReference<Long> tokenExpiryTime = new AtomicReference<>(0L);
private final String keycloakAdminHost;
private final String realm;
private final String adminAccountUsername;
private final String adminAccountPassword;
/**
* Create a keycloak admin client.
*/
public KeycloakAdminClient(
@Qualifier("keycloakWebClient") final WebClient webClient,
final KeycloakClientConfigProperties configProperties
) {
this.webClient = webClient;
this.keycloakAdminHost = configProperties.getKeycloakAdminHost();
this.realm = configProperties.getRealm();
this.adminAccountUsername = configProperties.getAdminAccountUsername();
this.adminAccountPassword = configProperties.getAdminAccountPassword();
}
private String getAdminTokenEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/realms/{realm}/protocol/openid-connect/token")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getClientsEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/clients")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getUserClientRolesEndpoint(String userId, String clientInternalId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/users/{userId}"
+ "/role-mappings/clients/{clientId}/composite")
.buildAndExpand(this.realm, this.realm, userId, clientInternalId)
.encode().toUriString();
}
private String getOrganizationByIdEndpoint(String orgId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}")
.buildAndExpand(this.realm, orgId)
.encode().toUriString();
}
private String getOrganizationsEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getOrganizationMembersEndpoint(String orgId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}/members")
.buildAndExpand(this.realm, orgId)
.encode().toUriString();
}
private String getOrganizationMemberByIdEndpoint(String orgId, String userId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}/members/{userId}")
.buildAndExpand(this.realm, orgId, userId)
.encode().toUriString();
}
private String getOrganizationInvitationEndpoint(String orgId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}/members/invite-user")
.buildAndExpand(this.realm, orgId)
.encode().toUriString();
}
private String getGroupsEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups")
.buildAndExpand(this.realm).toUriString();
}
private String getGroupByIdEndpoint(String groupId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups/{groupId}")
.buildAndExpand(this.realm, groupId).encode().toUriString();
}
private String getGroupChildrenEndpoint(String parentGroupId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups/{parentGroupId}/children")
.buildAndExpand(this.realm, parentGroupId)
.encode().toUriString();
}
private String getGroupMembersEndpoint(String groupId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups/{groupId}/members")
.buildAndExpand(this.realm, groupId).encode()
.toUriString();
}
private String getGroupMemberByIdEndpoint(String groupId, String userId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/users/{userId}/groups/{groupId}")
.buildAndExpand(this.realm, userId, groupId).encode().toUriString();
}
/**
* Retrieves a valid admin access token for this service, refreshing if necessary. Uses client
* credentials grant.
*
* @return Mono emitting the admin access token.
*/
Mono<String> getAdminAccessToken() {
if (System.currentTimeMillis() < this.tokenExpiryTime.get()
&& this.adminAccessToken.get() != null) {
log.debug("Using cached admin access token.");
return Mono.just(this.adminAccessToken.get());
}
log.info("Fetching new admin access token for auth-service using account: {}",
this.adminAccountUsername);
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", "admin-cli");
formData.add("username", this.adminAccountUsername);
formData.add("password", this.adminAccountPassword);
formData.add("grant_type", "password");
return this.webClient.post().uri(getAdminTokenEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
response -> response.bodyToMono(String.class).flatMap(errorBody -> {
log.error("Failed to get admin token. Status: {}, Body: {}",
response.statusCode(), errorBody);
return Mono.error(new ServiceUnavailableException(
"Could not obtain admin token from Keycloak. Status: "
+ response.statusCode()));
}))
.bodyToMono(KeycloakAdminTokenResponse.class).map(tokenResponse -> {
this.adminAccessToken.set(tokenResponse.getAccessToken());
// Set expiry time slightly before actual expiry to be safe (e.g., 30 seconds
// buffer)
this.tokenExpiryTime.set(System.currentTimeMillis()
+ (tokenResponse.getExpiresIn() - 30) * 1000L);
log.info("Successfully obtained new admin access token.");
return tokenResponse.getAccessToken();
})
.retryWhen(Retry.backoff(3, Duration.ofSeconds(2))
.maxBackoff(Duration.ofSeconds(10))
.filter(throwable -> throwable instanceof ServiceUnavailableException)
.doBeforeRetry(
retrySignal -> log.warn("Retrying admin token fetch attempt #{}",
retrySignal.totalRetries() + 1)));
}
/**
* Fetches the internal UUID of a Keycloak client given its public clientId.
*
* @param publicClientId The human-readable client_id (e.g., "cleverswarm-client").
* @return Mono emitting the internal UUID string.
*/
public Mono<String> getClientInternalId(String publicClientId) {
log.debug("Fetching internal ID for client: {}", publicClientId);
// We expect only one client with this ID
URI targetUri = UriComponentsBuilder.fromUriString(getClientsEndpoint())
.queryParam("clientId", publicClientId).queryParam("max", 1).build().toUri();
log.debug("Constructed URI for getClientInternalId: {}", targetUri.toString());
return getAdminAccessToken().flatMap(token -> this.webClient.get().uri(targetUri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
response -> response.bodyToMono(String.class).flatMap(errorBody -> {
log.error("Failed to get client '{}'. Status: {}, Body: {}",
publicClientId, response.statusCode(), errorBody);
return Mono.error(new ServiceUnavailableException(
"Could not fetch client details from Keycloak. Client: "
+ publicClientId));
}))
.bodyToFlux(KeycloakClientRepresentation.class)// Expecting a list, even if one item
.next() // Take the first element if present
.map(KeycloakClientRepresentation::getId)
.switchIfEmpty(Mono.error(new ServiceUnavailableException(
"Client not found in Keycloak: " + publicClientId)))
.doOnSuccess(id -> log.debug("Found internal ID '{}' for client '{}'", id,
publicClientId)));
}
/**
* 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 clientInternalId The internal UUID of the Keycloak client.
* @return Mono emitting a List of KeycloakRoleRepresentation.
*/
public Mono<List<KeycloakRoleRepresentation>> getUserEffectiveClientRoles(
String userId, String clientInternalId
) {
log.debug("Fetching effective client roles for user '{}' on client internal ID '{}'",
userId, clientInternalId);
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getUserClientRolesEndpoint(userId, clientInternalId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
response -> response.bodyToMono(String.class).flatMap(errorBody -> {
log.error(
"Failed to get user client roles. User:"
+ " {}, ClientInternalId: {}, Status: {}, Body: {}",
userId, clientInternalId, response.statusCode(), errorBody);
return Mono.error(new ServiceUnavailableException(
"Could not fetch user client roles from Keycloak."));
}))
.bodyToFlux(KeycloakRoleRepresentation.class).collectList()
.doOnSuccess(roles -> log.debug(
"Found {} effective client roles for user '{}' on client internal ID '{}'",
roles.size(), userId, clientInternalId)));
}
/**
* Creates a new organization in Keycloak.
*
* @param orgToCreate A representation of the organization to be created.
* @return A Mono emitting the representation of the newly created organization, including its
* ID.
*/
public Mono<KeycloakOrganizationRepresentation> createOrganization(
KeycloakOrganizationRepresentation orgToCreate) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.post().uri(getOrganizationsEndpoint())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON).bodyValue(orgToCreate).retrieve()
// Add error handling here for 409 Conflict, etc.
.bodyToMono(KeycloakOrganizationRepresentation.class));
}
/**
* Retrieves all organizations within the realm.
*
* @return A Mono emitting a List of all organization representations.
*/
public Mono<List<KeycloakOrganizationRepresentation>> getOrganizations() {
return getAdminAccessToken()
.flatMap(token -> this.webClient.get().uri(getOrganizationsEndpoint())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToFlux(KeycloakOrganizationRepresentation.class).collectList());
}
/**
* Retrieves a single organization by its unique ID.
*
* @param orgId The ID of the organization to retrieve.
* @return A Mono emitting the representation of the found organization.
*/
public Mono<KeycloakOrganizationRepresentation> getOrganizationById(String orgId) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.get().uri(getOrganizationByIdEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToMono(KeycloakOrganizationRepresentation.class));
}
/**
* Updates an existing organization's details in Keycloak.
*
* @param orgId The ID of the organization to update.
* @param orgToUpdate A representation containing the updated fields for the organization.
* @return A Mono that completes when the update is successful.
*/
public Mono<Void> updateOrganization(String orgId,
KeycloakOrganizationRepresentation orgToUpdate) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.put().uri(getOrganizationByIdEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON).bodyValue(orgToUpdate).retrieve()
.bodyToMono(Void.class));
}
/**
* Deletes an organization from Keycloak.
*
* @param orgId The ID of the organization to delete.
* @return A Mono that completes when the deletion is successful.
*/
public Mono<Void> deleteOrganization(String orgId) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.delete().uri(getOrganizationByIdEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToMono(Void.class));
}
/**
* Sends an invitation for a user to join an organization. Keycloak handles the email delivery.
*
* @param orgId The ID of the organization to invite the user to.
* @param invitation An object containing the invitee's details (email, name).
* @return A Mono that completes when the invitation has been successfully sent.
*/
public Mono<Void> inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) {
// Create form data from the invitation DTO
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("email", invitation.getEmail());
if (invitation.getFirstName() != null) {
formData.add("firstName", invitation.getFirstName());
}
if (invitation.getLastName() != null) {
formData.add("lastName", invitation.getLastName());
}
return getAdminAccessToken().flatMap(token -> this.webClient.post()
.uri(getOrganizationInvitationEndpoint(orgId)) // Points to /invitations
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
// UPDATED: Use FORM_URLENCODED content type
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
// UPDATED: Use fromFormData to send the body
.body(BodyInserters.fromFormData(formData)).retrieve().bodyToMono(Void.class));
}
/**
* Retrieves a list of all members of a specific organization.
*
* @param orgId The ID of the organization.
* @return A Mono emitting a List of user representations for the members.
*/
public Mono<List<KeycloakUserRepresentation>> getOrganizationMembers(String orgId) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.get().uri(getOrganizationMembersEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToFlux(KeycloakUserRepresentation.class).collectList());
}
/**
* Adds an existing Keycloak user to an organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to add as a member.
* @return A Mono that completes when the user is successfully added.
*/
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
// Keycloak's API to add an existing user to an Organization is a POST
// to the /members collection endpoint, with the user's ID in the body.
return getAdminAccessToken().flatMap(token -> this.webClient.post()
// Use the endpoint for the members collection, NOT the specific member
.uri(getOrganizationMembersEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
// The body should be a representation containing the user's ID
.bodyValue(userId).retrieve().bodyToMono(Void.class));
}
/**
* Removes a member from an organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to remove.
* @return A Mono that completes when the user is successfully removed.
*/
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
.uri(getOrganizationMemberByIdEndpoint(orgId, userId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToMono(Void.class));
}
/**
* Creates a new sub-group as a child of a parent group.
*
* @param parentGroupId The ID of the parent group.
* @param group A representation of the sub-group to create.
* @return A Mono that completes when the group is created.
* Keycloak returns location header, not body.
*/
public Mono<Void> createSubGroup(String parentGroupId, KeycloakGroupRepresentation group) {
return getAdminAccessToken().flatMap(token -> this.webClient.post()
.uri(getGroupChildrenEndpoint(parentGroupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(group)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Retrieves a group by its unique ID.
*
* @param groupId The ID of the group to retrieve.
* @return A Mono emitting the group representation.
*/
public Mono<KeycloakGroupRepresentation> getGroupById(String groupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getGroupByIdEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(KeycloakGroupRepresentation.class));
}
/**
* Lists the direct children (sub-groups) of a parent group.
*
* @param parentGroupId The ID of the parent group.
* @return A Mono emitting a list of sub-group representations.
*/
public Mono<List<KeycloakGroupRepresentation>> listSubGroups(String parentGroupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getGroupChildrenEndpoint(parentGroupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToFlux(KeycloakGroupRepresentation.class)
.collectList());
}
/**
* Updates an existing group.
*
* @param groupId The ID of the group to update.
* @param group A representation containing the updated fields.
* @return A Mono that completes on successful update.
*/
public Mono<Void> updateGroup(String groupId, KeycloakGroupRepresentation group) {
return getAdminAccessToken().flatMap(token -> this.webClient.put()
.uri(getGroupByIdEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(group)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Deletes a group by its ID.
*
* @param groupId The ID of the group to delete.
* @return A Mono that completes on successful deletion.
*/
public Mono<Void> deleteGroup(String groupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
.uri(getGroupByIdEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Adds a user to a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user.
* @return A Mono that completes when the user is added to the group.
*/
public Mono<Void> addMemberToGroup(String groupId, String userId) {
// Keycloak API for adding a user to a group is a PUT on the user's groups link
return getAdminAccessToken().flatMap(token -> this.webClient.put()
.uri(getGroupMemberByIdEndpoint(groupId, userId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Retrieves all members of a specific group.
*
* @param groupId The ID of the group.
* @return A Mono emitting a list of user representations.
*/
public Mono<List<KeycloakUserRepresentation>> getGroupMembers(String groupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getGroupMembersEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToFlux(KeycloakUserRepresentation.class)
.collectList());
}
/**
* Removes a user from a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user.
* @return A Mono that completes when the user is removed from the group.
*/
public Mono<Void> removeMemberFromGroup(String groupId, String userId) {
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
.uri(getGroupMemberByIdEndpoint(groupId, userId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(Void.class));
}
}
@@ -0,0 +1,473 @@
package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.function.Consumer;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
/**
* Service to interact with Keycloak Admin API.
* Turn keycloak sdk into reactive.
*/
@Service
@Slf4j
public class KeycloakAdminReactiveClient {
private final Keycloak keycloak;
private final String realm;
/**
* Create a keycloak admin client.
*/
public KeycloakAdminReactiveClient(
final Keycloak keycloak,
final KeycloakClientConfigProperties properties
) {
this.keycloak = keycloak;
this.realm = properties.getRealm();
}
/**
* Creates a new organization in Keycloak.
*
* @param orgToCreate A representation of the organization to be created.
* @return A Mono emitting the representation of the newly created organization, including its
* ID.
*/
public Mono<OrganizationRepresentation> createOrganization(
OrganizationRepresentation orgToCreate
) {
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.organizations().create(orgToCreate);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.SUCCESSFUL) {
final var body = resp.readEntity(OrganizationRepresentation.class);
sink.success(body);
} else {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to create organization " + orgToCreate
+ ", body=" + body));
}
} catch (final Throwable t) {
sink.error(t);
}
});
}
/**
* Retrieves all organizations within the realm.
*
* @return A Mono emitting a List of all organization representations.
*/
public Mono<List<OrganizationRepresentation>> getOrganizations() {
return Mono.just(this.keycloak.realm(this.realm)
.organizations().list(null, Integer.MAX_VALUE));
}
/**
* Retrieves a single organization by its unique ID.
*
* @param orgId The ID of the organization to retrieve.
* @return A Mono emitting the representation of the found organization.
*/
public Mono<OrganizationRepresentation> getOrganizationById(String orgId) {
return Mono.create(sink -> {
try {
sink.success(this.keycloak.realm(this.realm)
.organizations().get(orgId).toRepresentation());
} catch (final NotFoundException ex) {
sink.error(new NoSuchElementException(
"Organization with id " + orgId + " not exists", ex));
}
});
}
/**
* Updates an existing organization's details in Keycloak.
*
* @param orgId The ID of the organization to update.
* @param orgToUpdate A representation containing the updated fields for the organization.
* @return A Mono that completes when the update is successful.
*/
public Mono<Void> updateOrganization(
String orgId, OrganizationRepresentation orgToUpdate
) {
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.organizations().get(orgId).update(orgToUpdate);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR
|| resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to update organization " + orgId
+ " with data " + orgToUpdate
+ ", body=" + body));
} else {
sink.success();
}
}
});
}
/**
* Deletes an organization from Keycloak.
*
* @param orgId The ID of the organization to delete.
* @return A Mono that completes when the deletion is successful.
*/
public Mono<Void> deleteOrganization(String orgId) {
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.organizations().get(orgId).delete();
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR
|| resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to delete organization " + orgId
+ ", body=" + body));
} else {
sink.success();
}
}
});
}
/**
* Sends an invitation for a user to join an organization. Keycloak handles the email delivery.
*
* @param orgId The ID of the organization to invite the user to.
* @param invitation An object containing the invitee's details (email, name).
* @return A Mono that completes when the invitation has been successfully sent.
*/
public Mono<Void> inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) {
// Create form data from the invitation DTO
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.organizations().get(orgId)
.members().inviteUser(
invitation.getEmail(), invitation.getFirstName(), invitation.getLastName()
);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR
|| resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to process invitation request " + invitation
+ " for organization " + orgId
+ ", body=" + body));
} else {
sink.success();
}
}
});
}
/**
* Retrieves a list of all members of a specific organization.
*
* @param orgId The ID of the organization.
* @return A Mono emitting a List of user representations for the members.
*/
public Mono<List<MemberRepresentation>> getOrganizationMembers(String orgId) {
return Mono.create(sink -> {
try {
sink.success(this.keycloak.realm(this.realm)
.organizations().get(orgId)
.members().list(null, Integer.MAX_VALUE));
} catch (final NotFoundException ex) {
sink.error(new NoSuchElementException(
"Organization with id " + orgId + " not exists", ex));
}
});
}
/**
* Adds an existing Keycloak user to an organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to add as a member.
* @return A Mono that completes when the user is successfully added.
*/
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
// Keycloak's API to add an existing user to an Organization is a POST
// to the /members collection endpoint, with the user's ID in the body.
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.organizations().get(orgId)
.members().addMember(userId);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR
|| resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to add member " + userId + " to organization " + orgId
+ ", body=" + body));
} else {
sink.success();
}
}
});
}
/**
* Removes a member from an organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to remove.
* @return A Mono that completes when the user is successfully removed.
*/
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.organizations().get(orgId)
.members().removeMember(userId);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR
|| resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to remove member " + userId + " from organization " + orgId
+ ", body=" + body));
} else {
sink.success();
}
}
});
}
/**
* Creates a new sub-group as a child of a parent group.
*
* @param parentGroupId The ID of the parent group.
* @param group A representation of the sub-group to create.
* @return A Mono that completes when the group is created.
* Keycloak returns location header, not body.
*/
public Mono<Void> createSubGroup(String parentGroupId, GroupRepresentation group) {
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm)
.groups().group(parentGroupId)
.subGroup(group);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR
|| resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) {
final var body = resp.readEntity(String.class);
sink.error(new ServiceUnavailableException(
"Failed to create sub-group '" + group.getName()
+ "', body=" + body));
} else {
sink.success();
}
}
});
}
/**
* Retrieves a group by its unique ID.
*
* @param groupId The ID of the group to retrieve.
* @return A Mono emitting the group representation.
*/
public Mono<GroupRepresentation> getGroupById(String groupId) {
return Mono.create((Consumer<MonoSink<GroupRepresentation>>) sink ->
sink.success(this.keycloak.realm(this.realm)
.groups().group(groupId).toRepresentation()))
.onErrorMap(NotFoundException.class,
ex -> new NoSuchElementException(
"Group with id " + groupId + " not exists", ex));
}
/**
* Lists the direct children (sub-groups) of a parent group.
*
* @param parentGroupId The ID of the parent group.
* @return A Mono emitting a list of sub-group representations.
*/
public Mono<List<GroupRepresentation>> listSubGroups(String parentGroupId) {
return Mono.create((Consumer<MonoSink<List<GroupRepresentation>>>) sink ->
sink.success(this.keycloak.realm(this.realm)
.groups().group(parentGroupId)
.getSubGroups(null, Integer.MAX_VALUE, false)))
.onErrorMap(NotFoundException.class,
ex -> new NoSuchElementException(
"Group with id " + parentGroupId + " not exists", ex));
}
/**
* Updates an existing group.
*
* @param groupId The ID of the group to update.
* @param group A representation containing the updated fields.
* @return A Mono that completes on successful update.
*/
public Mono<Void> updateGroup(String groupId, GroupRepresentation group) {
return Mono
.create((Consumer<MonoSink<Void>>) sink -> {
this.keycloak.realm(this.realm).groups().group(groupId).update(group);
sink.success();
})
.onErrorMap(NotFoundException.class, ex -> new NoSuchElementException(
"Group with id " + groupId + " not exists"
));
}
/**
* Deletes a group by its ID.
*
* @param groupId The ID of the group to delete.
* @return A Mono that completes on successful deletion.
*/
public Mono<Void> deleteGroup(String groupId) {
return Mono
.create((Consumer<MonoSink<Void>>) sink -> {
this.keycloak.realm(this.realm).groups().group(groupId).remove();
sink.success();
})
.onErrorMap(NotFoundException.class, ex -> new NoSuchElementException(
"Group with id " + groupId + " not exists"
));
}
/**
* Adds a user to a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user.
* @return A Mono that completes when the user is added to the group.
*/
public Mono<Void> addMemberToGroup(String groupId, String userId) {
// Keycloak API for adding a user to a group is a PUT on the user's groups link
return Mono
.create((Consumer<MonoSink<Void>>) sink -> {
this.keycloak.realm(this.realm).users().get(userId).joinGroup(groupId);
sink.success();
})
.onErrorMap(ClientErrorException.class, ex -> new IllegalArgumentException(
"Failed to add user with id " + userId + " to group with id " + groupId
));
}
/**
* Retrieves all members of a specific group.
*
* @param groupId The ID of the group.
* @return A Mono emitting a list of user representations.
*/
public Mono<List<UserRepresentation>> getGroupMembers(String groupId) {
return Mono.create((Consumer<MonoSink<List<UserRepresentation>>>) sink -> sink.success(
this.keycloak.realm(this.realm).groups().group(groupId).members()))
.onErrorMap(NotFoundException.class, ex -> new NoSuchElementException(
"Group with id " + groupId + " not exist", ex
));
}
/**
* Removes a user from a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user.
* @return A Mono that completes when the user is removed from the group.
*/
public Mono<Void> removeMemberFromGroup(String groupId, String userId) {
return Mono
.create((Consumer<MonoSink<Void>>) sink -> {
this.keycloak.realm(this.realm).users().get(userId).leaveGroup(groupId);
sink.success();
})
.onErrorMap(ClientErrorException.class, ex -> new IllegalArgumentException(
"Failed to remove user with id " + userId + " from group with id " + groupId
));
}
/**
* Register user.
*/
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
log.info("Registering new user with email: {}", registrationRequest.getEmail());
final var user = new UserRepresentation();
user.setUsername(registrationRequest.getEmail());
user.setEmail(registrationRequest.getEmail());
user.setFirstName(registrationRequest.getFirstName());
user.setLastName(registrationRequest.getLastName());
user.setEnabled(true);
user.setEmailVerified(false);
final var credentials = new CredentialRepresentation();
credentials.setType(CredentialRepresentation.PASSWORD);
credentials.setValue(registrationRequest.getPassword());
credentials.setTemporary(false);
user.setCredentials(Collections.singletonList(credentials));
// Tell Keycloak to initiate email verification,
// we may implement our email verification on future.
user.setRequiredActions(Collections.singletonList("VERIFY_EMAIL"));
return Mono.create(sink -> {
final var resp = this.keycloak.realm(this.realm).users().create(user);
try (resp) {
if (resp.getStatusInfo().getFamily() == Response.Status.Family.SUCCESSFUL) {
log.info(
"User {} created successfully in Keycloak",
registrationRequest.getEmail());
sink.success();
} else if (resp.getStatus() == 409) { // CONFLICT
log.warn(
"User registration failed for {}:"
+ " User already exists (Conflict).",
registrationRequest.getEmail());
sink.error(new UserAlreadyExistsException("User with email "
+ registrationRequest.getEmail()
+ " already exists."));
} else {
final var errorBody = resp.readEntity(String.class);
log.error("Keycloak user creation failed for {}"
+ " with status {}: {}",
registrationRequest.getEmail(), resp.getStatus(),
errorBody);
sink.error(new RegistrationException(
"User registration failed due to Keycloak error."
+ " Status: " + resp.getStatus()));
}
}
});
}
/**
* Get user details with id.
*
* @throws NoSuchElementException in mono if keycloak returns 404.
*/
public Mono<UserRepresentation> getUserDetails(String userId) {
return Mono.create((Consumer<MonoSink<UserRepresentation>>) sink -> sink.success(
this.keycloak.realm(this.realm).users().get(userId).toRepresentation()))
.onErrorMap(NotFoundException.class,
ex -> new NoSuchElementException("User with id " + userId + " not exists", ex));
}
}
@@ -7,11 +7,7 @@ import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
@@ -20,20 +16,19 @@ import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.PermissionCheckException;
import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
@@ -42,7 +37,6 @@ import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
@@ -55,7 +49,7 @@ import reactor.core.publisher.Mono;
public class KeycloakIdentityManagerService implements IdentityManagerService {
private final WebClient webClient;
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
private final KeycloakAdminReactiveClient keycloakAdminClient; // Inject KeycloakAdminClient
private final String keycloakServerUrl;
private final String realm;
@@ -83,20 +77,13 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
.encode().toUriString();
}
private String getUsersAdminEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
.path("/admin/realms/{realm}/users")
.buildAndExpand(this.realm)
.encode().toUriString();
}
/**
* Create a keycloak OIDC client.
*/
@Autowired
public KeycloakIdentityManagerService(
final WebClient keycloakWebClient,
final KeycloakAdminClient keycloakAdminClient,
@Qualifier("keycloakWebClient") final WebClient keycloakWebClient,
final KeycloakAdminReactiveClient keycloakAdminClient,
final KeycloakClientConfigProperties configProperties
) {
this.webClient = keycloakWebClient;
@@ -119,23 +106,23 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
formData.add("scope", "openid email profile"); // Request standard scopes
return this.webClient.post().uri(getTokenEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
// Handle specific HTTP status codes
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
log.error("Keycloak login failed with status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new AuthenticationException(
"Login failed: Invalid credentials or Keycloak error."
+ " Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(TokenResponse.class)
.doOnSuccess(response -> log.debug("Login successful for user: {}", username))
.doOnError(error -> log.error("Error during login for user {}: {}", username,
error.getMessage()));
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
// Handle specific HTTP status codes
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
log.error("Keycloak login failed with status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new AuthenticationException(
"Login failed: Invalid credentials or Keycloak error."
+ " Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(TokenResponse.class)
.doOnSuccess(response -> log.debug("Login successful for user: {}", username))
.doOnError(error -> log.error("Error during login for user {}: {}", username,
error.getMessage()));
}
@Override
@@ -147,29 +134,29 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
formData.add("token", accessToken);
return this.webClient.post().uri(getIntrospectionEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
log.error(
"Keycloak token introspection failed with"
+ " status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new TokenVerificationException(
"Token introspection failed. Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(VerificationResponse.class).flatMap(response -> {
if (!response.isActive()) {
log.warn("Token verification failed: Token is inactive.");
return Mono.error(
new TokenVerificationException("Token is invalid or expired."));
}
log.debug("Token verification successful. User: {}", response.getUsername());
return Mono.just(response);
}).doOnError(error -> log.error("Error during token verification: {}",
error.getMessage()));
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
log.error(
"Keycloak token introspection failed with"
+ " status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new TokenVerificationException(
"Token introspection failed. Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(VerificationResponse.class).flatMap(response -> {
if (!response.isActive()) {
log.warn("Token verification failed: Token is inactive.");
return Mono.error(
new TokenVerificationException("Token is invalid or expired."));
}
log.debug("Token verification successful. User: {}", response.getUsername());
return Mono.just(response);
}).doOnError(error -> log.error("Error during token verification: {}",
error.getMessage()));
}
@Override
@@ -182,104 +169,33 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak
return this.webClient.post().uri(getRevocationEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
// Keycloak might return 400 if token is already invalid, which
// is okay for logout
if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) {
log.warn(
"Token revocation returned 400 (possibly already"
+ " invalid/revoked): {}",
errorBody);
return Mono.empty(); // Treat as success in logout scenario
}
log.error("Keycloak token revocation failed with status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new LogoutException("Logout failed. Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(Void.class) // Expecting empty body on success (2xx)
.doOnSuccess(v -> log.debug("Logout successful (token revoked)."))
.doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData)).retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
// Keycloak might return 400 if token is already invalid, which
// is okay for logout
if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) {
log.warn(
"Token revocation returned 400 (possibly already"
+ " invalid/revoked): {}",
errorBody);
return Mono.empty(); // Treat as success in logout scenario
}
log.error("Keycloak token revocation failed with status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new LogoutException("Logout failed. Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(Void.class) // Expecting empty body on success (2xx)
.doOnSuccess(v -> log.debug("Logout successful (token revoked)."))
.doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
}
@Override
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
log.info("Registering new user with email: {}", registrationRequest.getEmail());
Map<String, Object> userRepresentation = new HashMap<>();
userRepresentation.put("username", registrationRequest.getEmail());
userRepresentation.put("email", registrationRequest.getEmail());
userRepresentation.put("firstName", registrationRequest.getFirstName());
userRepresentation.put("lastName", registrationRequest.getLastName());
userRepresentation.put("enabled", true);
userRepresentation.put("emailVerified", false); // Initially false
Map<String, Object> credential = new HashMap<>();
credential.put("type", "password");
credential.put("value", registrationRequest.getPassword());
credential.put("temporary", false);
userRepresentation.put("credentials", Collections.singletonList(credential));
// Tell Keycloak to initiate email verification,
// we may implement our email verification on future.
userRepresentation.put("requiredActions", Collections.singletonList("VERIFY_EMAIL"));
return this.keycloakAdminClient.getAdminAccessToken()
.flatMap(adminToken -> this.webClient.post().uri(getUsersAdminEndpoint())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON).bodyValue(userRepresentation)
.exchangeToMono(response -> {
if (response.statusCode().is2xxSuccessful()) {
log.info(
"User {} created successfully in Keycloak."
+ " Keycloak will handle email verification.",
registrationRequest.getEmail());
return Mono.empty(); // Successfully initiated
} else if (response.statusCode() == HttpStatus.CONFLICT) {
log.warn(
"User registration failed for {}:"
+ " User already exists (Conflict).",
registrationRequest.getEmail());
return response.bodyToMono(String.class)
.flatMap(errorBody -> Mono.error(
new UserAlreadyExistsException("User with email "
+ registrationRequest.getEmail()
+ " already exists.")));
} else {
return response.bodyToMono(String.class).flatMap(errorBody -> {
log.error("Keycloak user creation failed for {}"
+ " with status {}: {}",
registrationRequest.getEmail(), response.statusCode(),
errorBody);
return Mono.error(new RegistrationException(
"User registration failed due to Keycloak error."
+ " Status: " + response.statusCode()));
});
}
}))
.doOnError(WebClientResponseException.class, e -> {
if (e.getStatusCode() == HttpStatus.CONFLICT) {
// Already handled by exchangeToMono, but good to log if it somehow gets
// here
log.warn(
"User registration conflict "
+ "(WebClientResponseException) for {}: {}",
registrationRequest.getEmail(), e.getMessage());
} else {
log.error("WebClientResponseException during user registration for {}: {}",
registrationRequest.getEmail(), e.getMessage());
}
})
.doOnError(
e -> !(e instanceof UserAlreadyExistsException
|| e instanceof RegistrationException),
e -> log.error("Generic error during user registration for {}: {}",
registrationRequest.getEmail(), e.getMessage()))
.then();
return this.keycloakAdminClient.registerUser(registrationRequest);
}
@Override
@@ -331,7 +247,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
@Override
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
KeycloakOrganizationRepresentation newOrg = new KeycloakOrganizationRepresentation();
OrganizationRepresentation newOrg = new OrganizationRepresentation();
newOrg.setName(request.getName());
newOrg.setDescription(request.getDescription());
newOrg.setAttributes(request.getAttributes());
@@ -339,25 +255,29 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
if (request.getDomains() != null) {
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
.stream()
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
.collect(Collectors.toSet());
newOrg.setDomains(domainRepresentations);
.stream()
.map(domainName -> {
final var domain = new OrganizationDomainRepresentation(domainName);
domain.setVerified(false);
return domain;
})
.collect(Collectors.toSet());
domainRepresentations.forEach(newOrg::addDomain);
}
return this.keycloakAdminClient.createOrganization(newOrg)
.map(this::toOrganizationResponse);
.map(this::toOrganizationResponse);
}
@Override
public Mono<List<OrganizationResponse>> getOrganizations() {
return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream()
.map(this::toOrganizationResponse).collect(Collectors.toList()));
.map(this::toOrganizationResponse).collect(Collectors.toList()));
}
@Override
public Mono<OrganizationResponse> getOrganizationById(String orgId) {
return this.keycloakAdminClient.getOrganizationById(orgId)
.map(this::toOrganizationResponse);
.map(this::toOrganizationResponse);
}
@Override
@@ -370,10 +290,17 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
// Update domains based on the new list of names
if (request.getDomains() != null) {
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
.stream()
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
.collect(Collectors.toSet());
existingOrg.setDomains(domainRepresentations);
.stream()
.map(domainName -> {
final var domain = new OrganizationDomainRepresentation(domainName);
domain.setVerified(false);
return domain;
})
.collect(Collectors.toSet());
if (existingOrg.getDomains() != null) {
existingOrg.getDomains().clear();
}
domainRepresentations.forEach(existingOrg::addDomain);
}
return this.keycloakAdminClient.updateOrganization(orgId, existingOrg);
});
@@ -397,7 +324,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
@Override
public Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId) {
return this.keycloakAdminClient.getOrganizationMembers(orgId).map(userList -> userList
.stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList()));
.stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList()));
}
@Override
@@ -411,15 +338,16 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
}
@Override
public Mono<GroupResponse> createSubGroup(final String parentGroupId,
final CreateGroupRequest request) {
KeycloakGroupRepresentation newGroup = new KeycloakGroupRepresentation();
public Mono<GroupResponse> createSubGroup(
final String parentGroupId, final CreateGroupRequest request
) {
GroupRepresentation newGroup = new GroupRepresentation();
newGroup.setName(request.getName());
newGroup.setAttributes(request.getAttributes());
return this.keycloakAdminClient.createSubGroup(parentGroupId, newGroup)
.thenReturn(new GroupResponse(null, request.getName(),
null, null, request.getAttributes()));
.thenReturn(new GroupResponse(null, request.getName(),
null, null, request.getAttributes()));
}
@Override
@@ -430,14 +358,14 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
@Override
public Mono<List<GroupResponse>> listSubGroups(final String parentGroupId) {
return this.keycloakAdminClient.listSubGroups(parentGroupId)
.map(groupList -> groupList.stream()
.map(this::toGroupResponse)
.collect(Collectors.toList()));
.map(groupList -> groupList.stream()
.map(this::toGroupResponse)
.collect(Collectors.toList()));
}
@Override
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
KeycloakGroupRepresentation groupUpdate = new KeycloakGroupRepresentation();
final var groupUpdate = new GroupRepresentation();
groupUpdate.setAttributes(request.getAttributes());
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
}
@@ -455,9 +383,9 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
@Override
public Mono<List<OrganizationMemberResponse>> getGroupMembers(final String groupId) {
return this.keycloakAdminClient.getGroupMembers(groupId)
.map(userList -> userList.stream()
.map(this::toOrganizationMemberResponse)
.collect(Collectors.toList()));
.map(userList -> userList.stream()
.map(this::toOrganizationMemberResponse)
.collect(Collectors.toList()));
}
@Override
@@ -466,39 +394,39 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
}
// --- Add DTO Mapper for Group ---
private GroupResponse toGroupResponse(final KeycloakGroupRepresentation keycloakGroup) {
private GroupResponse toGroupResponse(final GroupRepresentation keycloakGroup) {
if (keycloakGroup == null) {
return null;
}
return new GroupResponse(
keycloakGroup.getId(),
keycloakGroup.getName(),
keycloakGroup.getPath(),
keycloakGroup.getDescription(),
keycloakGroup.getAttributes()
keycloakGroup.getId(),
keycloakGroup.getName(),
keycloakGroup.getPath(),
keycloakGroup.getDescription(),
keycloakGroup.getAttributes()
);
}
// --- DTO Mapper Helper Methods ---
private OrganizationResponse toOrganizationResponse(
KeycloakOrganizationRepresentation keycloakOrg) {
OrganizationRepresentation keycloakOrg) {
if (keycloakOrg == null) {
return null;
}
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.getEnabled(),
keycloakOrg.getAttributes());
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.isEnabled(),
keycloakOrg.getAttributes());
}
private OrganizationMemberResponse toOrganizationMemberResponse(
KeycloakUserRepresentation keycloakUser) {
UserRepresentation keycloakUser) {
if (keycloakUser == null) {
return null;
}
return new OrganizationMemberResponse(keycloakUser.getId(), keycloakUser.getUsername(),
keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(),
keycloakUser.isEnabled());
keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(),
keycloakUser.isEnabled());
}
}
+40 -3
View File
@@ -7,7 +7,7 @@ spring:
name: auth-service
cloud:
consul:
host: ${CONSUL_HOST:localhost}
host: ${CONSUL_HOST:127.0.0.1}
port: ${CONSUL_PORT:8500}
config:
import-check:
@@ -15,8 +15,8 @@ spring:
# Keycloak Configuration (adjust values as needed)
keycloak:
keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://keycloak.dev.localhost} # Base URL of your Keycloak instance (NO trailing slash)
keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://keycloak.dev.localhost} # Base URL of your Keycloak instance (NO trailing slash)
realm: ${KEYCLOAK_AUTH_REALM:clevermicro-dev} # The realm name you are using
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!)
@@ -34,9 +34,46 @@ auth-service:
# Default Keycloak Client ID if no prefix matches (optional)
# defaultKeycloakClientId: "general-api-client"
# CleverMicro client
clevermicro:
client:
# rabbitmq
rabbitmq-host: ${RABBITMQ_HOST:localhost}
rabbitmq-port: ${RABBITMQ_PORT:5672}
rabbitmq-username: ${RABBITMQ_USER:springuser}
rabbitmq-password: ${RABBITMQ_PASSWORD:TheCleverWho}
rabbitmq-vhost: ${RABBITMQ_VHOST:/}
# swarm env
swarm-service-id: ${SWARM_SERVICE_ID:auth-service-id}
swarm-task-id=: ${SWARM_TASK_ID:dummy-task-id}
# backpressure
initial-availability: ${MAX_AVAILABILITY:-1}
logging:
level:
# Set log levels as needed.
com.clevermicro.authservice: DEBUG
org.springframework.web.client.RestTemplate: DEBUG
reactor.netty.http.client: DEBUG
# Enable prometheus endpoint
management:
endpoint:
prometheus:
access: read_only
web:
exposure:
include: health,prometheus
# OpenTelemetry
otel:
logs:
exporter: otlp
exporter:
otlp:
logs:
endpoint: ${OTLP_LOG_ENDPOINT:http://victorialogs.dev.localhost/insert/opentelemetry/v1/logs}
# for now disable the tracing and metrics exporter
traces:
exporter: none
metrics:
exporter: none
@@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.OrganizationController;
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
@@ -21,6 +20,7 @@ import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
@@ -52,10 +52,10 @@ public class OrganizationControllerTest {
@BeforeEach
void setUp() {
// Create the correct domain representation object
OrganizationDomainRepresentation domain =
new OrganizationDomainRepresentation("test-org.com", true);
final var domain = new OrganizationDomainRepresentation("test-org.com");
domain.setVerified(true);
this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain),
"A test organization", true, Map.of("displayName", List.of("Test Org")));
"A test organization", true, Map.of("displayName", List.of("Test Org")));
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
}
@@ -65,14 +65,14 @@ public class OrganizationControllerTest {
@Test
void createOrganization_Success_ReturnsCreatedWithBody() {
CreateOrganizationRequest request = new CreateOrganizationRequest("test-org",
List.of("test-org.com"), "A test org", null);
List.of("test-org.com"), "A test org", null);
when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class)))
.thenReturn(Mono.just(this.mockOrgResponse));
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isCreated()
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
.bodyValue(request).exchange().expectStatus().isCreated()
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
}
/**
@@ -83,7 +83,7 @@ public class OrganizationControllerTest {
CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null);
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
/**
@@ -92,10 +92,10 @@ public class OrganizationControllerTest {
@Test
void getOrganizations_Success_ReturnsListOfOrgs() {
when(this.identityManagerService.getOrganizations())
.thenReturn(Mono.just(this.mockOrgResponseList));
.thenReturn(Mono.just(this.mockOrgResponseList));
this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk()
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
}
/**
@@ -104,11 +104,11 @@ public class OrganizationControllerTest {
@Test
void getOrganizationById_Exists_ReturnsOrg() {
when(this.identityManagerService.getOrganizationById("org-id-123"))
.thenReturn(Mono.just(this.mockOrgResponse));
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isOk().expectBody(OrganizationResponse.class)
.isEqualTo(this.mockOrgResponse);
.expectStatus().isOk().expectBody(OrganizationResponse.class)
.isEqualTo(this.mockOrgResponse);
}
/**
@@ -117,10 +117,10 @@ public class OrganizationControllerTest {
@Test
void getOrganizationById_NotFound_ReturnsNotFound() {
when(this.identityManagerService.getOrganizationById("non-existent-id"))
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange()
.expectStatus().isNotFound();
.expectStatus().isNotFound();
}
/**
@@ -129,13 +129,13 @@ public class OrganizationControllerTest {
@Test
void updateOrganization_Success_ReturnsNoContent() {
UpdateOrganizationRequest request =
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
when(this.identityManagerService.updateOrganization(eq("org-id-123"),
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
@@ -146,7 +146,7 @@ public class OrganizationControllerTest {
when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty());
this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isNoContent();
.expectStatus().isNoContent();
}
/**
@@ -156,11 +156,11 @@ public class OrganizationControllerTest {
void inviteUserToOrganization_Success_ReturnsOk() {
InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User");
when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"),
any(InviteUserRequest.class))).thenReturn(Mono.empty());
any(InviteUserRequest.class))).thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isOk();
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isOk();
}
/**
@@ -169,14 +169,14 @@ public class OrganizationControllerTest {
@Test
void getOrganizationMembers_Success_ReturnsListOfMembers() {
List<OrganizationMemberResponse> members =
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
"test@test.com", true));
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
"test@test.com", true));
when(this.identityManagerService.getOrganizationMembers("org-id-123"))
.thenReturn(Mono.just(members));
.thenReturn(Mono.just(members));
this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
.isEqualTo(members);
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
.isEqualTo(members);
}
/**
@@ -186,11 +186,11 @@ public class OrganizationControllerTest {
void addMemberToOrganization_Success_ReturnsNoContent() {
AddMemberRequest request = new AddMemberRequest("user-to-add-id");
when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id"))
.thenReturn(Mono.empty());
.thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
@@ -199,10 +199,10 @@ public class OrganizationControllerTest {
@Test
void removeMemberFromOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.removeMemberFromOrganization("org-id-123",
"user-to-remove-id")).thenReturn(Mono.empty());
"user-to-remove-id")).thenReturn(Mono.empty());
this.webTestClient.delete()
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
.exchange().expectStatus().isNoContent();
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
.exchange().expectStatus().isNoContent();
}
}
@@ -3,10 +3,17 @@ package com.cleverthis.authservice.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@SpringBootTest(properties = {
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {KeycloakClientConfigPropertiesTest.class})
@EnableConfigurationProperties({KeycloakClientConfigProperties.class})
@TestPropertySource(properties = {
"keycloak.keycloak-host=https://keycloak.example.com",
"keycloak.keycloak-admin-host=https://keycloak-admin.example.com",
"keycloak.realm=cm-test",
@@ -0,0 +1,238 @@
package com.cleverthis.authservice.controller.rabbitmq;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.notNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointErrorResponse;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointOkResponse;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerSubmodule;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.BuiltinExchangeType;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class AbstractEndpointTest {
private final CleverMicroAmqpClient client = Mockito.mock(CleverMicroAmqpClient.class);
final ObjectMapper objectMapper = Mockito.spy(new ObjectMapper());
private class TestEndpoint extends AbstractEndpoint {
TestEndpoint() throws Exception {
super(
AbstractEndpointTest.this.client,
AbstractEndpointTest.this.objectMapper,
"test-key"
);
}
// The throws is needed for mocked object to throw exception during test
@SuppressWarnings("RedundantThrows")
@Override
protected void handleRequest(
final HandlerContext ctx, final EndpointRequest request
) throws EndpointException {
}
}
private final HandlerSubmodule handlerSubmodule = Mockito.mock(HandlerSubmodule.class);
@BeforeEach
void setup() {
when(this.client.getHandlerManager()).thenReturn(this.handlerSubmodule);
}
@Test
void testInitAndClose() throws Exception {
when(this.client.getHandlerManager().registerHandler(
anyString(), eq(""), eq(1),
notNull(), notNull()
)).thenReturn("test-tag");
final var testEndpoint = new TestEndpoint();
verify(this.client.getHandlerManager()).declareExchange(
"cleverthis.clevermicro.auth.endpoints", BuiltinExchangeType.DIRECT
);
verify(this.client.getHandlerManager()).declareQueue(
"cleverthis.clevermicro.auth.endpoints.test-key",
true, false, false, Map.of()
);
verify(this.client.getHandlerManager()).bindQueue(
"cleverthis.clevermicro.auth.endpoints.test-key",
"cleverthis.clevermicro.auth.endpoints",
"test-key", Map.of()
);
testEndpoint.close();
verify(this.client.getHandlerManager()).cancelHandler("test-tag");
}
@Test
void testHandler() throws Throwable {
final var testEndpoint = Mockito.spy(new TestEndpoint());
final var handlerContext = Mockito.mock(HandlerContext.class);
// silent reply method, it will be tested separately
doNothing().when(testEndpoint).reply(eq(handlerContext), notNull());
// parse req null
doReturn(null).when(testEndpoint).parseRequest(notNull());
testEndpoint.handleRabbitMessage(handlerContext);
verify(testEndpoint).reply(eq(handlerContext), argThat(it -> {
final var resp = (EndpointErrorResponse) it;
return resp.getException().equals("cleverthis.clevermicro.bad_request")
&& resp.getMessage().equals("Malformed request, cannot be parsed");
}));
// path: parse req ok
Mockito.clearInvocations(testEndpoint);
// handle req ok
doReturn(EndpointRequest.builder().build()).when(testEndpoint).parseRequest(notNull());
doNothing().when(testEndpoint).handleRequest(notNull(), notNull());
assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext));
verify(testEndpoint, never()).reply(notNull(), notNull());
// handle req endpoint exception
Mockito.clearInvocations(testEndpoint);
doThrow(new EndpointException("test", "message"))
.when(testEndpoint).handleRequest(notNull(), notNull());
assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext));
verify(testEndpoint).reply(notNull(), argThat(it -> {
final var resp = (EndpointErrorResponse) it;
return resp.getException().equals("test")
&& resp.getMessage().equals("message");
}));
// handle req any exception
Mockito.clearInvocations(testEndpoint);
doThrow(new RuntimeException("test"))
.when(testEndpoint).handleRequest(notNull(), notNull());
assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext));
verify(testEndpoint).reply(notNull(), argThat(it -> {
final var resp = (EndpointErrorResponse) it;
return resp.getException().equals("cleverthis.clevermicro.server_internal_error");
}));
}
@Test
void testParseRequest_SingleStream() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var inputStream = Mockito.mock(InputStream.class);
when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream));
final var req = EndpointRequest.builder().method("testMethod").build();
doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class);
final var result = testEndpoint.parseRequest(handlerContext);
assertEquals(req, result);
}
@Test
void testParseRequest_MultiStream() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var inputStream = Mockito.mock(InputStream.class);
when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.of(
Map.of("request", inputStream)
));
final var req = EndpointRequest.builder().method("testMethod").build();
doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class);
final var result = testEndpoint.parseRequest(handlerContext);
assertEquals(req, result);
}
@Test
void testReply_SuccessfulSerialization() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var response = Map.of("key", "value");
assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response));
verify(handlerContext).finish("""
{
"key" : "value"
}
""".trim().getBytes(StandardCharsets.UTF_8));
}
@Test
void testReply_SerializationFailure() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var response = mock(EndpointOkResponse.class);
// throw error when accessing fields, causing jackson failed to serialize
when(response.getResult()).thenThrow(new RuntimeException("test"));
assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response));
verify(handlerContext, never()).finish(any());
verify(handlerContext).refillAvailability();
}
@Test
void testReply_ReplyFailure() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var response = Map.of("key", "value");
doThrow(new IOException("Reply error")).when(handlerContext).finish(notNull());
assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response));
}
@Test
void testParseRequest_InvalidRequest() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var inputStream = Mockito.mock(InputStream.class);
when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream));
doThrow(new IOException("test")).when(this.objectMapper)
.readValue(inputStream, EndpointRequest.class);
final var result = testEndpoint.parseRequest(handlerContext);
assertNull(result);
}
@Test
void testParseRequest_NoStream() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.empty());
when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.empty());
final var result = testEndpoint.parseRequest(handlerContext);
assertNull(result);
}
}
@@ -0,0 +1,119 @@
package com.cleverthis.authservice.controller.rabbitmq.v1.user;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses;
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.idm.UserRepresentation;
import reactor.core.publisher.Mono;
class UserEndpointV1Test {
private final CleverMicroAmqpClient client = mock();
private final ObjectMapper objectMapper = spy(new ObjectMapper());
private final KeycloakAdminReactiveClient keycloakAdminClient = mock();
private UserEndpointV1 userEndpointV1;
@BeforeEach
void setup() throws IOException {
when(this.client.getHandlerManager()).thenReturn(mock());
this.userEndpointV1 = spy(new UserEndpointV1(
this.client, this.objectMapper, this.keycloakAdminClient));
}
@Test
void testHandler_MethodNotFound() {
final var req = mock(EndpointRequest.class);
when(req.getMethod()).thenReturn("method that doesn't exist");
final var ex = assertThrows(EndpointBadRequestException.class,
() -> this.userEndpointV1.handleRequest(mock(), req));
assertEquals("Method not found", ex.getMessage());
}
private EndpointRequest createRequest(String method, Map<String, ?> args) {
final String json;
try {
json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(args);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
final LinkedHashMap<String, JsonNode> convertedArgs;
try {
convertedArgs = this.objectMapper.readValue(json, new TypeReference<>() {
});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return EndpointRequest.builder().method(method).args(convertedArgs).build();
}
@Test
void testHandler_QueryUserById() throws Exception {
final var handlerContext = mock(HandlerContext.class);
// normal path
var req = createRequest("queryUserById", Map.of("id", "test-id"));
final var returnedUser = new UserRepresentation();
returnedUser.setId("test-id");
returnedUser.setUsername("test-username");
when(this.keycloakAdminClient.getUserDetails("test-id"))
.thenReturn(Mono.just(returnedUser));
this.userEndpointV1.handleRequest(handlerContext, req);
verify(handlerContext).finish(
this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(
EndpointResponses.ok(returnedUser)
)
);
// missing args
req = createRequest("queryUserById", Map.of("not-id", "test-id"));
{ // scope for final copy
final var finalReq = req;
assertThrows(EndpointBadRequestException.class,
() -> this.userEndpointV1.handleRequest(handlerContext, finalReq));
}
// invalid args
req = createRequest("queryUserById", Map.of("id", 123));
{ // scope for final copy
final var finalReq = req;
assertThrows(EndpointBadRequestException.class,
() -> this.userEndpointV1.handleRequest(handlerContext, finalReq));
}
// user not found
when(this.keycloakAdminClient.getUserDetails("test-id"))
.thenReturn(Mono.error(new NoSuchElementException("test exception")));
req = createRequest("queryUserById", Map.of("id", "test-id"));
{ // scope for final copy
final var finalReq = req;
final var ex = assertThrows(EndpointException.class,
() -> this.userEndpointV1.handleRequest(handlerContext, finalReq));
assertEquals("cleverthis.clevermicro.auth.user_not_found", ex.getEndpointException());
}
}
}
@@ -55,7 +55,8 @@ class FallbackPermissionMapLookupServiceTest {
assertNull(service.getConfig()); // fallback is null
// has json content
when(value.getDecodedValue())
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
.thenReturn(
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul();
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
}
@@ -76,45 +77,22 @@ class FallbackPermissionMapLookupServiceTest {
}
@Test
void testMatchClientIdByPath() {
void testMatchRuleByPath() {
final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of(
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
final var ruleA = PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build();
final var ruleB = PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build();
defaultConfig.setRules(List.of(ruleA, ruleB));
final var service = new FallbackPermissionMapLookupService(
defaultConfig, this.consulClient, this.objectMapper);
assertEquals(
"service-B",
service.matchClientIdByPath("/b/items/123").orElseThrow());
ruleB,
service.matchRuleByPath("/b/items/123").orElseThrow());
assertEquals(
Optional.empty(),
service.matchClientIdByPath("/c/items/123"));
service.matchRuleByPath("/c/items/123"));
}
@Test
void testMatchRuleByClientIdAndPath() {
final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setRules(List.of(
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
final var service = new FallbackPermissionMapLookupService(
defaultConfig, this.consulClient, this.objectMapper);
service.matchRuleByClientIdAndPath("service-A", "/a/items/123")
.ifPresentOrElse(
rule -> assertEquals("service-A", rule.getKeycloakClientId()),
() -> Assertions.fail("No rule matched"));
service.matchRuleByClientIdAndPath("service-B", "/a/items/123")
.ifPresentOrElse(
it -> Assertions.fail("Rule matched but should not have"),
() -> {}
);
}
}
@@ -1,191 +0,0 @@
package com.cleverthis.authservice.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import java.io.IOException;
import java.util.List;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class KeycloakAdminClientTest {
private MockWebServer mockBackEnd;
private KeycloakAdminClient adminClient;
@BeforeEach
void setup() throws IOException {
// isolate requests by creating one mock server per test
this.mockBackEnd = new MockWebServer();
this.mockBackEnd.start();
final var webClient = WebClient.builder().build();
final var configProperties = KeycloakClientConfigProperties.builder()
.keycloakAdminHost("http://localhost:" + this.mockBackEnd.getPort() + "/")
.realm("test-realm")
.adminAccountUsername("admin")
.adminAccountPassword("admin-password")
.build();
this.adminClient = Mockito.spy(new KeycloakAdminClient(webClient, configProperties));
}
@AfterEach
void tearDown() throws IOException {
this.mockBackEnd.shutdown();
}
@Test
void getAdminAccessToken_shouldReturnToken_whenRequestIsSuccessful()
throws InterruptedException {
// Arrange
final var tokenResponse = """
{
"access_token": "mock-access-token",
"expires_in": 3600
}
""";
this.mockBackEnd.enqueue(new MockResponse()
.setBody(tokenResponse)
.addHeader("Content-Type", "application/json")
);
// Act
final var result = this.adminClient.getAdminAccessToken();
// Assert
StepVerifier.create(result)
.expectNext("mock-access-token")
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
}
@Test
void getClientInternalId_shouldReturnInternalId_whenClientExists() throws InterruptedException {
// Arrange
final var publicClientId = "test-client";
final var internalClientId = "mock-internal-id";
Mockito.when(this.adminClient.getAdminAccessToken())
.thenReturn(Mono.just("mock-access-token"));
final var responseBody = """
[
{
"id": "mock-internal-id",
"clientId": "test-client"
}
]
""";
this.mockBackEnd.enqueue(new MockResponse()
.setBody(responseBody)
.addHeader("Content-Type", "application/json")
);
// Act
final var result = this.adminClient.getClientInternalId(publicClientId);
// Assert
StepVerifier.create(result)
.expectNext(internalClientId)
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/admin/realms/test-realm/clients?clientId=test-client&max=1",
recordedRequest.getPath());
}
@Test
void getClientInternalId_shouldThrowError_whenClientDoesNotExist() {
// Arrange
final var publicClientId = "nonexistent-client";
final var responseBody = "[]";
Mockito.when(this.adminClient.getAdminAccessToken())
.thenReturn(Mono.just("mock-access-token"));
this.mockBackEnd.enqueue(new MockResponse()
.setBody(responseBody)
.addHeader("Content-Type", "application/json")
);
// Act
final var result = this.adminClient.getClientInternalId(publicClientId);
// Assert
StepVerifier.create(result)
.expectErrorMatches(throwable -> throwable instanceof ServiceUnavailableException &&
throwable.getMessage().contains("Client not found in Keycloak"))
.verify();
}
@Test
void getUserEffectiveClientRoles_shouldReturnRoles_whenRequestIsSuccessful()
throws InterruptedException {
// Arrange
final var userId = "test-user";
final var clientInternalId = "mock-client-id";
Mockito.when(this.adminClient.getAdminAccessToken())
.thenReturn(Mono.just("mock-access-token"));
final var rolesResponse = """
[
{"name": "role1"},
{"name": "role2"}
]
""";
this.mockBackEnd.enqueue(new MockResponse()
.setBody(rolesResponse)
.addHeader("Content-Type", "application/json")
);
// Act
final var result = this.adminClient.getUserEffectiveClientRoles(userId, clientInternalId);
// Assert
StepVerifier.create(result)
.expectNextMatches(roles -> roles.stream().map(KeycloakRoleRepresentation::getName)
.toList().containsAll(List.of("role1", "role2")))
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals(
"/admin/realms/test-realm/users/test-realm/role-mappings/clients/test-user/composite",
recordedRequest.getPath());
}
@Test
void getUserEffectiveClientRoles_shouldReturnEmptyList_whenRolesNotFound() {
// Arrange
final var userId = "test-user";
final var clientInternalId = "mock-client-id";
Mockito.when(this.adminClient.getAdminAccessToken())
.thenReturn(Mono.just("mock-access-token"));
this.mockBackEnd.enqueue(new MockResponse()
.setBody("[]")
.addHeader("Content-Type", "application/json")
);
// Act
final var result = this.adminClient.getUserEffectiveClientRoles(userId, clientInternalId);
// Assert
StepVerifier.create(result)
.expectNextMatches(List::isEmpty)
.verifyComplete();
}
}
@@ -0,0 +1,974 @@
package com.cleverthis.authservice.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.mockito.Mockito;
class KeycloakAdminReactiveClientTest {
private KeycloakAdminReactiveClient adminClient;
private final Keycloak keycloak = mock(Keycloak.class);
@BeforeEach
void setup() {
when(this.keycloak.realm("test-realm")).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations()).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups()).thenReturn(mock());
when(this.keycloak.realm("test-realm").users()).thenReturn(mock());
final var configProperties = KeycloakClientConfigProperties.builder()
.realm("test-realm")
.build();
this.adminClient =
Mockito.spy(new KeycloakAdminReactiveClient(this.keycloak, configProperties));
}
@Test
void testCreateOrganization_Success() {
// Arrange
final var orgToCreate = new OrganizationRepresentation();
orgToCreate.setId("test-id");
orgToCreate.setName("Test Organization");
final var response = mock(Response.class);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily())
.thenReturn(Response.Status.Family.SUCCESSFUL);
when(response.readEntity(OrganizationRepresentation.class)).thenReturn(orgToCreate);
when(this.keycloak.realm("test-realm")
.organizations().create(orgToCreate))
.thenReturn(response);
// Act
final var result = this.adminClient.createOrganization(orgToCreate).block();
// Assert
assertNotNull(result);
assertEquals("Test Organization", result.getName());
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations()).create(orgToCreate);
}
@Test
void testCreateOrganization_Failure() {
// Arrange
final var orgToCreate = new OrganizationRepresentation();
orgToCreate.setId("test-id");
orgToCreate.setName("Test Organization");
final var response = mock(Response.class);
when(response.getStatusInfo()).thenReturn(mock());
// keycloak return non-200
when(response.getStatusInfo().getFamily())
.thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Error occurred");
when(
this.keycloak.realm("test-realm").organizations()
.create(orgToCreate))
.thenReturn(response);
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.createOrganization(orgToCreate).block());
// client read exception
when(response.getStatusInfo().getFamily())
.thenReturn(Response.Status.Family.SUCCESSFUL);
when(response.readEntity(OrganizationRepresentation.class))
.thenThrow(new ProcessingException("test"));
when(
this.keycloak.realm("test-realm").organizations()
.create(orgToCreate))
.thenReturn(response);
// Act & Assert
Assertions.assertThrows(ProcessingException.class,
() -> this.adminClient.createOrganization(orgToCreate).block());
}
@Test
void testGetOrganizations_Success() {
// Arrange
final var organization1 = new OrganizationRepresentation();
organization1.setId("org-1");
organization1.setName("Organization 1");
final var organization2 = new OrganizationRepresentation();
organization2.setId("org-2");
organization2.setName("Organization 2");
when(this.keycloak.realm("test-realm")
.organizations().list(null, Integer.MAX_VALUE))
.thenReturn(List.of(organization1, organization2));
// Act
final var result = this.adminClient.getOrganizations().block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("Organization 1", result.get(0).getName());
assertEquals("Organization 2", result.get(1).getName());
verify(this.keycloak.realm("test-realm").organizations()).list(null, Integer.MAX_VALUE);
}
@Test
void testGetOrganizationById_Success() {
// Arrange
final var organization = new OrganizationRepresentation();
organization.setId("org-1");
organization.setName("Test Organization");
when(this.keycloak.realm("test-realm")
.organizations().get("org-1"))
.thenReturn(mock());
when(this.keycloak.realm("test-realm")
.organizations().get("org-1").toRepresentation())
.thenReturn(organization);
// Act
final var result = this.adminClient.getOrganizationById("org-1").block();
// Assert
assertNotNull(result);
assertEquals("org-1", result.getId());
assertEquals("Test Organization", result.getName());
verify(this.keycloak.realm("test-realm").organizations().get("org-1"))
.toRepresentation();
}
@Test
void testGetOrganizationById_NotFound() {
// Arrange
when(this.keycloak.realm("test-realm")
.organizations().get("non-existing-id"))
.thenReturn(mock());
when(this.keycloak.realm("test-realm")
.organizations().get("non-existing-id").toRepresentation())
.thenThrow(new NotFoundException("Organization not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getOrganizationById("non-existing-id").block());
verify(this.keycloak.realm("test-realm").organizations().get("non-existing-id"))
.toRepresentation();
}
@Test
void testUpdateOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var orgToUpdate = new OrganizationRepresentation();
orgToUpdate.setId("org-1");
orgToUpdate.setName("Updated Organization");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.updateOrganization(orgId, orgToUpdate).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId)).update(orgToUpdate);
}
@Test
void testUpdateOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var orgToUpdate = new OrganizationRepresentation();
orgToUpdate.setId("org-1");
orgToUpdate.setName("Updated Organization");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.updateOrganization(orgId, orgToUpdate).block());
}
@Test
void testUpdateOrganization_Failure_ProcessingException() {
// Arrange
final var orgId = "org-1";
final var orgToUpdate = new OrganizationRepresentation();
orgToUpdate.setId("org-1");
orgToUpdate.setName("Updated Organization");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class))
.thenThrow(new ProcessingException("Processing error"));
// Act & Assert
Assertions.assertThrows(ProcessingException.class,
() -> this.adminClient.updateOrganization(orgId, orgToUpdate).block());
}
@Test
void testDeleteOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).delete())
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.deleteOrganization(orgId).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId)).delete();
}
@Test
void testDeleteOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).delete())
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.deleteOrganization(orgId).block());
}
@Test
void testDeleteOrganization_Failure_ServerError() {
// Arrange
final var orgId = "org-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).delete())
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenReturn("Server error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.deleteOrganization(orgId).block());
}
@Test
void testInviteUserToOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName()))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.inviteUserToOrganization(orgId, invitation).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName());
}
@Test
void testInviteUserToOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName()))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.inviteUserToOrganization(orgId, invitation).block());
}
@Test
void testInviteUserToOrganization_Failure_Exception() {
// Arrange
final var orgId = "org-1";
final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName()))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.inviteUserToOrganization(orgId, invitation).block());
}
@Test
void testGetOrganizationMembers_Success() {
// Arrange
final var orgId = "org-1";
final var member1 = new MemberRepresentation();
member1.setId("user-1");
member1.setUsername("member1");
final var member2 = new MemberRepresentation();
member2.setId("user-2");
member2.setUsername("member2");
when(this.keycloak.realm("test-realm").organizations().get(orgId))
.thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId)
.members().list(null, Integer.MAX_VALUE))
.thenReturn(List.of(member1, member2));
// Act
final var result = this.adminClient.getOrganizationMembers(orgId).block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("user-1", result.get(0).getId());
assertEquals("user-2", result.get(1).getId());
verify(this.keycloak.realm("test-realm").organizations().get(orgId)
.members()).list(null, Integer.MAX_VALUE);
}
@Test
void testGetOrganizationMembers_Failure_NotFound() {
// Arrange
final var orgId = "non-existing-org";
when(this.keycloak.realm("test-realm").organizations().get(orgId))
.thenThrow(new NotFoundException("Organization not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getOrganizationMembers(orgId).block());
verify(this.keycloak.realm("test-realm").organizations()).get(orgId);
}
@Test
void testAddMemberToOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.addMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.addMemberToOrganization(orgId, userId).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.addMember(userId);
}
@Test
void testAddMemberToOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.addMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.addMemberToOrganization(orgId, userId).block());
}
@Test
void testAddMemberToOrganization_Failure_UnexpectedException() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.addMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.addMemberToOrganization(orgId, userId).block());
}
@Test
void testRemoveMemberFromOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.removeMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.removeMemberFromOrganization(orgId, userId).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.removeMember(userId);
}
@Test
void testRemoveMemberFromOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.removeMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.removeMemberFromOrganization(orgId, userId).block());
}
@Test
void testRemoveMemberFromOrganization_Failure_Exception() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.removeMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.removeMemberFromOrganization(orgId, userId).block());
}
@Test
void testCreateSubGroup_Success() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup = new GroupRepresentation();
subGroup.setName("Sub Group 1");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.createSubGroup(parentGroupId, subGroup).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").groups().group(parentGroupId)).subGroup(subGroup);
}
@Test
void testCreateSubGroup_Failure_ClientError() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup = new GroupRepresentation();
subGroup.setName("Sub Group 1");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.createSubGroup(parentGroupId, subGroup).block());
}
@Test
void testCreateSubGroup_Failure_Exception() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup = new GroupRepresentation();
subGroup.setName("Sub Group 1");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.createSubGroup(parentGroupId, subGroup).block());
}
@Test
void testGetGroupById_Success() {
// Arrange
final var groupId = "group-1";
final var group = new GroupRepresentation();
group.setId(groupId);
group.setName("Test Group");
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation())
.thenReturn(group);
// Act
final var result = this.adminClient.getGroupById(groupId).block();
// Assert
assertNotNull(result);
assertEquals("group-1", result.getId());
assertEquals("Test Group", result.getName());
verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation();
}
@Test
void testGetGroupById_NotFound() {
// Arrange
final var groupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation())
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getGroupById(groupId).block());
verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation();
}
@Test
void testListSubGroups_Success() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup1 = new GroupRepresentation();
subGroup1.setId("sub-group-1");
subGroup1.setName("Sub Group 1");
final var subGroup2 = new GroupRepresentation();
subGroup2.setId("sub-group-2");
subGroup2.setName("Sub Group 2");
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)
.getSubGroups(null, Integer.MAX_VALUE, false))
.thenReturn(List.of(subGroup1, subGroup2));
// Act
final var result = this.adminClient.listSubGroups(parentGroupId).block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("sub-group-1", result.get(0).getId());
assertEquals("sub-group-2", result.get(1).getId());
verify(this.keycloak.realm("test-realm").groups().group(parentGroupId))
.getSubGroups(null, Integer.MAX_VALUE, false);
}
@Test
void testListSubGroups_NotFound() {
// Arrange
final var parentGroupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(parentGroupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.listSubGroups(parentGroupId).block());
verify(this.keycloak.realm("test-realm").groups()).group(parentGroupId);
}
@Test
void testUpdateGroup_Success() {
// Arrange
final var groupId = "group-1";
final var groupToUpdate = new GroupRepresentation();
groupToUpdate.setId("group-1");
groupToUpdate.setName("Updated Group");
when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock());
// Act
this.adminClient.updateGroup(groupId, groupToUpdate).block();
// Assert
verify(this.keycloak.realm("test-realm").groups().group(groupId)).update(groupToUpdate);
}
@Test
void testUpdateGroup_NotFound() {
// Arrange
final var groupId = "non-existing-group";
final var groupToUpdate = new GroupRepresentation();
groupToUpdate.setId("non-existing-group");
groupToUpdate.setName("Non-existing Group");
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.updateGroup(groupId, groupToUpdate).block());
verify(this.keycloak.realm("test-realm").groups()).group(groupId);
}
@Test
void testDeleteGroup_Success() {
// Arrange
final var groupId = "group-1";
when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock());
// Act
this.adminClient.deleteGroup(groupId).block();
// Assert
verify(this.keycloak.realm("test-realm").groups().group(groupId)).remove();
}
@Test
void testDeleteGroup_NotFound() {
// Arrange
final var groupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.deleteGroup(groupId).block());
verify(this.keycloak.realm("test-realm").groups()).group(groupId);
}
@Test
void testAddMemberToGroup_Success() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
// Act
this.adminClient.addMemberToGroup(groupId, userId).block();
// Assert
verify(this.keycloak.realm("test-realm").users().get(userId)).joinGroup(groupId);
}
@Test
void testAddMemberToGroup_ClientError() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
final var userResource = mock(UserResource.class);
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource);
doThrow(new ClientErrorException(404)).when(userResource).joinGroup(groupId);
// Act & Assert
Assertions.assertThrows(IllegalArgumentException.class,
() -> this.adminClient.addMemberToGroup(groupId, userId).block());
}
@Test
void testGetGroupMembers_Success() {
// Arrange
final var groupId = "group-1";
final var member1 = new UserRepresentation();
member1.setId("user-1");
member1.setUsername("member1");
final var member2 = new UserRepresentation();
member2.setId("user-2");
member2.setUsername("member2");
when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(groupId).members())
.thenReturn(List.of(member1, member2));
// Act
final var result = this.adminClient.getGroupMembers(groupId).block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("user-1", result.get(0).getId());
assertEquals("user-2", result.get(1).getId());
verify(this.keycloak.realm("test-realm").groups().group(groupId)).members();
}
@Test
void testGetGroupMembers_GroupNotFound() {
// Arrange
final var groupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getGroupMembers(groupId).block());
verify(this.keycloak.realm("test-realm").groups()).group(groupId);
}
@Test
void testRemoveMemberFromGroup_Success() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
// Act
this.adminClient.removeMemberFromGroup(groupId, userId).block();
// Assert
verify(this.keycloak.realm("test-realm").users().get(userId)).leaveGroup(groupId);
}
@Test
void testRemoveMemberFromGroup_Failure_ClientError() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
final var userResource = mock(UserResource.class);
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource);
doThrow(new ClientErrorException(404)).when(userResource).leaveGroup(groupId);
// Act & Assert
Assertions.assertThrows(IllegalArgumentException.class,
() -> this.adminClient.removeMemberFromGroup(groupId, userId).block());
}
@Test
void testRegisterUser_Success() {
// Arrange
final var registrationRequest = new RegistrationRequest(
"John", "Doe", "john@example.com", "password123"
);
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class)))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.registerUser(registrationRequest).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").users())
.create(argThat(user -> user.getUsername().equals("john@example.com")));
}
@Test
void testRegisterUser_UserAlreadyExists() {
// Arrange
final var registrationRequest = new RegistrationRequest(
"John", "Doe", "john@example.com", "password123"
);
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class)))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.getStatus()).thenReturn(409);
// Act & Assert
Assertions.assertThrows(UserAlreadyExistsException.class,
() -> this.adminClient.registerUser(registrationRequest).block());
}
@Test
void testRegisterUser_ServerError() {
// Arrange
final var registrationRequest = new RegistrationRequest(
"John", "Doe", "john@example.com", "password123"
);
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class)))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenReturn("Internal Server Error");
// Act & Assert
Assertions.assertThrows(RegistrationException.class,
() -> this.adminClient.registerUser(registrationRequest).block());
}
@Test
void testGetUserDetails_Success() {
// Arrange
final var userId = "user-1";
final var userRepresentation = new UserRepresentation();
userRepresentation.setId(userId);
userRepresentation.setUsername("user1");
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation())
.thenReturn(userRepresentation);
// Act
final var result = this.adminClient.getUserDetails(userId).block();
// Assert
assertNotNull(result);
assertEquals("user-1", result.getId());
assertEquals("user1", result.getUsername());
verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation();
}
@Test
void testGetUserDetails_UserNotFound() {
// Arrange
final var userId = "non-existing-user";
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation())
.thenThrow(new NotFoundException("User not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getUserDetails(userId).block());
verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation();
}
}
@@ -1,28 +1,42 @@
package com.cleverthis.authservice.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
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 java.util.List;
import java.util.Map;
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.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.mockito.Mockito;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class KeycloakIdentityManagerServiceTest {
private MockWebServer mockBackEnd;
private KeycloakAdminClient adminClient;
private KeycloakAdminReactiveClient adminClient;
private KeycloakIdentityManagerService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -40,7 +54,7 @@ class KeycloakIdentityManagerServiceTest {
.clientId("test-client")
.clientSecret("client-password")
.build();
this.adminClient = Mockito.mock(KeycloakAdminClient.class);
this.adminClient = Mockito.mock(KeycloakAdminReactiveClient.class);
this.service = Mockito.spy(new KeycloakIdentityManagerService(
webClient, this.adminClient, configProperties));
@@ -246,4 +260,375 @@ class KeycloakIdentityManagerServiceTest {
.expectNext(false)
.verifyComplete();
}
@Test
void createOrganization_success() {
// Arrange
final var request = new CreateOrganizationRequest(
"Test Organization", List.of("test.org"), "", Map.of()
);
final var mockResponse = new OrganizationRepresentation();
mockResponse.setId("123");
mockResponse.setName("Test Organization");
mockResponse.addDomain(new OrganizationDomainRepresentation("test.org"));
mockResponse.setDescription("");
mockResponse.setEnabled(true);
Mockito.when(this.adminClient.createOrganization(any()))
.thenReturn(Mono.just(mockResponse));
// Act
final var result = this.service.createOrganization(request);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "123".equals(response.getId()) &&
"Test Organization".equals(response.getName()))
.verifyComplete();
}
@Test
void getOrganizations_success() {
// Arrange
final var mockOrganizations = List.of(
new OrganizationRepresentation() {{
setId("org1");
setName("Organization 1");
addDomain(new OrganizationDomainRepresentation("domain1.org"));
}},
new OrganizationRepresentation() {{
setId("org2");
setName("Organization 2");
addDomain(new OrganizationDomainRepresentation("domain2.org"));
}}
);
Mockito.when(this.adminClient.getOrganizations())
.thenReturn(Mono.just(mockOrganizations));
// Act
final var result = this.service.getOrganizations();
// Assert
StepVerifier.create(result)
.expectNextMatches(orgList -> orgList.size() == 2
&& "Organization 1".equals(orgList.getFirst().getName())
&& "domain1.org".equals(
orgList.getFirst().getDomains().iterator().next().getName())
&& "Organization 2".equals(orgList.get(1).getName()))
.verifyComplete();
}
@Test
void getOrganizationById_success() {
// Arrange
final var orgId = "abc123";
final var orgRepresentation = new OrganizationRepresentation();
orgRepresentation.setId(orgId);
orgRepresentation.setName("Test Organization");
orgRepresentation.addDomain(new OrganizationDomainRepresentation("test.org"));
orgRepresentation.setDescription("Test Description");
orgRepresentation.setEnabled(true);
Mockito.when(this.adminClient.getOrganizationById(orgId))
.thenReturn(Mono.just(orgRepresentation));
// Act
final var result = this.service.getOrganizationById(orgId);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "abc123".equals(response.getId())
&& "Test Organization".equals(response.getName())
&& "Test Description".equals(response.getDescription())
&& Boolean.TRUE.equals(response.getEnabled())
&& response.getDomains().iterator().next().getName().equals("test.org"))
.verifyComplete();
}
@Test
void updateOrganization_success() {
// Arrange
final var orgId = "org123";
final var updateRequest = new UpdateOrganizationRequest(
List.of("updated.org"), "Updated Description", Map.of("key", List.of("value"))
);
final var existingOrg = new OrganizationRepresentation();
existingOrg.setId(orgId);
existingOrg.setDescription("Old Description");
existingOrg.addDomain(new OrganizationDomainRepresentation("old.org"));
Mockito.when(this.adminClient.getOrganizationById(orgId))
.thenReturn(Mono.just(existingOrg));
Mockito.when(this.adminClient.updateOrganization(orgId, existingOrg))
.thenReturn(Mono.empty());
// Act
final var result = this.service.updateOrganization(orgId, updateRequest);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).updateOrganization(orgId, existingOrg);
assertEquals("Updated Description", existingOrg.getDescription());
assertEquals("value", existingOrg.getAttributes().get("key").getFirst());
assertEquals(1, existingOrg.getDomains().size());
assertEquals("updated.org", existingOrg.getDomains().iterator().next().getName());
}
@Test
void deleteOrganization_success() {
// Arrange
final var orgId = "test-org-id";
Mockito.when(this.adminClient.deleteOrganization(orgId))
.thenReturn(Mono.empty());
// Act
final var result = this.service.deleteOrganization(orgId);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).deleteOrganization(orgId);
}
@Test
void inviteUserToOrganization_success() {
// Arrange
final var orgId = "test-org-id";
final var request = new InviteUserRequest("test@example.com", "John", "Wick");
final var invitationRequest = new KeycloakInvitationRequest();
invitationRequest.setEmail(request.getEmail());
invitationRequest.setFirstName(request.getFirstName());
invitationRequest.setLastName(request.getLastName());
Mockito.when(this.adminClient.inviteUserToOrganization(any(), any()))
.thenReturn(Mono.empty());
// Act
final var result = this.service.inviteUserToOrganization(orgId, request);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).inviteUserToOrganization(orgId, invitationRequest);
}
@Test
void getOrganizationMembers_success() {
// Arrange
final var orgId = "test-org-id";
final var mockMembers = List.of(
// I asked Gemini, and this is an antipattern that no one mentions
// when I learned Java. Basically, it will create a new anonymous
// class for every instance. In this case, we have two.
// Gemini suggests we use a method to create a user and then put it in the list,
// but a dedicated method is not as compact as double brace initialization.
// This test is generated by gemini 2.5 pro. I think it's ok to use here
// because it's a unit test, and I blame keycloak to not offer a builder.
// DO NOT use this pattern in the main source set.
new MemberRepresentation() {{
setId("user1");
setUsername("user1Username");
setFirstName("User1");
setLastName("One");
setEmail("user1@example.com");
setEnabled(true);
}},
new MemberRepresentation() {{
setId("user2");
setUsername("user2Username");
setFirstName("User2");
setLastName("Two");
setEmail("user2@example.com");
setEnabled(true);
}}
);
Mockito.when(this.adminClient.getOrganizationMembers(orgId))
.thenReturn(Mono.just(mockMembers));
// Act
final var result = this.service.getOrganizationMembers(orgId);
// Assert
StepVerifier.create(result)
.expectNextMatches(members -> members.size() == 2
&& "user1".equals(members.getFirst().getId())
&& "user1@example.com".equals(members.getFirst().getEmail())
&& "user2".equals(members.get(1).getId())
&& "user2@example.com".equals(members.get(1).getEmail()))
.verifyComplete();
Mockito.verify(this.adminClient).getOrganizationMembers(orgId);
}
@Test
void addMemberToOrganization_success() {
// Arrange
final var orgId = "test-org-id";
final var userId = "test-user-id";
Mockito.when(this.adminClient.addMemberToOrganization(orgId, userId))
.thenReturn(Mono.empty());
// Act
final var result = this.service.addMemberToOrganization(orgId, userId);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).addMemberToOrganization(orgId, userId);
}
@Test
void removeMemberFromOrganization_success() {
// Arrange
final var orgId = "test-org-id";
final var userId = "test-user-id";
Mockito.when(this.adminClient.removeMemberFromOrganization(orgId, userId))
.thenReturn(Mono.empty());
// Act
final var result = this.service.removeMemberFromOrganization(orgId, userId);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).removeMemberFromOrganization(orgId, userId);
}
@Test
void createSubGroup_success() {
// Arrange
final var parentGroupId = "parent-group-id";
final var request = new CreateGroupRequest(
"Test SubGroup", "", Map.of("key", List.of("value")));
final var mockGroupRepresentation = new GroupRepresentation();
mockGroupRepresentation.setId("sub-group-id");
mockGroupRepresentation.setName(request.getName());
mockGroupRepresentation.setAttributes(request.getAttributes());
Mockito.when(this.adminClient.createSubGroup(parentGroupId, mockGroupRepresentation))
.thenReturn(Mono.empty());
// Act
final var result = this.service.createSubGroup(parentGroupId, request);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "Test SubGroup".equals(response.getName()) &&
response.getAttributes().get("key").contains("value"))
.verifyComplete();
Mockito.verify(this.adminClient).createSubGroup(Mockito.eq(parentGroupId), Mockito.any());
}
@Test
void getGroupById_success() {
// Arrange
final var groupId = "test-group-id";
final var mockGroup = new GroupRepresentation();
mockGroup.setId(groupId);
mockGroup.setName("Test Group");
mockGroup.setPath("/test-group");
mockGroup.setDescription("Test Group Description");
mockGroup.setAttributes(Map.of("key", List.of("value1", "value2")));
Mockito.when(this.adminClient.getGroupById(groupId))
.thenReturn(Mono.just(mockGroup));
// Act
final var result = this.service.getGroupById(groupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> response.getId().equals(groupId) &&
response.getName().equals("Test Group") &&
response.getPath().equals("/test-group") &&
response.getDescription().equals("Test Group Description") &&
response.getAttributes().get("key").contains("value1"))
.verifyComplete();
Mockito.verify(this.adminClient).getGroupById(groupId);
}
@Test
void listSubGroups_success() {
// Arrange
final var parentGroupId = "parent-group-id";
final var subGroups = List.of(
new GroupRepresentation() {{
setId("sub-group-1");
setName("SubGroup1");
}},
new GroupRepresentation() {{
setId("sub-group-2");
setName("SubGroup2");
}}
);
Mockito.when(this.adminClient.listSubGroups(parentGroupId))
.thenReturn(Mono.just(subGroups));
// Act
final var result = this.service.listSubGroups(parentGroupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(groups -> groups.size() == 2 &&
"SubGroup1".equals(groups.getFirst().getName()) &&
"sub-group-2".equals(groups.get(1).getId()))
.verifyComplete();
Mockito.verify(this.adminClient).listSubGroups(parentGroupId);
}
@Test
void getGroupMembers_success() {
// Arrange
final var groupId = "test-group-id";
final var mockMembers = List.of(
new UserRepresentation() {{
setId("user1");
setUsername("user1Username");
setFirstName("User1");
setLastName("One");
setEmail("user1@example.com");
setEnabled(true);
}},
new UserRepresentation() {{
setId("user2");
setUsername("user2Username");
setFirstName("User2");
setLastName("Two");
setEmail("user2@example.com");
setEnabled(true);
}}
);
Mockito.when(this.adminClient.getGroupMembers(groupId))
.thenReturn(Mono.just(mockMembers));
// Act
final var result = this.service.getGroupMembers(groupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(members -> members.size() == 2
&& "user1".equals(members.getFirst().getId())
&& "user1@example.com".equals(members.getFirst().getEmail())
&& "user2".equals(members.get(1).getId())
&& "user2@example.com".equals(members.get(1).getEmail()))
.verifyComplete();
Mockito.verify(this.adminClient).getGroupMembers(groupId);
}
}
+12
View File
@@ -0,0 +1,12 @@
spring:
cloud:
consul:
config:
enabled: false
otel:
logs:
exporter: none
traces:
exporter: none
metrics:
exporter: none