Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cdde6ec922
|
|||
|
bed3de76dd
|
|||
|
465c7fd8ec
|
|||
|
6f0af690f2
|
|||
| 0fc59eef0e | |||
|
eaa14e7101
|
|||
| b764c6f12b |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
Submodule
+1
Submodule .devcontainer/identity-management added at 12d8e11f3b
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule ".devcontainer/identity-management"]
|
||||
path = .devcontainer/identity-management
|
||||
url = ../identity-management.git
|
||||
+3
-3
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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 = "";
|
||||
}
|
||||
+36
-3
@@ -20,10 +20,10 @@ import org.springframework.validation.annotation.Validated;
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "auth-service.permission-mapping")
|
||||
@Data
|
||||
@Validated
|
||||
public class PermissionMappingConfig {
|
||||
@Validated
|
||||
public class PermissionMappingConfigProperties {
|
||||
|
||||
@Valid
|
||||
@Valid
|
||||
private List<Rule> rules = new ArrayList<>();
|
||||
private String defaultKeycloakClientId; // Optional default
|
||||
|
||||
@@ -39,5 +39,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;
|
||||
}
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
|
||||
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* REST Controller for managing fine-grained groups (departments, teams) within
|
||||
* the context of a parent organization.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/groups")
|
||||
@Slf4j
|
||||
public class IntraOrganizationGroupController {
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
|
||||
/**
|
||||
* Constructs the controller with the required service dependency.
|
||||
*
|
||||
* @param identityManagerService The service handling identity management logic.
|
||||
*/
|
||||
@Autowired
|
||||
public IntraOrganizationGroupController(final IdentityManagerService identityManagerService) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sub-group within a parent organization.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param request The request body containing details for the new group.
|
||||
* @return A Mono emitting the created group's representation.
|
||||
*/
|
||||
@PostMapping("/{orgId}/children")
|
||||
public Mono<ResponseEntity<GroupResponse>> createSubGroup(@PathVariable final String orgId,
|
||||
@Valid @RequestBody final CreateGroupRequest request) {
|
||||
log.info("Received request to create sub-group with name '{}' in organization '{}'",
|
||||
request.getName(), orgId);
|
||||
return this.identityManagerService.createSubGroup(orgId, request)
|
||||
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all sub-groups within a parent organization.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @return A Mono emitting a list of group representations.
|
||||
*/
|
||||
@GetMapping("/{orgId}/children")
|
||||
public Mono<ResponseEntity<List<GroupResponse>>> listSubGroups(
|
||||
@PathVariable final String orgId) {
|
||||
log.info("Received request to list sub-groups for organization '{}'", orgId);
|
||||
return this.identityManagerService.listSubGroups(orgId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single sub-group by its ID.
|
||||
*
|
||||
* @param orgId The ID of the parent organization (for context).
|
||||
* @param groupId The ID of the sub-group to retrieve.
|
||||
* @return A Mono emitting the group's representation.
|
||||
*/
|
||||
@GetMapping("/{orgId}/children/{groupId}")
|
||||
public Mono<ResponseEntity<GroupResponse>> getSubGroupById(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId) {
|
||||
log.info("Received request to get sub-group '{}' from organization '{}'", groupId, orgId);
|
||||
return this.identityManagerService.getGroupById(groupId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization (for context).
|
||||
* @param groupId The ID of the sub-group to update.
|
||||
* @param request The request body with fields to update.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@PutMapping("/{orgId}/children/{groupId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> updateSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId,
|
||||
@Valid @RequestBody final UpdateGroupRequest request) {
|
||||
log.info("Received request to update sub-group '{}' in organization '{}'", groupId, orgId);
|
||||
return this.identityManagerService.updateGroup(groupId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization (for context).
|
||||
* @param groupId The ID of the sub-group to delete.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}/children/{groupId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> deleteSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId) {
|
||||
log.info("Received request to delete sub-group '{}' from organization '{}'",
|
||||
groupId, orgId);
|
||||
return this.identityManagerService.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
// --- Sub-Group Membership Management ---
|
||||
|
||||
/**
|
||||
* Adds an existing organization member to a sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param groupId The ID of the sub-group.
|
||||
* @param request The request body containing the user's ID.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@PostMapping("/{orgId}/children/{groupId}/members")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> addMemberToSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId,
|
||||
@Valid @RequestBody final AddGroupMemberRequest request) {
|
||||
log.info("Received request to add user '{}' to sub-group '{}' in organization '{}'",
|
||||
request.getUserId(), groupId, orgId);
|
||||
return this.identityManagerService.addMemberToGroup(groupId, request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of a specific sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param groupId The ID of the sub-group.
|
||||
* @return A Mono emitting a list of organization member representations.
|
||||
*/
|
||||
@GetMapping("/{orgId}/children/{groupId}/members")
|
||||
public Mono<ResponseEntity<List<OrganizationMemberResponse>>>
|
||||
listSubGroupMembers(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId) {
|
||||
log.info("Received request to list members of sub-group '{}' in organization '{}'",
|
||||
groupId, orgId);
|
||||
return this.identityManagerService.getGroupMembers(groupId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from a sub-group.
|
||||
*
|
||||
* @param orgId The ID of the parent organization.
|
||||
* @param groupId The ID of the sub-group.
|
||||
* @param userId The ID of the user to remove.
|
||||
* @return A Mono completing with a 204 No Content response.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}/children/{groupId}/members/{userId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> removeMemberFromSubGroup(@PathVariable final String orgId,
|
||||
@PathVariable final String groupId, @PathVariable final String userId) {
|
||||
log.info("Received request to remove user '{}' from sub-group '{}' in organization '{}'",
|
||||
userId, groupId, orgId);
|
||||
return this.identityManagerService.removeMemberFromGroup(groupId, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.cleverthis.authservice.controller;
|
||||
|
||||
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
|
||||
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* REST Controller for handling Organization related requests.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/organizations")
|
||||
@Slf4j
|
||||
public class OrganizationController {
|
||||
|
||||
private final IdentityManagerService identityManagerService;
|
||||
|
||||
@Autowired
|
||||
public OrganizationController(IdentityManagerService identityManagerService) {
|
||||
this.identityManagerService = identityManagerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Organization. Requires administrative privileges, which should be enforced by
|
||||
* the /auth endpoint.
|
||||
*/
|
||||
@PostMapping
|
||||
public Mono<ResponseEntity<OrganizationResponse>> createOrganization(
|
||||
@Valid @RequestBody CreateOrganizationRequest request) {
|
||||
log.info("Received request to create organization with name: {}", request.getName());
|
||||
return this.identityManagerService.createOrganization(request)
|
||||
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of all Organizations.
|
||||
*/
|
||||
@GetMapping
|
||||
public Mono<ResponseEntity<List<OrganizationResponse>>> getOrganizations() {
|
||||
log.info("Received request to list all organizations");
|
||||
return this.identityManagerService.getOrganizations().map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single Organization by its ID.
|
||||
*/
|
||||
@GetMapping("/{orgId}")
|
||||
public Mono<ResponseEntity<OrganizationResponse>> getOrganizationById(
|
||||
@PathVariable String orgId) {
|
||||
log.info("Received request to get organization by ID: {}", orgId);
|
||||
return this.identityManagerService.getOrganizationById(orgId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing Organization.
|
||||
*/
|
||||
@PutMapping("/{orgId}")
|
||||
public Mono<ResponseEntity<Void>> updateOrganization(@PathVariable String orgId,
|
||||
@Valid @RequestBody UpdateOrganizationRequest request) {
|
||||
log.info("Received request to update organization ID: {}", orgId);
|
||||
return this.identityManagerService.updateOrganization(orgId, request)
|
||||
.thenReturn(ResponseEntity.noContent().<Void>build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an Organization.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> deleteOrganization(@PathVariable String orgId) {
|
||||
log.info("Received request to delete organization ID: {}", orgId);
|
||||
return this.identityManagerService.deleteOrganization(orgId);
|
||||
}
|
||||
|
||||
// --- Membership Management ---
|
||||
|
||||
/**
|
||||
* Invites a user to join an Organization via email.
|
||||
*/
|
||||
@PostMapping("/{orgId}/invitations")
|
||||
public Mono<ResponseEntity<Void>> inviteUserToOrganization(@PathVariable String orgId,
|
||||
@Valid @RequestBody InviteUserRequest request) {
|
||||
log.info("Received request to invite user with email {} to organization ID: {}",
|
||||
request.getEmail(), orgId);
|
||||
return this.identityManagerService.inviteUserToOrganization(orgId, request)
|
||||
.thenReturn(ResponseEntity.ok().<Void>build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all members of an Organization.
|
||||
*/
|
||||
@GetMapping("/{orgId}/members")
|
||||
public Mono<ResponseEntity<List<OrganizationMemberResponse>>> getOrganizationMembers(
|
||||
@PathVariable String orgId) {
|
||||
log.info("Received request to get members of organization ID: {}", orgId);
|
||||
return this.identityManagerService.getOrganizationMembers(orgId).map(ResponseEntity::ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an existing user to an Organization.
|
||||
*/
|
||||
@PostMapping("/{orgId}/members")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> addMemberToOrganization(@PathVariable String orgId,
|
||||
@Valid @RequestBody AddMemberRequest request) {
|
||||
log.info("Received request to add user ID {} to organization ID: {}", request.getUserId(),
|
||||
orgId);
|
||||
return this.identityManagerService.addMemberToOrganization(orgId, request.getUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from an Organization.
|
||||
*/
|
||||
@DeleteMapping("/{orgId}/members/{userId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> removeMemberFromOrganization(@PathVariable String orgId,
|
||||
@PathVariable String userId) {
|
||||
log.info("Received request to remove user ID {} from organization ID: {}", userId, orgId);
|
||||
return this.identityManagerService.removeMemberFromOrganization(orgId, userId);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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();
|
||||
}
|
||||
}
|
||||
+70
@@ -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;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
|
||||
/**
|
||||
* Data Transfer Object for representing a Keycloak Group when interacting
|
||||
* with the Keycloak Admin API.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class KeycloakGroupRepresentation {
|
||||
private String id;
|
||||
private String name;
|
||||
private String path;
|
||||
private String description; // Note: Description is often stored in attributes
|
||||
private Map<String, List<String>> attributes;
|
||||
private List<KeycloakGroupRepresentation> subGroups;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for creating an invitation request to join an organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakInvitationRequest {
|
||||
|
||||
private String email;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
// You can also specify roles to be redirected to after accepting
|
||||
// private List<String> redirectRoles;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Data Transfer Object for representing a Keycloak User, typically when listing members of an
|
||||
* organization. It includes only the most relevant fields.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class KeycloakUserRepresentation {
|
||||
|
||||
private String id;
|
||||
private String username;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private boolean enabled;
|
||||
private Long createdTimestamp;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for adding an existing user to an Organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class AddMemberRequest {
|
||||
|
||||
@NotBlank(message = "User ID cannot be blank")
|
||||
private String userId; // The Keycloak user ID (sub)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the request body when creating a new Organization.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateOrganizationRequest {
|
||||
|
||||
@NotBlank(message = "Organization name cannot be blank")
|
||||
private String name; // This will become the internal name in Keycloak
|
||||
|
||||
@NotEmpty(message = "At least one organization domain must be provided")
|
||||
private List<String> domains;
|
||||
|
||||
private String description;
|
||||
|
||||
private Map<String, List<String>> attributes; // For custom metadata like displayName
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for inviting a new or existing user to an Organization via email.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class InviteUserRequest {
|
||||
|
||||
@NotBlank(message = "Email cannot be blank")
|
||||
@Email(message = "A valid email address is required")
|
||||
private String email;
|
||||
|
||||
// Optional fields if inviting a brand new user
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
|
||||
// You could also add a list of roles/groups to assign upon joining
|
||||
// private List<String> rolesToAssign;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for representing a member of an Organization. This mirrors Keycloak's UserRepresentation for
|
||||
* relevant fields.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor public class OrganizationMemberResponse {
|
||||
|
||||
private String id;
|
||||
private String username;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private boolean enabled;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import 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;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.cleverthis.authservice.dto.organization;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO for the request body when updating an Organization.
|
||||
* Name is typically not updatable, but description and attributes are.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UpdateOrganizationRequest {
|
||||
private List<String> domains;
|
||||
private String description;
|
||||
private Map<String, List<String>> attributes;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
@ExceptionHandler(ServiceUnavailableException.class) // New Handler
|
||||
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
|
||||
// Error communicating with Keycloak Admin API or other critical backend
|
||||
logger.error("Service unavailable exception: {}", ex); // Log with stack trace
|
||||
this.logger.error("Service unavailable exception: {}", ex); // Log with stack trace
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"An external service required for authorization is currently unavailable.");
|
||||
@@ -99,12 +99,26 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ResourceNotFoundException and translates it to a 404 Not Found response.
|
||||
*
|
||||
* @param ex The ResourceNotFoundException that was thrown.
|
||||
* @return A ProblemDetail object for a 404 response.
|
||||
*/
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||
problemDetail.setTitle("Resource Not Found");
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map {@link Exception} to HTTP 500, Catch-all for other Generic exceptions.
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGenericException(Exception ex) {
|
||||
logger.error("An internal error occurred.: ", ex); // Log the full stack trace
|
||||
this.logger.error("An internal error occurred.: ", ex); // Log the full stack trace
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
|
||||
problemDetail.setTitle("Internal Server Error");
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cleverthis.authservice.exception;
|
||||
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
/**
|
||||
* Custom exception to be thrown when a requested resource is not found.
|
||||
* The @ResponseStatus annotation tells Spring to automatically return a 404 Not Found
|
||||
* status when this exception is thrown from a controller and not handled by an
|
||||
* exception handler. This can simplify the GlobalExceptionHandler.
|
||||
*/
|
||||
@ResponseStatus(value = HttpStatus.NOT_FOUND)
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* Constructs a new ResourceNotFoundException with the specified detail message.
|
||||
*
|
||||
* @param message the detail message.
|
||||
*/
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+14
-25
@@ -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)));
|
||||
}
|
||||
}
|
||||
+473
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+349
-217
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
+238
@@ -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);
|
||||
}
|
||||
}
|
||||
+119
@@ -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());
|
||||
}
|
||||
}
|
||||
+15
-37
@@ -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"),
|
||||
() -> {}
|
||||
);
|
||||
}
|
||||
}
|
||||
+974
@@ -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();
|
||||
}
|
||||
}
|
||||
+634
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
spring:
|
||||
cloud:
|
||||
consul:
|
||||
config:
|
||||
enabled: false
|
||||
otel:
|
||||
logs:
|
||||
exporter: none
|
||||
traces:
|
||||
exporter: none
|
||||
metrics:
|
||||
exporter: none
|
||||
Reference in New Issue
Block a user