Compare commits

..

7 Commits

Author SHA1 Message Date
hurui200320 cdde6ec922 feat:add dev container
Unit test coverage / gradle-test (pull_request) Successful in 6m16s
Unit test coverage / gradle-test (push) Successful in 6m2s
CI for publishing docker image / build-and-publish (push) Successful in 5m59s
2025-08-14 16:24:02 +08:00
hurui200320 bed3de76dd 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
2025-07-31 12:56:14 +08:00
Abed 465c7fd8ec Implement Intra-Organization Group Management APIs in auth-service #25
Unit test coverage / gradle-test (pull_request) Failing after 3m27s
Unit test coverage / gradle-test (push) Failing after 1m55s
CI for publishing docker image / build-and-publish (push) Successful in 2m33s
2025-07-01 11:24:51 +08:00
Abed 6f0af690f2 feat #24 Implement Tenant (Organization) Management APIs in auth-service 2025-07-01 11:24:50 +08:00
hurui200320 0fc59eef0e Implement UMA ticket for forward-auth (#31)
Unit test coverage / gradle-test (push) Failing after 1m48s
CI for publishing docker image / build-and-publish (push) Successful in 2m30s
According to design https://docs.cleverthis.com/en/architecture/microservices/feature-discussion/service-specific-permission-system, this PR replaced the role-based forward auth with UMA ticket to allow keycloak to verify permission based on client authorization rules, which supports roles, with extra features like resource access control and group/organization.

Other than that, this PR also:

+ add config properties objects, which allows easier unit test
+ adapt setup from clevermicro/identity-management#9, now the auth-service works with the dev realm created in identity management repo
+ add pass-through config, allow clients to configure a list of rules to skip the token checking. Use case: CleverBRAG's token for its API access

This PR bumps the coverage rate to 73%, which is still below 85%. Thus the pipeline is failing.

Reviewed-on: #31
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
Co-authored-by: Rui Hu <rui.hu@cleverthis.com>
Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
2025-06-30 09:05:14 +00:00
hurui200320 eaa14e7101 feat(General): implement consul config permission mapping resolving with fallback to application yam
Unit test coverage / gradle-test (pull_request) Failing after 2m36s
Unit test coverage / gradle-test (push) Failing after 1m40s
CI for publishing docker image / build-and-publish (push) Successful in 2m20s
Pull config from consul to resolve permission mapping dynamically

if consul is not available or the

value is corrupted, use settings from local value in application.yaml

ISSUES CLOSED: clevermicro/user-management#23
2025-06-13 12:17:06 +08:00
abed.alrahman b764c6f12b feat#3_implement_access_control (#22)
Unit test coverage / gradle-test (push) Failing after 1m44s
CI for publishing docker image / build-and-publish (push) Successful in 2m31s
Co-authored-by: Abed <alrbeei@yahoo.com>
Reviewed-on: #22
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
Co-authored-by: Abed Alrahman <abed.alrahman@cleverthis.com>
Co-committed-by: Abed Alrahman <abed.alrahman@cleverthis.com>
2025-06-12 11:15:42 +00:00
74 changed files with 5966 additions and 888 deletions
+9
View File
@@ -0,0 +1,9 @@
# Note: You can use any Debian/Ubuntu based image you want.
FROM mcr.microsoft.com/devcontainers/base:bullseye
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
bash \
curl \
jq
+9
View File
@@ -0,0 +1,9 @@
log_level = "INFO"
datacenter = "default_dc"
server = true
bootstrap_expect = 1
ui_config {
enabled = true
}
# we need this line to listen on 0.0.0.0
client_addr = "0.0.0.0"
+38
View File
@@ -0,0 +1,38 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker-compose
{
"name": "Docker from Docker Compose",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// Use this environment variable if you need to bind mount your local source code into a new container.
"remoteEnv": {
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
"WORKSPACE_FOLDER": "/workspaces/${localWorkspaceFolderBasename}"
},
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"version": "latest",
"enableNonRootDocker": "true",
"moby": "false"
},
"ghcr.io/devcontainers/features/java:1": {
"version": "21"
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8099,
"rabbitmq:5672",
"rabbitmq:15672",
"consul:8500",
"keycloak:8000",
],
"postCreateCommand": "/bin/bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/setup.sh",
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
+81
View File
@@ -0,0 +1,81 @@
version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
# Forwards the local Docker socket to the container.
- /var/run/docker.sock:/var/run/docker-host.sock
# Update this to wherever you want VS Code to mount the folder of your project
- ../..:/workspaces:cached
# Overrides default command so things don't shut down after the process ends.
entrypoint: /usr/local/share/docker-init.sh
command: sleep infinity
# Uncomment the next four lines if you will use a ptrace-based debuggers like C++, Go, and Rust.
# cap_add:
# - SYS_PTRACE
# security_opt:
# - seccomp:unconfined
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
depends_on:
- rabbitmq
- consul
- keycloak
rabbitmq:
image: rabbitmq:4-management
hostname: "my-rabbit"
environment:
RABBITMQ_DEFAULT_USER: springuser
RABBITMQ_DEFAULT_PASS: TheCleverWho
volumes:
- "rabbitmq-data:/var/lib/rabbitmq"
consul:
image: hashicorp/consul:1.20
command:
- "agent"
- "-data-dir=/consul/data"
- "-config-file=/config/config.hcl"
environment:
- CONSUL_BIND_INTERFACE=eth0
volumes:
- "consul-data:/consul/data"
- "./consul-main-config.hcl:/config/config.hcl"
keycloak:
image: "quay.io/keycloak/keycloak:26.2.5"
networks:
default:
aliases:
- "keycloak.dev.localhost"
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HOSTNAME=http://keycloak.dev.localhost:8000
- KC_HOSTNAME_ADMIN=http://keycloak.dev.localhost:8000
- KC_HTTP_PORT=8000
command:
- start
# enable http, let reverse proxy handles the TLS
- --http-enabled=true
# take X-Forwarded-* headers from reverse proxy
- --proxy-headers=xforwarded
# hide node name in cookie for sticky session, we're using reverse proxy for that
# ref: https://www.keycloak.org/server/reverseproxy#_enable_sticky_sessions
- --spi-sticky-session-encoder-infinispan-should-attach-route=false
# read hostname from reverse proxy headers
- --hostname-strict=false
# Limit the request queue to prevent OOM
- --http-max-queued-requests=200
volumes:
rabbitmq-data:
consul-data:
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
cd ${WORKSPACE_FOLDER}/.devcontainer/identity-management/
./tools/setup-cli/setup-admin-cli.sh
sleep 10 # wait keycloak
./tools/login-admin/login-admin.sh -s=http://keycloak.dev.localhost:8000 -u=admin -p=admin
./tools/create-realm/create-realm.sh -n=clevermicro-dev
./tools/create-client/create-client.sh -r=clevermicro-dev -n=document-service
./tools/dev-realm/set-up.sh -r=clevermicro-dev
./tools/create-user/create-user.sh -r=clevermicro-dev -u=test -p=password -f=Jhon -l=Doe -e=jhon.doe@example.com
+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
View File
@@ -0,0 +1,3 @@
[submodule ".devcontainer/identity-management"]
path = .devcontainer/identity-management
url = ../identity-management.git
+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
-155
View File
@@ -1,155 +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>
</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;
}
@@ -0,0 +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 KeycloakAdminReactiveClient}.
*/
@Configuration
@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
public class KeycloakClientConfig {
/**
* 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"
);
}
}
@@ -0,0 +1,71 @@
package com.cleverthis.authservice.config;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Config model for {@link KeycloakClientConfig}.
*/
@ConfigurationProperties(prefix = "keycloak")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class KeycloakClientConfigProperties {
/**
* Host name of your Keycloak instance.
*/
@Builder.Default
@NotBlank(message = "Keycloak server URL cannot be blank")
private String keycloakHost = "http://keycloak.dev.localhost";
/**
* Admin host name of your Keycloak instance.
*/
@Builder.Default
@NotBlank(message = "Keycloak server URL cannot be blank")
private String keycloakAdminHost = "http://keycloak-admin.dev.localhost";
/**
* The realm name.
*/
@Builder.Default
@NotBlank(message = "Keycloak realm cannot be blank")
private String realm = "clevermicro-dev";
/**
* The client id for calling OIDC endpoints.
*/
@Builder.Default
@NotBlank(message = "Keycloak client ID cannot be blank")
private String clientId = "auth-service";
/**
* The client secret.
*/
@Builder.Default
@NotBlank(message = "Keycloak client secret cannot be blank")
private String clientSecret = "";
/**
* The admin account for accessing the admin endpoints.
*/
@Builder.Default
@NotBlank(message = "Keycloak admin account username cannot be blank")
private String adminAccountUsername = "auth-service-account";
/**
* The admin account password.
*/
@Builder.Default
@NotBlank(message = "Keycloak admin account password cannot be blank")
private String adminAccountPassword = "";
}
@@ -20,10 +20,10 @@ import org.springframework.validation.annotation.Validated;
@Component
@ConfigurationProperties(prefix = "auth-service.permission-mapping")
@Data
@Validated
public class PermissionMappingConfig {
@Validated
public class PermissionMappingConfigProperties {
@Valid
@Valid
private List<Rule> rules = new ArrayList<>();
private String defaultKeycloakClientId; // Optional default
@@ -39,5 +39,38 @@ public class PermissionMappingConfig {
private String pathPrefix;
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
private String keycloakClientId;
@Builder.Default
private List<PassThrough> passthroughs = new ArrayList<>();
}
/**
* Define the pass through rules.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PassThrough {
@NotBlank(message = "Type must not be blank")
private Type type;
@NotBlank(message = "The value of the rule must not be blank")
private String value;
/**
* Supported pass-through types.
*/
public enum Type {
/**
* Use regex defined in value to match the path.
* Example value: {@code ^/path/(a-zA-Z)+}.
*/
PATH_REGEX,
/**
* Use the prefix defined in value to match the path.
* Example value: {@code /path/}.
*/
PATH_PREFIX
}
}
}
@@ -1,6 +1,6 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.config.PermissionMappingConfig;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
import com.cleverthis.authservice.dto.TokenResponse;
@@ -11,6 +11,7 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
import jakarta.validation.Valid;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -160,24 +161,52 @@ public class AuthController {
return headers;
}
private static final Map<String, String> HTTP_METHOD_TO_SCOPE = Map.of(
"get", "rest:read",
"post", "rest:create",
"put", "rest:update",
"delete", "rest:delete"
);
/**
* Handles forward authentication requests from Traefik. Expects the access token in the
* Authorization header (Bearer scheme), and X-Forwarded-Method, X-Forwarded-Uri from Traefik.
* On success, returns 200 OK with user metadata in headers. On failure, returns 401
* Unauthorized or 403 Forbidden.
*/
@RequestMapping(value = "/auth", method = { RequestMethod.GET, RequestMethod.POST,
RequestMethod.PUT, RequestMethod.DELETE,
RequestMethod.PATCH })
public Mono<ResponseEntity<? extends Object>> forwardAuth(
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader,
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString) {
@RequestMapping(value = "/auth", method = {
RequestMethod.GET, RequestMethod.POST,
RequestMethod.PUT, RequestMethod.DELETE,
RequestMethod.PATCH})
public Mono<ResponseEntity<?>> forwardAuth(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false)
String authorizationHeader,
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString
) {
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
originalUriString);
originalUriString);
// first detect the client id and service relative path
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 rule = optionalRule.get();
String accessToken = extractBearerToken(authorizationHeader);
final var targetServiceClientId = rule.getKeycloakClientId();
final var serviceRelativePath =
extractServiceRelativePath(originalUriString, rule);
// then check pass-through rule
if (shouldPassThrough(rule, serviceRelativePath)) {
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
}
if (authorizationHeader == null) {
return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST).build());
}
// no pass-through, check permission
final var accessToken = extractBearerToken(authorizationHeader);
if (accessToken == null) {
log.warn("ForwardAuth: Missing or invalid Bearer token format.");
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
@@ -185,85 +214,102 @@ public class AuthController {
// Step 1: Validate the user's token and get user details (especially userSub)
return this.identityManagerService.verifyToken(accessToken)
.flatMap(verificationResponse -> {
// Token is valid and active, proceed to permission check
String userSub = verificationResponse.getSub();
if (userSub == null || userSub.isBlank()) {
.flatMap(verificationResponse -> {
// Token is valid and active, proceed to permission check
final var userSub = verificationResponse.getSub();
if (userSub == null || userSub.isBlank()) {
log.error(
"ForwardAuth: User subject (sub) "
+ "is missing from token after verification.");
return Mono.just(
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}
// Step 2: Figure out the scope
final var scope = HTTP_METHOD_TO_SCOPE.get(originalMethod.toLowerCase());
if (scope == null) { // reject if method not allowed
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
}
// Step 3: Check permission
return this.identityManagerService.checkResourcePermission(
accessToken, targetServiceClientId, serviceRelativePath, scope
)
.flatMap(hasPermission -> {
if (hasPermission) {
log.info(
"ForwardAuth: GRANTED for User='{}',"
+ " Resource='{}', Scope='{}', Client='{}'",
verificationResponse.getUsername(),
serviceRelativePath, scope,
targetServiceClientId);
final var responseHeaders = buildAuthHeaders(verificationResponse);
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
.<Void>build());
} else {
log.warn(
"ForwardAuth: DENIED for User='{}',"
+ " Resource='{}', Scope='{}', Client='{}'",
verificationResponse.getUsername(),
serviceRelativePath, scope,
targetServiceClientId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<Void>build());
}
});
}).onErrorResume(TokenVerificationException.class, e -> {
log.warn("ForwardAuth: Token verification failed: {}", e.getMessage());
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}).onErrorResume(throwable -> !(throwable instanceof TokenVerificationException),
t -> {
log.error(
"ForwardAuth: User subject (sub) "
+ "is missing from token after verification.");
return Mono.just(
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}
// Step 2: Determine target Keycloak Client ID from original URI
Optional<String> targetServiceClientIdOpt =
getTargetServiceClientId(originalUriString);
if (targetServiceClientIdOpt.isEmpty()) {
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
originalUriString);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
}
String targetServiceClientId = targetServiceClientIdOpt.get();
// Step 3: Construct the required Keycloak Client Role name
// Path for role construction should be relative to the service prefix
String serviceRelativePath =
extractServiceRelativePath(originalUriString, targetServiceClientId);
String requiredRoleName =
constructRequiredRoleName(originalMethod, serviceRelativePath);
log.debug("ForwardAuth: UserSub='{}', TargetClient='{}', RequiredRole='{}'",
userSub, targetServiceClientId, requiredRoleName);
// Step 4: Check permission
return this.identityManagerService.checkUserClientRolePermission(userSub,
targetServiceClientId, requiredRoleName).flatMap(hasPermission -> {
if (hasPermission) {
log.info(
"ForwardAuth: GRANTED for UserSub='{}',"
+ " Role='{}', Client='{}'",
userSub, requiredRoleName, targetServiceClientId);
HttpHeaders responseHeaders =
buildAuthHeaders(verificationResponse);
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
.<Void>build());
} else {
log.warn(
"ForwardAuth: DENIED for UserSub='{}',"
+ " Role='{}', Client='{}'",
userSub, requiredRoleName, targetServiceClientId);
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
.<Void>build());
}
});
}).onErrorResume(TokenVerificationException.class, e -> {
log.warn("ForwardAuth: Token verification failed: {}", e.getMessage());
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}).onErrorResume(throwable -> !(throwable instanceof TokenVerificationException),
t -> {
log.error(
"ForwardAuth: Generic error/exception during"
"ForwardAuth: Generic error/exception during"
+ " permission processing: {}",
t.getMessage(), t);
return Mono.just(
ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>build());
})
.onErrorResume(Exception.class, e -> { // Catch other unexpected errors
log.error("ForwardAuth: Internal error during permission check: {}",
e.getMessage(), e);
return Mono
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
});
t.getMessage(), t);
return Mono.just(
ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>build());
})
.onErrorResume(Exception.class, e -> { // Catch other unexpected errors
log.error("ForwardAuth: Internal error during permission check: {}",
e.getMessage(), e);
return Mono
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
});
}
private Optional<String> getTargetServiceClientId(String fullUriString) {
private boolean shouldPassThrough(
final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath
) {
final var passthroughs = rule.getPassthroughs();
try {
return passthroughs.stream()
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
} catch (final Throwable throwable) {
log.error("Error executing pass-through rule for client {} : {}",
rule.getKeycloakClientId(), throwable.getMessage(), throwable);
return false;
}
}
private boolean executePassThroughRule(
final PermissionMappingConfigProperties.PassThrough rule,
final String serviceRelativePath
) {
return switch (rule.getType()) {
case PATH_REGEX -> serviceRelativePath.matches(rule.getValue());
case PATH_PREFIX -> serviceRelativePath.startsWith(rule.getValue());
};
}
private Optional<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;
}
@@ -272,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);
@@ -281,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<PermissionMappingConfig.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
@@ -323,39 +365,4 @@ public class AuthController {
return ""; // Or throw
}
}
/**
* Constructs the required Keycloak Client Role name based on the HTTP method and relative path.
* Example: GET /api/jobs/{id} -> GET_api_jobs_BY_ID Example: POST /api/users -> POST_api_users
*/
private String constructRequiredRoleName(String httpMethod, String relativePath) {
// Normalize path: remove leading/trailing slashes, replace slashes with underscores
String normalizedPath = relativePath.trim();
if (normalizedPath.startsWith("/")) {
normalizedPath = normalizedPath.substring(1);
}
if (normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1);
}
// Replace path parameters like {id} or UUIDs with a placeholder like "BY_ID" or "ITEM"
// This is a simple replacement, more robust regex might be needed for complex patterns
normalizedPath = normalizedPath.replaceAll("\\{[^}]+}", "BY_ID"); // {param} -> BY_ID
// Example: Replace UUIDs - adjust regex as needed for your UUID format
normalizedPath = normalizedPath.replaceAll(
"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
"BY_ID");
// Replace remaining slashes with underscores and make uppercase
String rolePathPart = normalizedPath.replace("/", "_").toUpperCase();
if (rolePathPart.isEmpty()) {
rolePathPart = "ROOT"; // Or some other indicator for root path of the service
}
String roleName = httpMethod.toUpperCase() + "_" + rolePathPart;
// Optional: Truncate or hash if role names become too long for Keycloak
log.debug("Constructed role name: {} from method: {} and relative path: {}", roleName,
httpMethod, relativePath);
return roleName;
}
}
@@ -0,0 +1,175 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.service.IdentityManagerService;
import jakarta.validation.Valid;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
/**
* REST Controller for managing fine-grained groups (departments, teams) within
* the context of a parent organization.
*/
@RestController
@RequestMapping("/groups")
@Slf4j
public class IntraOrganizationGroupController {
private final IdentityManagerService identityManagerService;
/**
* Constructs the controller with the required service dependency.
*
* @param identityManagerService The service handling identity management logic.
*/
@Autowired
public IntraOrganizationGroupController(final IdentityManagerService identityManagerService) {
this.identityManagerService = identityManagerService;
}
/**
* Creates a new sub-group within a parent organization.
*
* @param orgId The ID of the parent organization.
* @param request The request body containing details for the new group.
* @return A Mono emitting the created group's representation.
*/
@PostMapping("/{orgId}/children")
public Mono<ResponseEntity<GroupResponse>> createSubGroup(@PathVariable final String orgId,
@Valid @RequestBody final CreateGroupRequest request) {
log.info("Received request to create sub-group with name '{}' in organization '{}'",
request.getName(), orgId);
return this.identityManagerService.createSubGroup(orgId, request)
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
}
/**
* Lists all sub-groups within a parent organization.
*
* @param orgId The ID of the parent organization.
* @return A Mono emitting a list of group representations.
*/
@GetMapping("/{orgId}/children")
public Mono<ResponseEntity<List<GroupResponse>>> listSubGroups(
@PathVariable final String orgId) {
log.info("Received request to list sub-groups for organization '{}'", orgId);
return this.identityManagerService.listSubGroups(orgId).map(ResponseEntity::ok);
}
/**
* Retrieves a single sub-group by its ID.
*
* @param orgId The ID of the parent organization (for context).
* @param groupId The ID of the sub-group to retrieve.
* @return A Mono emitting the group's representation.
*/
@GetMapping("/{orgId}/children/{groupId}")
public Mono<ResponseEntity<GroupResponse>> getSubGroupById(@PathVariable final String orgId,
@PathVariable final String groupId) {
log.info("Received request to get sub-group '{}' from organization '{}'", groupId, orgId);
return this.identityManagerService.getGroupById(groupId).map(ResponseEntity::ok);
}
/**
* Updates an existing sub-group.
*
* @param orgId The ID of the parent organization (for context).
* @param groupId The ID of the sub-group to update.
* @param request The request body with fields to update.
* @return A Mono completing with a 204 No Content response.
*/
@PutMapping("/{orgId}/children/{groupId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> updateSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId,
@Valid @RequestBody final UpdateGroupRequest request) {
log.info("Received request to update sub-group '{}' in organization '{}'", groupId, orgId);
return this.identityManagerService.updateGroup(groupId, request);
}
/**
* Deletes a sub-group.
*
* @param orgId The ID of the parent organization (for context).
* @param groupId The ID of the sub-group to delete.
* @return A Mono completing with a 204 No Content response.
*/
@DeleteMapping("/{orgId}/children/{groupId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId) {
log.info("Received request to delete sub-group '{}' from organization '{}'",
groupId, orgId);
return this.identityManagerService.deleteGroup(groupId);
}
// --- Sub-Group Membership Management ---
/**
* Adds an existing organization member to a sub-group.
*
* @param orgId The ID of the parent organization.
* @param groupId The ID of the sub-group.
* @param request The request body containing the user's ID.
* @return A Mono completing with a 204 No Content response.
*/
@PostMapping("/{orgId}/children/{groupId}/members")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> addMemberToSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId,
@Valid @RequestBody final AddGroupMemberRequest request) {
log.info("Received request to add user '{}' to sub-group '{}' in organization '{}'",
request.getUserId(), groupId, orgId);
return this.identityManagerService.addMemberToGroup(groupId, request.getUserId());
}
/**
* Lists all members of a specific sub-group.
*
* @param orgId The ID of the parent organization.
* @param groupId The ID of the sub-group.
* @return A Mono emitting a list of organization member representations.
*/
@GetMapping("/{orgId}/children/{groupId}/members")
public Mono<ResponseEntity<List<OrganizationMemberResponse>>>
listSubGroupMembers(@PathVariable final String orgId,
@PathVariable final String groupId) {
log.info("Received request to list members of sub-group '{}' in organization '{}'",
groupId, orgId);
return this.identityManagerService.getGroupMembers(groupId).map(ResponseEntity::ok);
}
/**
* Removes a user from a sub-group.
*
* @param orgId The ID of the parent organization.
* @param groupId The ID of the sub-group.
* @param userId The ID of the user to remove.
* @return A Mono completing with a 204 No Content response.
*/
@DeleteMapping("/{orgId}/children/{groupId}/members/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> removeMemberFromSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId, @PathVariable final String userId) {
log.info("Received request to remove user '{}' from sub-group '{}' in organization '{}'",
userId, groupId, orgId);
return this.identityManagerService.removeMemberFromGroup(groupId, userId);
}
}
@@ -0,0 +1,140 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.service.IdentityManagerService;
import jakarta.validation.Valid;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
/**
* REST Controller for handling Organization related requests.
*/
@RestController
@RequestMapping("/organizations")
@Slf4j
public class OrganizationController {
private final IdentityManagerService identityManagerService;
@Autowired
public OrganizationController(IdentityManagerService identityManagerService) {
this.identityManagerService = identityManagerService;
}
/**
* Creates a new Organization. Requires administrative privileges, which should be enforced by
* the /auth endpoint.
*/
@PostMapping
public Mono<ResponseEntity<OrganizationResponse>> createOrganization(
@Valid @RequestBody CreateOrganizationRequest request) {
log.info("Received request to create organization with name: {}", request.getName());
return this.identityManagerService.createOrganization(request)
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
}
/**
* Retrieves a list of all Organizations.
*/
@GetMapping
public Mono<ResponseEntity<List<OrganizationResponse>>> getOrganizations() {
log.info("Received request to list all organizations");
return this.identityManagerService.getOrganizations().map(ResponseEntity::ok);
}
/**
* Retrieves a single Organization by its ID.
*/
@GetMapping("/{orgId}")
public Mono<ResponseEntity<OrganizationResponse>> getOrganizationById(
@PathVariable String orgId) {
log.info("Received request to get organization by ID: {}", orgId);
return this.identityManagerService.getOrganizationById(orgId).map(ResponseEntity::ok);
}
/**
* Updates an existing Organization.
*/
@PutMapping("/{orgId}")
public Mono<ResponseEntity<Void>> updateOrganization(@PathVariable String orgId,
@Valid @RequestBody UpdateOrganizationRequest request) {
log.info("Received request to update organization ID: {}", orgId);
return this.identityManagerService.updateOrganization(orgId, request)
.thenReturn(ResponseEntity.noContent().<Void>build());
}
/**
* Deletes an Organization.
*/
@DeleteMapping("/{orgId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteOrganization(@PathVariable String orgId) {
log.info("Received request to delete organization ID: {}", orgId);
return this.identityManagerService.deleteOrganization(orgId);
}
// --- Membership Management ---
/**
* Invites a user to join an Organization via email.
*/
@PostMapping("/{orgId}/invitations")
public Mono<ResponseEntity<Void>> inviteUserToOrganization(@PathVariable String orgId,
@Valid @RequestBody InviteUserRequest request) {
log.info("Received request to invite user with email {} to organization ID: {}",
request.getEmail(), orgId);
return this.identityManagerService.inviteUserToOrganization(orgId, request)
.thenReturn(ResponseEntity.ok().<Void>build());
}
/**
* Retrieves all members of an Organization.
*/
@GetMapping("/{orgId}/members")
public Mono<ResponseEntity<List<OrganizationMemberResponse>>> getOrganizationMembers(
@PathVariable String orgId) {
log.info("Received request to get members of organization ID: {}", orgId);
return this.identityManagerService.getOrganizationMembers(orgId).map(ResponseEntity::ok);
}
/**
* Adds an existing user to an Organization.
*/
@PostMapping("/{orgId}/members")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> addMemberToOrganization(@PathVariable String orgId,
@Valid @RequestBody AddMemberRequest request) {
log.info("Received request to add user ID {} to organization ID: {}", request.getUserId(),
orgId);
return this.identityManagerService.addMemberToOrganization(orgId, request.getUserId());
}
/**
* Removes a user from an Organization.
*/
@DeleteMapping("/{orgId}/members/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> removeMemberFromOrganization(@PathVariable String orgId,
@PathVariable String userId) {
log.info("Received request to remove user ID {} from organization ID: {}", userId, orgId);
return this.identityManagerService.removeMemberFromOrganization(orgId, userId);
}
}
@@ -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
);
}
}
}
@@ -3,6 +3,7 @@ package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -13,6 +14,7 @@ import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VerificationResponse {
private boolean active; // Standard introspection field: is the token active?
@JsonProperty("client_id")
@@ -0,0 +1,18 @@
package com.cleverthis.authservice.dto.group;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for adding an existing user to a group.
* This can be reused from the organization membership logic.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddGroupMemberRequest {
@NotBlank(message = "User ID cannot be blank")
private String userId; // The Keycloak user ID (sub)
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.group; // New package for group-related DTOs
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when creating a new sub-group (e.g., department, team)
* within an organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateGroupRequest {
@NotBlank(message = "Group name cannot be blank")
private String name;
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.dto.group;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing a group in API responses.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupResponse {
private String id;
private String name;
private String path; // The full path of the group (e.g., /tenant-org/departments/engineering)
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto.group;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when updating a sub-group.
* The name of a group is typically not updatable via this DTO; it's part of the path.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateGroupRequest {
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak Group when interacting
* with the Keycloak Admin API.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KeycloakGroupRepresentation {
private String id;
private String name;
private String path;
private String description; // Note: Description is often stored in attributes
private Map<String, List<String>> attributes;
private List<KeycloakGroupRepresentation> subGroups;
}
@@ -0,0 +1,22 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for creating an invitation request to join an organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakInvitationRequest {
private String email;
private String firstName;
private String lastName;
// You can also specify roles to be redirected to after accepting
// private List<String> redirectRoles;
}
@@ -0,0 +1,31 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
/**
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
* Admin API.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
// Include only non-null fields during serialization, useful for update operations
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KeycloakOrganizationRepresentation {
private String id;
private String name;
private Set<OrganizationDomainRepresentation> domains;
private String description;
private Boolean enabled;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak User, typically when listing members of an
* organization. It includes only the most relevant fields.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakUserRepresentation {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean enabled;
private Long createdTimestamp;
}
@@ -0,0 +1,17 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for adding an existing user to an Organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class AddMemberRequest {
@NotBlank(message = "User ID cannot be blank")
private String userId; // The Keycloak user ID (sub)
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when creating a new Organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateOrganizationRequest {
@NotBlank(message = "Organization name cannot be blank")
private String name; // This will become the internal name in Keycloak
@NotEmpty(message = "At least one organization domain must be provided")
private List<String> domains;
private String description;
private Map<String, List<String>> attributes; // For custom metadata like displayName
}
@@ -0,0 +1,26 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for inviting a new or existing user to an Organization via email.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class InviteUserRequest {
@NotBlank(message = "Email cannot be blank")
@Email(message = "A valid email address is required")
private String email;
// Optional fields if inviting a brand new user
private String firstName;
private String lastName;
// You could also add a list of roles/groups to assign upon joining
// private List<String> rolesToAssign;
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.dto.organization;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing a member of an Organization. This mirrors Keycloak's UserRepresentation for
* relevant fields.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class OrganizationMemberResponse {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean enabled;
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.organization;
import 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.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationResponse {
private String id;
private String name;
private Set<OrganizationDomainRepresentation> domains;
private String description;
private Boolean enabled;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto.organization;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when updating an Organization.
* Name is typically not updatable, but description and attributes are.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateOrganizationRequest {
private List<String> domains;
private String description;
private Map<String, List<String>> attributes;
}
@@ -68,7 +68,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ServiceUnavailableException.class) // New Handler
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
// Error communicating with Keycloak Admin API or other critical backend
logger.error("Service unavailable exception: {}", ex); // Log with stack trace
this.logger.error("Service unavailable exception: {}", ex); // Log with stack trace
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"An external service required for authorization is currently unavailable.");
@@ -99,12 +99,26 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
return problemDetail;
}
/**
* Handles ResourceNotFoundException and translates it to a 404 Not Found response.
*
* @param ex The ResourceNotFoundException that was thrown.
* @return A ProblemDetail object for a 404 response.
*/
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Resource Not Found");
return problemDetail;
}
/**
* Map {@link Exception} to HTTP 500, Catch-all for other Generic exceptions.
*/
@ExceptionHandler(Exception.class)
public ProblemDetail handleGenericException(Exception ex) {
logger.error("An internal error occurred.: ", ex); // Log the full stack trace
this.logger.error("An internal error occurred.: ", ex); // Log the full stack trace
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
problemDetail.setTitle("Internal Server Error");
@@ -0,0 +1,24 @@
package com.cleverthis.authservice.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Custom exception to be thrown when a requested resource is not found.
* The @ResponseStatus annotation tells Spring to automatically return a 404 Not Found
* status when this exception is thrown from a controller and not handled by an
* exception handler. This can simplify the GlobalExceptionHandler.
*/
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
/**
* Constructs a new ResourceNotFoundException with the specified detail message.
*
* @param message the detail message.
*/
public ResourceNotFoundException(String message) {
super(message);
}
}
@@ -3,7 +3,18 @@ package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import reactor.core.publisher.Mono; // Using Mono for asynchronous operations with WebClient
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import java.util.List;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import reactor.core.publisher.Mono;
/**
* Interface defining operations for interacting with an identity provider. This abstraction allows
@@ -39,19 +50,6 @@ public interface IdentityManagerService {
* revocation fails.
*/
Mono<Void> logout(String refreshToken);
/**
* Checks if a user has a specific client role for a target service (Keycloak client).
*
* @param userSub The subject (ID) of the user.
* @param targetServiceClientId The public client ID of the target service/application
* in Keycloak.
* @param requiredRoleName The name of the client role to check for.
* @return A Mono emitting true if the user has the role, false otherwise. Emits an error if the
* check cannot be performed (e.g., Keycloak unavailable).
*/
Mono<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId,
String requiredRoleName);
/**
* Registers a new user in the identity provider.
@@ -64,4 +62,152 @@ public interface IdentityManagerService {
* Emits an error (e.g., UserAlreadyExistsException) if registration fails.
*/
Mono<Void> registerUser(RegistrationRequest registrationRequest);
Mono<Boolean> checkResourcePermission(
String reqAccessToken, String clientId, String resourceUri, String scope
);
/**
* Creates a new Organization in Keycloak.
*
* @param request DTO with details for the new organization.
* @return A Mono emitting the newly created Organization's representation.
*/
Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request);
/**
* Retrieves all Organizations from Keycloak.
*
* @return A Mono emitting a list of Organization representations.
*/
Mono<List<OrganizationResponse>> getOrganizations();
/**
* Retrieves a single Organization by its ID from Keycloak.
*
* @param orgId The ID of the organization to retrieve.
* @return A Mono emitting the Organization's representation.
*/
Mono<OrganizationResponse> getOrganizationById(String orgId);
/**
* Updates an existing Organization in Keycloak.
*
* @param orgId The ID of the organization to update.
* @param request DTO with the fields to update.
* @return A Mono completing when the update is successful.
*/
Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request);
/**
* Deletes an Organization from Keycloak.
*
* @param orgId The ID of the organization to delete.
* @return A Mono completing when the deletion is successful.
*/
Mono<Void> deleteOrganization(String orgId);
/**
* Invites a user to join an Organization. Keycloak handles the email sending.
*
* @param orgId The ID of the organization to invite the user to.
* @param request DTO containing the user's email and other details.
* @return A Mono completing when the invitation is successfully sent.
*/
Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request);
/**
* Retrieves all members of an Organization.
*
* @param orgId The ID of the organization.
* @return A Mono emitting a list of members.
*/
Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId);
/**
* Adds an existing user to an Organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to add.
* @return A Mono completing when the user is successfully added.
*/
Mono<Void> addMemberToOrganization(String orgId, String userId);
/**
* Removes a user from an Organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to remove.
* @return A Mono completing when the user is successfully removed.
*/
Mono<Void> removeMemberFromOrganization(String orgId, String userId);
/**
* Creates a new sub-group as a child of a specified parent group.
*
* @param parentGroupId The ID of the parent group.
* @param request DTO containing the details for the new sub-group.
* @return A Mono emitting the representation of the created group.
*/
Mono<GroupResponse> createSubGroup(String parentGroupId, CreateGroupRequest request);
/**
* Retrieves a single group by its unique ID.
*
* @param groupId The ID of the group to retrieve.
* @return A Mono emitting the group's representation.
*/
Mono<GroupResponse> getGroupById(String groupId);
/**
* Lists all direct sub-groups (children) of a given parent group.
*
* @param parentGroupId The ID of the parent group.
* @return A Mono emitting a list of group representations.
*/
Mono<List<GroupResponse>> listSubGroups(String parentGroupId);
/**
* Updates an existing group's details.
*
* @param groupId The ID of the group to update.
* @param request DTO containing the fields to update.
* @return A Mono that completes when the update is successful.
*/
Mono<Void> updateGroup(String groupId, UpdateGroupRequest request);
/**
* Deletes a group by its ID.
*
* @param groupId The ID of the group to delete.
* @return A Mono that completes when the deletion is successful.
*/
Mono<Void> deleteGroup(String groupId);
/**
* Adds an existing user to a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user to add.
* @return A Mono that completes when the user is successfully added.
*/
Mono<Void> addMemberToGroup(String groupId, String userId);
/**
* Retrieves all members of a specific group.
*
* @param groupId The ID of the group.
* @return A Mono emitting a list of member representations.
*/
Mono<List<OrganizationMemberResponse>> getGroupMembers(String groupId);
/**
* Removes a user from a specific group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user to remove.
* @return A Mono that completes when the user is successfully removed.
*/
Mono<Void> removeMemberFromGroup(String groupId, String userId);
}
@@ -1,6 +1,6 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.config.PermissionMappingConfig;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import java.util.Optional;
/**
@@ -16,11 +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<PermissionMappingConfig.Rule> matchRuleByClientIdAndPath(String clientId, String path);
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(String path);
}
@@ -1,6 +1,6 @@
package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.config.PermissionMappingConfig;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -29,30 +29,31 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
*/
static final String PERMISSION_MAPPING_CONSUL_KEY =
"cleverthis/clevermicro/auth-service/config/permission-mapping";
private final PermissionMappingConfig permissionMappingConfig;
private final PermissionMappingConfigProperties permissionMappingConfigProperties;
private final ConsulClient consulClient;
private final ObjectMapper objectMapper;
/**
* Create an instance of {@link FallbackPermissionMapLookupService}.
*
* @param permissionMappingConfig the local config, used as fallback.
* @param consulClient the consul client, used for query consul value.
* @param objectMapper for deserializing the value from consul.
* @param permissionMappingConfigProperties the local config, used as fallback.
* @param consulClient the consul client, used for query consul value.
* @param objectMapper for deserializing the value from consul.
*/
@Autowired
public FallbackPermissionMapLookupService(
final PermissionMappingConfig permissionMappingConfig,
final PermissionMappingConfigProperties permissionMappingConfigProperties,
final ConsulClient consulClient,
final ObjectMapper objectMapper
) {
this.permissionMappingConfig = permissionMappingConfig;
this.permissionMappingConfigProperties = permissionMappingConfigProperties;
this.consulClient = consulClient;
this.objectMapper = objectMapper;
refreshConsul(); // initial refresh
}
private final AtomicReference<PermissionMappingConfig> consulValue = new AtomicReference<>();
private final AtomicReference<PermissionMappingConfigProperties> consulValue =
new AtomicReference<>();
@Scheduled(initialDelay = 1000, fixedRate = 1000)
void refreshConsul() {
@@ -71,7 +72,7 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
try { // try decoding
final var config = this.objectMapper.readValue(
kvValue.getDecodedValue(), PermissionMappingConfig.class);
kvValue.getDecodedValue(), PermissionMappingConfigProperties.class);
this.consulValue.set(config);
} catch (final JsonProcessingException ex) {
// got a corrupted value, leaving the cache empty
@@ -85,12 +86,12 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
}
}
PermissionMappingConfig getConfig() {
PermissionMappingConfigProperties getConfig() {
final var consulValue = this.consulValue.get();
if (consulValue != null) {
return consulValue;
} else {
return this.permissionMappingConfig;
return this.permissionMappingConfigProperties;
}
}
@@ -100,27 +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<PermissionMappingConfig.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();
}
}
@@ -1,178 +0,0 @@
package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse;
import com.cleverthis.authservice.dto.KeycloakClientRepresentation;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
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.beans.factory.annotation.Value;
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);
@Value("${keycloak.server-url}")
private String keycloakServerUrl;
@Value("${keycloak.realm}")
private String realm;
// Credentials for auth-service's own client to call Admin API
@Value("${auth-service.admin-client.client-id}")
private String adminServiceClientId;
@Value("${auth-service.admin-client.client-secret}")
private String adminServiceClientSecret;
public KeycloakAdminClient(@Qualifier("keycloakWebClient") WebClient webClient) {
this.webClient = webClient;
}
private String getAdminTokenEndpoint() {
// Ensure no double slashes if keycloakServerUrl itself has a trailing slash (it shouldn't)
return String.format("%s/realms/%s/protocol/openid-connect/token",
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
}
private String getClientsEndpoint() {
return String.format("%s/admin/realms/%s/clients",
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
}
private String getUserClientRolesEndpoint(String userId, String clientInternalId) {
return String.format("%s/admin/realms/%s/users/%s/role-mappings/clients/%s/composite",
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm, userId, clientInternalId);
}
/**
* 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 client_id: {}",
this.adminServiceClientId);
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.adminServiceClientId);
formData.add("client_secret", this.adminServiceClientSecret);
formData.add("grant_type", "client_credentials");
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)));
}
}
@@ -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));
}
}
@@ -1,30 +1,43 @@
package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.PermissionCheckException;
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.Map;
import java.util.List;
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.beans.factory.annotation.Value;
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;
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.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
/**
@@ -32,49 +45,53 @@ import reactor.core.publisher.Mono;
* Keycloak's REST API endpoints.
*/
@Service
@Slf4j
@Slf4j
public class KeycloakIdentityManagerService implements IdentityManagerService {
private final WebClient webClient;
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
private final KeycloakAdminReactiveClient keycloakAdminClient; // Inject KeycloakAdminClient
private final String keycloakServerUrl;
private final String realm;
private final String clientId;
private final String clientSecret;
@Value("${keycloak.server-url}")
private String keycloakServerUrl;
@Value("${keycloak.realm}")
private String realm;
@Value("${keycloak.client-id}")
private String clientId;
@Value("${keycloak.client-secret}")
private String clientSecret;
@Value("${keycloak.grant-type}")
private String grantType; // Should be 'password' for ROPC
private String getTokenEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/token", this.keycloakServerUrl,
this.realm);
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
.path("/realms/{realm}/protocol/openid-connect/token")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getIntrospectionEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/token/introspect",
this.keycloakServerUrl, this.realm);
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
.path("/realms/{realm}/protocol/openid-connect/token/introspect")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getRevocationEndpoint() {
return String.format("%s/realms/%s/protocol/openid-connect/revoke", this.keycloakServerUrl,
this.realm);
}
private String getUsersAdminEndpoint() {
return String.format("%s/admin/realms/%s/users",
this.keycloakServerUrl.replaceAll("/+$", ""), this.realm);
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
.path("/realms/{realm}/protocol/openid-connect/revoke")
.buildAndExpand(this.realm)
.encode().toUriString();
}
/**
* Create a keycloak OIDC client.
*/
@Autowired
public KeycloakIdentityManagerService(WebClient keycloakWebClient,
KeycloakAdminClient keycloakAdminClient) {
public KeycloakIdentityManagerService(
@Qualifier("keycloakWebClient") final WebClient keycloakWebClient,
final KeycloakAdminReactiveClient keycloakAdminClient,
final KeycloakClientConfigProperties configProperties
) {
this.webClient = keycloakWebClient;
this.keycloakAdminClient = keycloakAdminClient;
this.keycloakServerUrl = configProperties.getKeycloakHost();
this.realm = configProperties.getRealm();
this.clientId = configProperties.getClientId();
this.clientSecret = configProperties.getClientSecret();
}
@Override
@@ -83,29 +100,29 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.clientId);
formData.add("client_secret", this.clientSecret);
formData.add("grant_type", this.grantType); // Use configured grant type (password)
formData.add("grant_type", "password"); // Use configured grant type (password)
formData.add("username", username);
formData.add("password", password);
formData.add("scope", "openid email profile"); // Request standard scopes
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
@@ -117,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
@@ -152,149 +169,264 @@ 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<Boolean> checkUserClientRolePermission(String userSub, String targetServiceClientId,
String requiredRoleName) {
log.info("Checking permission for userSub '{}', targetClient '{}', requiredRole '{}'",
userSub, targetServiceClientId, requiredRoleName);
// Step 1: Get the internal UUID of the target Keycloak client
return this.keycloakAdminClient.getClientInternalId(targetServiceClientId)
.flatMap(clientInternalId -> {
// Step 2: Get the user's effective client roles for that client's internal ID
log.debug("Found internal client ID: {}. Fetching roles for userSub: {}",
clientInternalId, userSub);
return this.keycloakAdminClient.getUserEffectiveClientRoles(userSub,
clientInternalId);
}).map(roles -> {
// Step 3: Check if the required role name is present in the list of roles
boolean hasRole = roles.stream()
.anyMatch(role -> requiredRoleName.equals(role.getName()));
if (hasRole) {
log.info("Permission GRANTED for userSub '{}' to role '{}' on client '{}'",
userSub, requiredRoleName, targetServiceClientId);
} else {
log.warn(
"Permission DENIED for userSub '{}' to role '{}' on"
+ " client '{}'. User roles: {}",
userSub, requiredRoleName, targetServiceClientId,
roles.stream().map(KeycloakRoleRepresentation::getName).toList());
}
return hasRole;
})
.doOnError(e -> log.error(
"Error during permission check for userSub '{}', "
+ "client '{}', role '{}': {}",
userSub, targetServiceClientId, requiredRoleName, e.getMessage(), e))
// If any error occurs during the admin calls (client not found, user roles fetch
// fail), treat as permission denied.
// Or, you could throw a specific PermissionCheckException to be handled by
// GlobalExceptionHandler.
.onErrorResume(e -> {
log.error("Permission check failed due to an error, defaulting to DENIED: {}",
e.getMessage());
return Mono.just(false); // Default to false on error to be safe
});
}
@Override
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
log.info("Registering new user with email: {}", registrationRequest.getEmail());
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
public Mono<Boolean> checkResourcePermission(
final String reqAccessToken, final String clientId,
final String resourceUri, final String scope
) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket");
formData.add("permission_resource_format", "uri");
formData.add("permission_resource_matching_uri", "true");
formData.add("audience", clientId);
formData.add("permission", resourceUri + "#" + scope);
return this.webClient.post().uri(getTokenEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.header("Authorization", "Bearer " + reqAccessToken)
.body(BodyInserters.fromFormData(formData)).retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errBody -> {
log.debug("UMA ticket returns status {} with response: {}",
clientResponse.statusCode(), errBody);
return Mono.error(new PermissionCheckException(
"UMA ticket verification failed. Status: "
+ clientResponse.statusCode()));
}))
.onStatus(HttpStatusCode::is5xxServerError,
clientResponse -> clientResponse.bodyToMono(String.class)
.flatMap(errorBody -> {
log.error(
"Keycloak uma ticket verification failed with"
+ " status {}: {}",
clientResponse.statusCode(), errorBody);
return Mono.error(new ServiceUnavailableException(
"UMA ticket verification failed. Status: "
+ clientResponse.statusCode()));
}))
.bodyToMono(String.class)
.flatMap(response -> {
log.debug("UMA ticket verification successful for access token {}: {}",
reqAccessToken, response);
return Mono.just(true);
}).doOnError(error -> log.error("Error during uma ticket verification: {}",
error.getMessage()))
.onErrorResume(PermissionCheckException.class, e -> Mono.just(false));
}
@Override
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
OrganizationRepresentation newOrg = new OrganizationRepresentation();
newOrg.setName(request.getName());
newOrg.setDescription(request.getDescription());
newOrg.setAttributes(request.getAttributes());
newOrg.setEnabled(true); // Default to enabled
if (request.getDomains() != null) {
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
.stream()
.map(domainName -> {
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);
}
@Override
public Mono<List<OrganizationResponse>> getOrganizations() {
return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream()
.map(this::toOrganizationResponse).collect(Collectors.toList()));
}
@Override
public Mono<OrganizationResponse> getOrganizationById(String orgId) {
return this.keycloakAdminClient.getOrganizationById(orgId)
.map(this::toOrganizationResponse);
}
@Override
public Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request) {
return this.keycloakAdminClient.getOrganizationById(orgId).flatMap(existingOrg -> {
// Update fields from the request
existingOrg.setDescription(request.getDescription());
existingOrg.setAttributes(request.getAttributes());
// Update domains based on the new list of names
if (request.getDomains() != null) {
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
.stream()
.map(domainName -> {
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);
});
}
@Override
public Mono<Void> deleteOrganization(String orgId) {
return this.keycloakAdminClient.deleteOrganization(orgId);
}
@Override
public Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request) {
KeycloakInvitationRequest invitation = new KeycloakInvitationRequest();
invitation.setEmail(request.getEmail());
invitation.setFirstName(request.getFirstName());
invitation.setLastName(request.getLastName());
// Keycloak handles creating the user if they don't exist.
return this.keycloakAdminClient.inviteUserToOrganization(orgId, invitation);
}
@Override
public Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId) {
return this.keycloakAdminClient.getOrganizationMembers(orgId).map(userList -> userList
.stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList()));
}
@Override
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
return this.keycloakAdminClient.addMemberToOrganization(orgId, userId);
}
@Override
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
return this.keycloakAdminClient.removeMemberFromOrganization(orgId, userId);
}
@Override
public Mono<GroupResponse> createSubGroup(
final String parentGroupId, final CreateGroupRequest request
) {
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()));
}
@Override
public Mono<GroupResponse> getGroupById(final String groupId) {
return this.keycloakAdminClient.getGroupById(groupId).map(this::toGroupResponse);
}
@Override
public Mono<List<GroupResponse>> listSubGroups(final String parentGroupId) {
return this.keycloakAdminClient.listSubGroups(parentGroupId)
.map(groupList -> groupList.stream()
.map(this::toGroupResponse)
.collect(Collectors.toList()));
}
@Override
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
final var groupUpdate = new GroupRepresentation();
groupUpdate.setAttributes(request.getAttributes());
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
}
@Override
public Mono<Void> deleteGroup(final String groupId) {
return this.keycloakAdminClient.deleteGroup(groupId);
}
@Override
public Mono<Void> addMemberToGroup(final String groupId, final String userId) {
return this.keycloakAdminClient.addMemberToGroup(groupId, userId);
}
@Override
public Mono<List<OrganizationMemberResponse>> getGroupMembers(final String groupId) {
return this.keycloakAdminClient.getGroupMembers(groupId)
.map(userList -> userList.stream()
.map(this::toOrganizationMemberResponse)
.collect(Collectors.toList()));
}
@Override
public Mono<Void> removeMemberFromGroup(final String groupId, final String userId) {
return this.keycloakAdminClient.removeMemberFromGroup(groupId, userId);
}
// --- Add DTO Mapper for Group ---
private GroupResponse toGroupResponse(final GroupRepresentation keycloakGroup) {
if (keycloakGroup == null) {
return null;
}
return new GroupResponse(
keycloakGroup.getId(),
keycloakGroup.getName(),
keycloakGroup.getPath(),
keycloakGroup.getDescription(),
keycloakGroup.getAttributes()
);
}
// --- DTO Mapper Helper Methods ---
private OrganizationResponse toOrganizationResponse(
OrganizationRepresentation keycloakOrg) {
if (keycloakOrg == null) {
return null;
}
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.isEnabled(),
keycloakOrg.getAttributes());
}
private OrganizationMemberResponse toOrganizationMemberResponse(
UserRepresentation keycloakUser) {
if (keycloakUser == null) {
return null;
}
return new OrganizationMemberResponse(keycloakUser.getId(), keycloakUser.getUsername(),
keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(),
keycloakUser.isEnabled());
}
}
+46 -14
View File
@@ -7,7 +7,7 @@ spring:
name: auth-service
cloud:
consul:
host: ${CONSUL_HOST:localhost}
host: ${CONSUL_HOST:consul}
port: ${CONSUL_PORT:8500}
config:
import-check:
@@ -15,19 +15,14 @@ spring:
# Keycloak Configuration (adjust values as needed)
keycloak:
server-url: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash)
realm: ${KEYCLOAK_AUTH_REALM:myrealm} # The realm name you are using
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:test-client} # Client ID created in Keycloak for this service
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:7Xyh7M6Tc1FvwznY265KcLzcmXoWmjs6} # Client Secret from Keycloak (use secrets management!)
# Grant type specific settings (ROPC for /login)
grant-type: password
# Configuration for auth-service's OWN client to call Keycloak Admin API
# This client needs service account roles like 'view-clients', 'view-users', 'query-groups'
keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://keycloak.dev.localhost:8000} # Base URL of your Keycloak instance (NO trailing slash)
keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://keycloak.dev.localhost:8000} # 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!)
admin-account-username: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-account} # an admin account for admin operations
admin-account-password: ${KEYCLOAK_AUTH_ADMIN_SECRET:6B8KWFQDPOoj88V7xnMYoQIgj9vVfuvw} # Secret for this admin account
auth-service:
admin-client:
client-id: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-admin} # A SEPARATE client ID in Keycloak for admin operations
client-secret: ${KEYCLOAK_AUTH_ADMIN_SECRET:qDqbVqfmCmG6SSrAW3pyqXEpu0bOxWJj} # Secret for this admin client
permission-mapping:
# Rules to map incoming request path prefixes to Keycloak Client IDs (representing services)
# The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
@@ -39,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:rabbitmq}
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
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
@@ -3,7 +3,7 @@ package com.cleverthis.authservice;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.PermissionMappingConfig;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.controller.AuthController;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
@@ -12,7 +12,6 @@ import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService;
import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
@@ -36,8 +35,8 @@ import reactor.core.publisher.Mono;
*/
@WebFluxTest(AuthController.class)
@Import({GlobalExceptionHandler.class,
PermissionMappingConfig.class,
FallbackPermissionMapLookupService.class})
PermissionMappingConfigProperties.class,
FallbackPermissionMapLookupService.class})
class AuthControllerTest {
@Autowired
@@ -47,7 +46,7 @@ class AuthControllerTest {
private IdentityManagerService identityManagerService;
@MockitoBean
private PermissionMappingConfig permissionMappingConfig;
private PermissionMappingConfigProperties permissionMappingConfigProperties;
// required by the fallback permission mapping lookup service
// provide a mocked one
@@ -78,22 +77,22 @@ class AuthControllerTest {
"test@example.com", true, "testuser", List.of("groupA"));
// Setup mock PermissionMappingConfig - Default to having some rules
PermissionMappingConfig.Rule rule1 = new PermissionMappingConfig.Rule();
PermissionMappingConfigProperties.Rule rule1 = new PermissionMappingConfigProperties.Rule();
rule1.setPathPrefix("/serviceA/");
rule1.setKeycloakClientId("serviceA-client");
PermissionMappingConfig.Rule rule2 = new PermissionMappingConfig.Rule();
PermissionMappingConfigProperties.Rule rule2 = new PermissionMappingConfigProperties.Rule();
rule2.setPathPrefix("/serviceB/items/");
rule2.setKeycloakClientId("serviceB-client");
List<PermissionMappingConfig.Rule> rules = new ArrayList<>();
List<PermissionMappingConfigProperties.Rule> rules = new ArrayList<>();
rules.add(rule1);
rules.add(rule2);
// Mock getRules() to return the list
when(this.permissionMappingConfig.getRules()).thenReturn(rules);
when(this.permissionMappingConfigProperties.getRules()).thenReturn(rules);
// Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
// specific tests
when(this.permissionMappingConfig.getDefaultKeycloakClientId()).thenReturn(null);
when(this.permissionMappingConfigProperties.getDefaultKeycloakClientId()).thenReturn(null);
}
// --- /login Tests ---
@@ -177,6 +176,8 @@ class AuthControllerTest {
// --- /auth (ForwardAuth) Tests ---
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
@Test
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
String userToken = "user-token-granted";
@@ -185,8 +186,9 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "GET_API_DATA")).thenReturn(Mono.just(true));
when(this.identityManagerService.checkResourcePermission(
userToken, "serviceA-client", "/api/data", "rest:read"
)).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -198,24 +200,6 @@ class AuthControllerTest {
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionGranted_PathWith_uuid_ReturnsOkWithUserHeaders() {
String userToken = "user-token-uuid";
String originalMethod = "PUT";
String originalUri = "/serviceA/api/items/123e4567-e89b-12d3-a456-426614174000";
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "PUT_API_ITEMS_BY_ID")).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectBody().isEmpty();
}
@Test
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
String validToken = "valid-token-fw-nogroups";
@@ -226,8 +210,10 @@ class AuthControllerTest {
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "GET_API_NOGROUPS")).thenReturn(Mono.just(true));
when(this.identityManagerService.checkResourcePermission(
validToken, "serviceA-client",
"/api/nogroups", "rest:read"
)).thenReturn(Mono.just(true));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
@@ -240,15 +226,17 @@ class AuthControllerTest {
}
@Test
void forwardAuth_PermissionDenied_ReturnsForbidden() {
void forwardAuth_PermissionDenied_NoMatchingRole() {
String userToken = "user-token-denied";
String originalMethod = "POST";
String originalUri = "/serviceA/api/admin";
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "POST_API_ADMIN")).thenReturn(Mono.just(false));
when(this.identityManagerService.checkResourcePermission(
userToken, "serviceA-client",
"/restricted/action", "rest:create"
)).thenReturn(Mono.just(false));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
@@ -310,25 +298,6 @@ class AuthControllerTest {
.isForbidden(); // Controller logic for no rule match
}
@Test
void forwardAuth_PermissionCheckServiceError_ReturnsInternalServerError() {
String userToken = "user-token-service-error";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data";
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
when(this.identityManagerService.checkUserClientRolePermission("user-sub-123",
"serviceA-client", "GET_API_DATA")).thenReturn(
Mono.error(new ServiceUnavailableException("Keycloak admin client error")));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.is4xxClientError();
}
@Test
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
@@ -0,0 +1,209 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.IntraOrganizationGroupController;
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.ResourceNotFoundException;
import com.cleverthis.authservice.service.IdentityManagerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Unit tests for the IntraOrganizationGroupController. This class tests the API
* layer for sub-group management, mocking the service layer.
*/
@WebFluxTest(IntraOrganizationGroupController.class)
@Import(GlobalExceptionHandler.class)
public class IntraOrganizationGroupControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
private GroupResponse mockGroupResponse;
/**
* Sets up mock data before each test.
*/
@BeforeEach
void setUp() {
mockGroupResponse = new GroupResponse("group-id-eng", "engineering",
"/tenant-id-123/engineering",
"Engineering Department", Map.of("cost-center", List.of("ENG-101")));
}
/**
* Tests successful creation of a sub-group.
*/
@Test
void createSubGroup_Success_ReturnsCreated() {
String parentGroupId = "org-id-123";
CreateGroupRequest request = new CreateGroupRequest("engineering",
"Engineering Department", null);
when(identityManagerService.createSubGroup(eq(parentGroupId), any(CreateGroupRequest.class)))
.thenReturn(Mono.just(mockGroupResponse));
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
.exchange().expectStatus().isCreated()
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
}
/**
* Tests creation of a sub-group with invalid input.
*/
@Test
void createSubGroup_InvalidInput_ReturnsBadRequest() {
String parentGroupId = "org-id-123";
// Blank name is invalid
CreateGroupRequest request = new CreateGroupRequest("", null, null);
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isBadRequest();
}
/**
* Tests successful listing of sub-groups.
*/
@Test
void listSubGroups_Success_ReturnsListOfGroups() {
String parentGroupId = "org-id-123";
when(identityManagerService.listSubGroups(parentGroupId))
.thenReturn(Mono.just(Collections.singletonList(mockGroupResponse)));
webTestClient.get().uri("/groups/{parentGroupId}/children", parentGroupId).exchange()
.expectStatus().isOk()
.expectBodyList(GroupResponse.class).hasSize(1);
}
/**
* Tests successful retrieval of a group by its ID.
*/
@Test
void getGroupById_Exists_ReturnsGroup() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
when(identityManagerService.getGroupById(groupId)).thenReturn(Mono.just(mockGroupResponse));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}", parentGroupId, groupId)
.exchange().expectStatus().isOk()
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
}
/**
* Tests retrieval of a non-existent group.
*/
@Test
void getGroupById_NotFound_ReturnsNotFound() {
String parentGroupId = "org-id-123";
String groupId = "non-existent-group";
when(identityManagerService.getGroupById(groupId))
.thenReturn(Mono.error(new ResourceNotFoundException("Group not found")));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId).exchange()
.expectStatus().isNotFound();
}
/**
* Tests successful update of a group.
*/
@Test
void updateGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
UpdateGroupRequest request = new UpdateGroupRequest("An updated description", null);
when(identityManagerService.updateGroup(eq(groupId), any(UpdateGroupRequest.class)))
.thenReturn(Mono.empty());
webTestClient.put().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
.exchange().expectStatus().isNoContent();
}
/**
* Tests successful deletion of a group.
*/
@Test
void deleteGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
when(identityManagerService.deleteGroup(groupId)).thenReturn(Mono.empty());
webTestClient.delete().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successfully adding a member to a group.
*/
@Test
void addMemberToGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
AddGroupMemberRequest request = new AddGroupMemberRequest("user-id-to-add");
when(identityManagerService.addMemberToGroup(groupId, request.getUserId())).thenReturn(Mono.empty());
webTestClient.post().uri("/groups/{parentGroupId}/children/{groupId}/members",
parentGroupId, groupId).contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isNoContent();
}
/**
* Tests successful listing of group members.
*/
@Test
void listGroupMembers_Success_ReturnsListOfMembers() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
List<OrganizationMemberResponse> members = List.of(
new OrganizationMemberResponse("user-id-123", "abed.spring",
"Abed", "Spring", "abed@test.com", true));
when(identityManagerService.getGroupMembers(groupId)).thenReturn(Mono.just(members));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}/members",
parentGroupId, groupId).exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class).isEqualTo(members);
}
/**
* Tests successfully removing a member from a group.
*/
@Test
void removeMemberFromGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
String userId = "user-to-remove";
when(identityManagerService.removeMemberFromGroup(groupId, userId)).thenReturn(Mono.empty());
webTestClient.delete()
.uri("/groups/{parentGroupId}/children/{groupId}/members/{userId}",
parentGroupId, groupId, userId)
.exchange().expectStatus().isNoContent();
}
}
@@ -0,0 +1,208 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.OrganizationController;
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.ResourceNotFoundException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.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;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
/**
* Unit tests for the OrganizationController. This class tests the API layer for organization
* management, mocking the service layer.
*/
@WebFluxTest(OrganizationController.class)
@Import(GlobalExceptionHandler.class)
public class OrganizationControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
private OrganizationResponse mockOrgResponse;
private List<OrganizationResponse> mockOrgResponseList;
/**
* Sets up mock data before each test.
*/
@BeforeEach
void setUp() {
// Create the correct domain representation object
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")));
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
}
/**
* Tests successful creation of an organization.
*/
@Test
void createOrganization_Success_ReturnsCreatedWithBody() {
CreateOrganizationRequest request = new CreateOrganizationRequest("test-org",
List.of("test-org.com"), "A test org", null);
when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class)))
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isCreated()
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
}
/**
* Tests organization creation with invalid input, expecting a Bad Request.
*/
@Test
void createOrganization_InvalidInput_ReturnsBadRequest() {
CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null);
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
/**
* Tests successful retrieval of all organizations.
*/
@Test
void getOrganizations_Success_ReturnsListOfOrgs() {
when(this.identityManagerService.getOrganizations())
.thenReturn(Mono.just(this.mockOrgResponseList));
this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk()
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
}
/**
* Tests successful retrieval of a single organization by its ID.
*/
@Test
void getOrganizationById_Exists_ReturnsOrg() {
when(this.identityManagerService.getOrganizationById("org-id-123"))
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isOk().expectBody(OrganizationResponse.class)
.isEqualTo(this.mockOrgResponse);
}
/**
* Tests retrieval of a non-existent organization, expecting Not Found.
*/
@Test
void getOrganizationById_NotFound_ReturnsNotFound() {
when(this.identityManagerService.getOrganizationById("non-existent-id"))
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange()
.expectStatus().isNotFound();
}
/**
* Tests successful update of an organization.
*/
@Test
void updateOrganization_Success_ReturnsNoContent() {
UpdateOrganizationRequest request =
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
when(this.identityManagerService.updateOrganization(eq("org-id-123"),
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successful deletion of an organization.
*/
@Test
void deleteOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty());
this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isNoContent();
}
/**
* Tests successful invitation of a user to an organization.
*/
@Test
void inviteUserToOrganization_Success_ReturnsOk() {
InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User");
when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"),
any(InviteUserRequest.class))).thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isOk();
}
/**
* Tests successful retrieval of organization members.
*/
@Test
void getOrganizationMembers_Success_ReturnsListOfMembers() {
List<OrganizationMemberResponse> members =
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
"test@test.com", true));
when(this.identityManagerService.getOrganizationMembers("org-id-123"))
.thenReturn(Mono.just(members));
this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
.isEqualTo(members);
}
/**
* Tests successfully adding a member to an organization.
*/
@Test
void addMemberToOrganization_Success_ReturnsNoContent() {
AddMemberRequest request = new AddMemberRequest("user-to-add-id");
when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id"))
.thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successfully removing a member from an organization.
*/
@Test
void removeMemberFromOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.removeMemberFromOrganization("org-id-123",
"user-to-remove-id")).thenReturn(Mono.empty());
this.webTestClient.delete()
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
.exchange().expectStatus().isNoContent();
}
}
@@ -0,0 +1,40 @@
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.context.properties.EnableConfigurationProperties;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@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",
"keycloak.client-id=test-service",
"keycloak.client-secret=secret-here",
"keycloak.admin-account-username=admin",
"keycloak.admin-account-password=admin-passwd"
})
class KeycloakClientConfigPropertiesTest {
@Autowired
private KeycloakClientConfigProperties config;
@Test
void testConfigParsing() {
assertEquals("https://keycloak.example.com", this.config.getKeycloakHost());
assertEquals("https://keycloak-admin.example.com", this.config.getKeycloakAdminHost());
assertEquals("cm-test", this.config.getRealm());
assertEquals("test-service", this.config.getClientId());
assertEquals("secret-here", this.config.getClientSecret());
assertEquals("admin", this.config.getAdminAccountUsername());
assertEquals("admin-passwd", this.config.getAdminAccountPassword());
}
}
@@ -0,0 +1,12 @@
package com.cleverthis.authservice.config;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import org.junit.jupiter.api.Test;
class KeycloakClientConfigTest {
@Test
void testConstructor() {
assertDoesNotThrow(KeycloakClientConfig::new);
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.config;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
public class WebClientConfigTest {
private final WebClientConfig config = new WebClientConfig();
@Test
public void testKeycloakWebClientNotNull() {
assertNotNull(this.config.keycloakWebClient(), "WebClient bean should not be null");
}
}
@@ -0,0 +1,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());
}
}
}
@@ -0,0 +1,145 @@
package com.cleverthis.authservice.exception;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
class GlobalExceptionHandlerTest {
private GlobalExceptionHandler globalExceptionHandler;
@BeforeEach
void setUp() {
this.globalExceptionHandler = new GlobalExceptionHandler();
}
@Test
void testHandleAuthenticationException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Authentication failed";
final var exception = new AuthenticationException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleAuthenticationException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Authentication Failed", result.getTitle());
}
@Test
void testHandleTokenVerificationException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Token verification failed";
final var exception = new TokenVerificationException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleTokenVerificationException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Token Verification Failed", result.getTitle());
}
@Test
void testHandleLogoutException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Logout failed";
final var exception = new LogoutException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleLogoutException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Logout Failed", result.getTitle());
}
@Test
void testHandlePermissionCheckException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Access denied";
final var exception = new PermissionCheckException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handlePermissionCheckException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.FORBIDDEN.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Access Denied", result.getTitle());
}
@Test
void testHandleServiceUnavailableException_ReturnsProblemDetail() {
// Arrange
final var exception = new ServiceUnavailableException("test");
// Act
final var result = this.globalExceptionHandler.handleServiceUnavailableException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.SERVICE_UNAVAILABLE.value(), result.getStatus());
assertEquals("An external service required for authorization is currently unavailable.",
result.getDetail());
assertEquals("Service Unavailable", result.getTitle());
}
@Test
void testHandleUserAlreadyExistsException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "User already exists";
final var exception = new UserAlreadyExistsException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleUserAlreadyExistsException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.CONFLICT.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("User Registration Conflict", result.getTitle());
}
@Test
void testHandleRegistrationException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Registration failed";
final var exception = new RegistrationException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleRegistrationException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("User Registration Failed", result.getTitle());
}
@Test
void testHandleGenericException_ReturnsProblemDetail() {
// Arrange
final var exception = new Exception("Internal error");
// Act
final var result = this.globalExceptionHandler.handleGenericException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
assertEquals("An internal error occurred.", result.getDetail());
assertEquals("Internal Server Error", result.getTitle());
}
}
@@ -4,7 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.PermissionMappingConfig;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.ecwid.consul.v1.ConsulClient;
import com.ecwid.consul.v1.Response;
import com.ecwid.consul.v1.kv.model.GetValue;
@@ -55,9 +55,10 @@ class FallbackPermissionMapLookupServiceTest {
assertNull(service.getConfig()); // fallback is null
// has json content
when(value.getDecodedValue())
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfig()));
.thenReturn(
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul();
assertEquals(new PermissionMappingConfig(), service.getConfig());
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
}
/**
@@ -66,7 +67,7 @@ class FallbackPermissionMapLookupServiceTest {
*/
@Test
void testGetDefaultKeycloakClientId() {
final var defaultConfig = new PermissionMappingConfig();
final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setDefaultKeycloakClientId("default-client-id");
final var service = new FallbackPermissionMapLookupService(
@@ -76,45 +77,22 @@ class FallbackPermissionMapLookupServiceTest {
}
@Test
void testMatchClientIdByPath() {
final var defaultConfig = new PermissionMappingConfig();
defaultConfig.setRules(List.of(
PermissionMappingConfig.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfig.Rule.builder()
.keycloakClientId("service-B").pathPrefix("/b/").build()
));
void testMatchRuleByPath() {
final var defaultConfig = new PermissionMappingConfigProperties();
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 PermissionMappingConfig();
defaultConfig.setRules(List.of(
PermissionMappingConfig.Rule.builder()
.keycloakClientId("service-A").pathPrefix("/a/").build(),
PermissionMappingConfig.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"),
() -> {}
);
}
}
@@ -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();
}
}
@@ -0,0 +1,634 @@
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 KeycloakAdminReactiveClient adminClient;
private KeycloakIdentityManagerService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void setup() throws IOException {
// isolate requests by creating one mock server per test
this.mockBackEnd = new MockWebServer();
this.mockBackEnd.start();
final var webClient = WebClient.builder().build();
final var configProperties = KeycloakClientConfigProperties.builder()
.keycloakHost("http://localhost:" + this.mockBackEnd.getPort() + "/")
.realm("test-realm")
.clientId("test-client")
.clientSecret("client-password")
.build();
this.adminClient = Mockito.mock(KeycloakAdminReactiveClient.class);
this.service = Mockito.spy(new KeycloakIdentityManagerService(
webClient, this.adminClient, configProperties));
}
@AfterEach
void tearDown() throws IOException {
this.mockBackEnd.shutdown();
}
@Test
void login_success() throws JsonProcessingException, InterruptedException {
// Arrange
final var username = "test-user";
final var password = "test-password";
final var mockTokenResponse = new TokenResponse("access-token", 3600, 3600,
"refresh-token", "bearer", "id-token", 0, "session-id", "openid");
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(this.objectMapper.writeValueAsString(mockTokenResponse))
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.login(username, password);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> response.getAccessToken().equals("access-token") &&
response.getRefreshToken().equals("refresh-token"))
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
}
@Test
void login_failure_invalidCredentials() {
// Arrange
final var username = "wrong-user";
final var password = "wrong-password";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(401)
.setBody(
"{\"error\":\"invalid_grant\",\"error_description\":\"Invalid user credentials\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.login(username, password);
// Assert
StepVerifier.create(result)
.expectErrorMatches(throwable -> throwable instanceof AuthenticationException &&
throwable.getMessage()
.contains("Login failed: Invalid credentials or Keycloak error."))
.verify();
}
@Test
void verifyToken_success() throws JsonProcessingException, InterruptedException {
// Arrange
final var accessToken = "valid-access-token";
final var mockVerificationResponse = VerificationResponse.builder()
.clientId("test-client-id")
.active(true)
.emailVerified(true)
.preferredUsername("test-user")
.sub("user-sub")
.build();
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.verifyToken(accessToken);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "test-client-id".equals(response.getClientId()) &&
"test-user".equals(response.getPreferredUsername()) &&
Boolean.TRUE.equals(response.getEmailVerified()))
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token/introspect",
recordedRequest.getPath());
}
@Test
void verifyToken_failure_inactiveToken() throws JsonProcessingException {
// Arrange
final var accessToken = "inactive-access-token";
final var mockVerificationResponse = VerificationResponse.builder()
.sub("user-sub")
.active(false)
.username("test-user")
.build();
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.verifyToken(accessToken);
// Assert
StepVerifier.create(result)
.expectErrorMatches(throwable -> throwable instanceof TokenVerificationException &&
throwable.getMessage().contains("Token is invalid or expired."))
.verify();
}
@Test
void logout_success() throws InterruptedException {
// Arrange
final var refreshToken = "valid-refresh-token";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.logout(refreshToken);
// Assert
StepVerifier.create(result)
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/revoke",
recordedRequest.getPath());
}
@Test
void logout_failure_invalidToken() {
// Arrange
final var refreshToken = "invalid-refresh-token";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(400)
.setBody(
"{\"error\":\"invalid_request\",\"error_description\":\"Invalid token or token has been revoked\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.logout(refreshToken);
// Assert
StepVerifier.create(result)
.verifyComplete(); // Expecting no error due to handling of 400 as a soft failure
}
@Test
void checkResourcePermission_success() throws InterruptedException {
// Arrange
final var accessToken = "valid-token";
final var clientId = "resource-client";
final var resourceUri = "/resources/123";
final var scope = "read";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody("{\"response\":\"success\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result =
this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
// Assert
StepVerifier.create(result)
.expectNext(true)
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
assertEquals("Bearer valid-token", recordedRequest.getHeader("Authorization"));
}
@Test
void checkResourcePermission_permissionDenied() {
// Arrange
final var accessToken = "valid-token";
final var clientId = "resource-client";
final var resourceUri = "/resources/123";
final var scope = "read";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(403)
.setBody("{\"error\":\"permission_denied\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result =
this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
// Assert
StepVerifier.create(result)
.expectNext(false)
.verifyComplete();
}
@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