18 Commits

Author SHA1 Message Date
hurui200320 cdde6ec922 feat:add dev container
Unit test coverage / gradle-test (pull_request) Successful in 6m16s
Unit test coverage / gradle-test (push) Successful in 6m2s
CI for publishing docker image / build-and-publish (push) Successful in 5m59s
2025-08-14 16:24:02 +08:00
hurui200320 bed3de76dd Implement query user endpoint for RabbitMQ (#35)
Unit test coverage / gradle-test (pull_request) Successful in 7m21s
Unit test coverage / gradle-test (push) Successful in 7m26s
CI for publishing docker image / build-and-publish (push) Successful in 7m2s
This PR implemented ticket clevermicro/user-management#34 for the user-service-v1 endpoint, along with all the infrastructures required to support the endpoints.

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

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

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

Other than that, this PR also:

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

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

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

if consul is not available or the

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

ISSUES CLOSED: clevermicro/user-management#23
2025-06-13 12:17:06 +08:00
abed.alrahman b764c6f12b feat#3_implement_access_control (#22)
Unit test coverage / gradle-test (push) Failing after 1m44s
CI for publishing docker image / build-and-publish (push) Successful in 2m31s
Co-authored-by: Abed <alrbeei@yahoo.com>
Reviewed-on: #22
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
Co-authored-by: Abed Alrahman <abed.alrahman@cleverthis.com>
Co-committed-by: Abed Alrahman <abed.alrahman@cleverthis.com>
2025-06-12 11:15:42 +00:00
abed.alrahman 2ff5930e86 Implement New User Registration in auth-service #8 (#21)
Unit test coverage / gradle-test (push) Failing after 1m50s
CI for publishing docker image / build-and-publish (push) Successful in 2m20s
Co-authored-by: Abed <alrbeei@yahoo.com>
Reviewed-on: #21
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Co-authored-by: Abed Alrahman <abed.alrahman@cleverthis.com>
Co-committed-by: Abed Alrahman <abed.alrahman@cleverthis.com>
2025-06-06 07:17:36 +00:00
Abed 4ff18c878b Merge branch 'Implement_Access_Control_in_auth-service#3' into develop
Unit test coverage / gradle-test (push) Failing after 1m20s
CI for publishing docker image / build-and-publish (push) Failing after 1m35s
2025-05-25 23:27:38 +02:00
abed.alrahman 79dd8384e0 Merge pull request 'Replace-User-Metadata-#5' (#16) from Replace-User-Metadata-#5 into develop
Unit test coverage / gradle-test (push) Failing after 1m25s
CI for publishing docker image / build-and-publish (push) Failing after 1m34s
Reviewed-on: #16
Reviewed-by: Rui Hu <rui.hu@cleverthis.com>
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
2025-05-22 10:07:50 +00:00
hurui200320 0e0c463a1e Add checkstyle and add pipelines for building docker image and coverage check
Unit test coverage / gradle-test (push) Failing after 2m42s
CI for publishing docker image / build-and-publish (push) Successful in 3m46s
ISSUES CLOSED: clevermicro/user-management#17
PR: clevermicro/user-management#19
2025-05-22 12:52:11 +08:00
Abed ade56ec23d Add more tests cases to test class, to test auth endpoint as well 2025-05-20 03:27:18 +02:00
Abed 66767d9e1e 1. Implement Access Control in auth-service via Traefik Forward Auth
2. Add keycloak admin client, to fetch as admin from keycloak
3. Add /auth endpoint, that will parse the request, and decide for auth
2025-05-13 00:56:35 +02:00
Abed 33182d5a8e add auth token to warn message 2025-05-11 00:29:05 +02:00
Abed 9889789fcc Group added to Verfication response, 2025-05-11 00:21:28 +02:00
stanislav.hejny ac85ff58af Merge pull request 'Feat#1 creating auth-service, with JUnit test' (#14) from Create-Auth-Microservice#1 into develop
Reviewed-on: #14
Reviewed-by: Stanislav Hejny <stanislav.hejny@cleverthis.com>
2025-05-07 08:38:05 +00:00
Abed 668bd05e5a Applying check style, and changing the log messages 2025-05-07 01:23:13 +02:00
Abed 0a16092b3d Feat#1 creating auth-service, with JUnit test 2025-05-05 02:35:20 +02:00
91 changed files with 8169 additions and 46 deletions
+9
View File
@@ -0,0 +1,9 @@
# Note: You can use any Debian/Ubuntu based image you want.
FROM mcr.microsoft.com/devcontainers/base:bullseye
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
bash \
curl \
jq
+9
View File
@@ -0,0 +1,9 @@
log_level = "INFO"
datacenter = "default_dc"
server = true
bootstrap_expect = 1
ui_config {
enabled = true
}
# we need this line to listen on 0.0.0.0
client_addr = "0.0.0.0"
+38
View File
@@ -0,0 +1,38 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker-compose
{
"name": "Docker from Docker Compose",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// Use this environment variable if you need to bind mount your local source code into a new container.
"remoteEnv": {
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
"WORKSPACE_FOLDER": "/workspaces/${localWorkspaceFolderBasename}"
},
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"version": "latest",
"enableNonRootDocker": "true",
"moby": "false"
},
"ghcr.io/devcontainers/features/java:1": {
"version": "21"
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8099,
"rabbitmq:5672",
"rabbitmq:15672",
"consul:8500",
"keycloak:8000",
],
"postCreateCommand": "/bin/bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/setup.sh",
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
+81
View File
@@ -0,0 +1,81 @@
version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
# Forwards the local Docker socket to the container.
- /var/run/docker.sock:/var/run/docker-host.sock
# Update this to wherever you want VS Code to mount the folder of your project
- ../..:/workspaces:cached
# Overrides default command so things don't shut down after the process ends.
entrypoint: /usr/local/share/docker-init.sh
command: sleep infinity
# Uncomment the next four lines if you will use a ptrace-based debuggers like C++, Go, and Rust.
# cap_add:
# - SYS_PTRACE
# security_opt:
# - seccomp:unconfined
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
depends_on:
- rabbitmq
- consul
- keycloak
rabbitmq:
image: rabbitmq:4-management
hostname: "my-rabbit"
environment:
RABBITMQ_DEFAULT_USER: springuser
RABBITMQ_DEFAULT_PASS: TheCleverWho
volumes:
- "rabbitmq-data:/var/lib/rabbitmq"
consul:
image: hashicorp/consul:1.20
command:
- "agent"
- "-data-dir=/consul/data"
- "-config-file=/config/config.hcl"
environment:
- CONSUL_BIND_INTERFACE=eth0
volumes:
- "consul-data:/consul/data"
- "./consul-main-config.hcl:/config/config.hcl"
keycloak:
image: "quay.io/keycloak/keycloak:26.2.5"
networks:
default:
aliases:
- "keycloak.dev.localhost"
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
- KC_HOSTNAME=http://keycloak.dev.localhost:8000
- KC_HOSTNAME_ADMIN=http://keycloak.dev.localhost:8000
- KC_HTTP_PORT=8000
command:
- start
# enable http, let reverse proxy handles the TLS
- --http-enabled=true
# take X-Forwarded-* headers from reverse proxy
- --proxy-headers=xforwarded
# hide node name in cookie for sticky session, we're using reverse proxy for that
# ref: https://www.keycloak.org/server/reverseproxy#_enable_sticky_sessions
- --spi-sticky-session-encoder-infinispan-should-attach-route=false
# read hostname from reverse proxy headers
- --hostname-strict=false
# Limit the request queue to prevent OOM
- --http-max-queued-requests=200
volumes:
rabbitmq-data:
consul-data:
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
cd ${WORKSPACE_FOLDER}/.devcontainer/identity-management/
./tools/setup-cli/setup-admin-cli.sh
sleep 10 # wait keycloak
./tools/login-admin/login-admin.sh -s=http://keycloak.dev.localhost:8000 -u=admin -p=admin
./tools/create-realm/create-realm.sh -n=clevermicro-dev
./tools/create-client/create-client.sh -r=clevermicro-dev -n=document-service
./tools/dev-realm/set-up.sh -r=clevermicro-dev
./tools/create-user/create-user.sh -r=clevermicro-dev -u=test -p=password -f=Jhon -l=Doe -e=jhon.doe@example.com
+72
View File
@@ -0,0 +1,72 @@
name: "Unit test coverage"
on:
push:
pull_request:
env:
MIN_COVERAGE_PERCENTAGE: 85
DEBIAN_FRONTEND: noninteractive
TZ: UTC
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_VERIFY: ""
# Test containers suggest this will yield better fs performance
DOCKER_DRIVER: overlay2
# ryuk need privileged permission, disable it in CI
TESTCONTAINERS_RYUK_DISABLED: true
jobs:
# gradle test for modules
gradle-test:
runs-on: general
container:
image: ubuntu:24.04
services:
docker:
image: docker:dind
cmd:
- "dockerd"
- "-H"
- "tcp://0.0.0.0:2375"
- "--tls=false"
steps:
# need to setup node and git
- run: |
apt-get update
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: ./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" [^>]*>' 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"
echo "COVERED: $COVERED"
COVERAGE_PERCENTAGE=$(( 100 * $COVERED / ($COVERED + $MISSED) ))
echo "Code coverage: ${COVERAGE_PERCENTAGE}%"
echo "Minimal coverage: ${MIN_COVERAGE_PERCENTAGE}%"
echo "percentage=${COVERAGE_PERCENTAGE}" >> $GITHUB_OUTPUT
- name: Post coverage to RP
if: github.event_name == 'pull_request'
run: |
curl --fail \
-X POST '${{github.api_url}}/repos/${{github.repository}}/issues/${{github.event.number}}/comments' \
-H "Content-Type: application/json" \
-H "Authorization: token ${{github.token}}" \
-d '{"body":"Coverage is ${{steps.coverage.outputs.percentage}}%"}'
- name: Check coverage rate
run: |
if [ ${{steps.coverage.outputs.percentage}} -lt $MIN_COVERAGE_PERCENTAGE ]; then
exit 1
fi
+91
View File
@@ -0,0 +1,91 @@
name: "CI for publishing docker image"
on:
push:
branches:
- "**" # build on all branches since the tag is decided by branch name
workflow_dispatch:
# allow manual trigger
env:
REGISTRY_URL: "git.cleverthis.com"
DEBIAN_FRONTEND: noninteractive
TZ: UTC
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_VERIFY: ""
jobs:
build-and-publish:
runs-on: general
services:
docker:
image: docker:dind
cmd:
- "dockerd"
- "-H"
- "tcp://0.0.0.0:2375"
- "--tls=false"
container:
image: ubuntu:24.04
steps:
# need to setup node and git
- run: |
apt-get update
apt-get install -y nodejs git
# check out code
- uses: actions/checkout@v4
# provide slug version of the envs
- name: Slugify ref name
uses: https://github.com/rlespinasse/slugify-value@v1.4.0
with:
key: GITHUB_REF_NAME
prefix: ""
slug-maxlength: 80
# shorten the sha
- name: Shorten SHA
uses: https://github.com/rlespinasse/shortify-git-revision@v1.6.0
with:
name: GITHUB_SHA
short-on-error: true
length: 7
prefix: ""
# install jdk
- uses: https://github.com/actions/setup-java@v4
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: |
apt-get install -y ca-certificates curl dnsutils
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install docker-ce-cli docker-buildx-plugin docker-compose-plugin
# Login to register
- uses: https://github.com/docker/login-action@v3
with:
registry: ${{ env.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
# file: ci.Dockerfile
file: Dockerfile
push: true
tags: |
${{ env.REGISTRY_URL }}/${{ env.GITHUB_REPOSITORY }}:${{ env.GITHUB_REF_NAME_SLUG }}
${{ env.REGISTRY_URL }}/${{ env.GITHUB_REPOSITORY }}:${{ env.GITHUB_SHA_SHORT }}
+32 -46
View File
@@ -1,50 +1,36 @@
# Useful examples .gradle
# https://github.com/github/gitignore build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# OS X ### STS ###
*.DS_Store .apt_generated
# 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/
.classpath .classpath
.factorypath
.project .project
.settings/ .settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
+3
View File
@@ -0,0 +1,3 @@
[submodule ".devcontainer/identity-management"]
path = .devcontainer/identity-management
url = ../identity-management.git
+11
View File
@@ -0,0 +1,11 @@
FROM gradle:jdk21 AS build
WORKDIR /code
COPY . /code
RUN gradle bootJar --no-daemon
FROM azul/zulu-openjdk:21-latest
EXPOSE 8099
WORKDIR /app
COPY --from=build /code/build/libs/*.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+128
View File
@@ -0,0 +1,128 @@
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.7"
jacoco
checkstyle
idea
}
group = "com.cleverthis.clevermicro"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
configurations.all {
// disable the cache, so it will fetch the latest snapshot for client api
resolutionStrategy.cacheChangingModulesFor(30, TimeUnit.SECONDS)
resolutionStrategy.cacheDynamicVersionsFor(30, TimeUnit.SECONDS)
}
repositories {
mavenCentral()
maven {
name = "cleverthis-forgejo-maven"
url = uri("https://git.cleverthis.com/api/packages/clevermicro/maven")
// disable auth since artifacts are publicly available
// if (System.getenv("MAVEN_TOKEN").isNullOrBlank()) {
// credentials(HttpHeaderCredentials::class) {
// name = "Authorization"
// val token =
// findProperty("cleverThisForgejoToken") as String? ?: error("Token not found")
// value = "token $token"
// }
// } else {
// credentials(HttpHeaderCredentials::class) {
// name = "Authorization"
// val token = System.getenv("MAVEN_TOKEN")
// value = "token $token"
// }
// }
// authentication {
// create("header", HttpHeaderAuthentication::class)
// }
}
}
extra["springCloudVersion"] = "2025.0.0"
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}
// https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
val mockitoAgent = configurations.create("mockitoAgent")
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-consul-config")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
// clevermicro
implementation("com.cleverthis.clevermicro:core:0.2.0-SNAPSHOT")
// keycloak admin client
implementation("org.keycloak:keycloak-admin-client:26.0.6")
// opentelemetry
implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.11.0"))
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
mockitoAgent("org.mockito:mockito-core") { isTransitive = false }
}
tasks.withType<Test> {
jvmArgs("-javaagent:${mockitoAgent.asPath}")
useJUnitPlatform()
// generate jacoco test report
finalizedBy("jacocoTestReport")
// show full output streams so we can debug for pipelines
testLogging {
showStandardStreams = true
}
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
}
}
checkstyle {
toolVersion = "10.23.1"
configFile = file("${project.rootDir}/checkstyle.xml")
isShowViolations = true
isIgnoreFailures = false
maxWarnings = 0
maxErrors = 0
sourceSets = setOf(project.sourceSets["main"])
}
idea { // tell IDEA to always download source code and javadoc
module {
isDownloadJavadoc = true
isDownloadSources = true
}
}
+380
View File
@@ -0,0 +1,380 @@
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<!--
This used to be a Checkstyle config by Max Vetrenko, Ruslan Diachenko,
and Roman Ivanov.
It has since been modified to include changes specific to CleverThis.
-->
<module name="Checker">
<module name="SuppressWarningsFilter"/>
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Excludes all 'module-info.java' files -->
<!-- See https://checkstyle.org/filefilters/index.html -->
<module name="BeforeExecutionExclusionFileFilter">
<property name="fileNamePattern" value="module\-info\.java$"/>
</module>
<!-- https://checkstyle.org/filters/suppressionfilter.html -->
<module name="SuppressionFilter">
<property name="file" value="${org.checkstyle.google.suppressionfilter.config}"
default="checkstyle-suppressions.xml" />
<property name="optional" value="true"/>
</module>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.org/checks/whitespace/index.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<module name="LineLength">
<property name="fileExtensions" value="java"/>
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
<module name="TreeWalker">
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format"
value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message"
value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap">
<property name="tokens" value="PACKAGE_DEF, IMPORT, STATIC_IMPORT"/>
</module>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens"
value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces">
<property name="tokens"
value="LITERAL_DO, LITERAL_ELSE, LITERAL_FOR, LITERAL_IF, LITERAL_WHILE"/>
</module>
<module name="LeftCurly">
<property name="tokens"
value="ANNOTATION_DEF, CLASS_DEF, CTOR_DEF, ENUM_CONSTANT_DEF, ENUM_DEF,
INTERFACE_DEF, LAMBDA, LITERAL_CATCH, LITERAL_DEFAULT,
LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF,
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE, METHOD_DEF,
OBJBLOCK, STATIC_INIT, RECORD_DEF, COMPACT_CTOR_DEF"/>
</module>
<module name="RightCurly">
<property name="id" value="RightCurlySame"/>
<property name="tokens"
value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE,
LITERAL_DO"/>
</module>
<module name="RightCurly">
<property name="id" value="RightCurlyAlone"/>
<property name="option" value="alone"/>
<property name="tokens"
value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT,
INSTANCE_INIT, ANNOTATION_DEF, ENUM_DEF, INTERFACE_DEF, RECORD_DEF,
COMPACT_CTOR_DEF, LITERAL_SWITCH"/>
</module>
<module name="SuppressionXpathSingleFilter">
<!-- suppresion is required till https://github.com/checkstyle/checkstyle/issues/7541 -->
<property name="id" value="RightCurlyAlone"/>
<property name="query" value="//RCURLY[parent::SLIST[count(./*)=1]
or preceding-sibling::*[last()][self::LCURLY]]"/>
</module>
<module name="WhitespaceAfter">
<property name="tokens"
value="COMMA, SEMI, TYPECAST, LITERAL_IF, LITERAL_ELSE, LITERAL_RETURN,
LITERAL_WHILE, LITERAL_DO, LITERAL_FOR, LITERAL_FINALLY, DO_WHILE, ELLIPSIS,
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_CATCH, LAMBDA,
LITERAL_YIELD"/>
</module>
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyLambdas" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<property name="ignoreEnhancedForColon" value="false"/>
<property name="tokens"
value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR,
BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAMBDA, LAND,
LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH, LITERAL_SYNCHRONIZED,
LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN,
NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION, RCURLY, SL, SLIST, SL_ASSIGN, SR,
SR_ASSIGN, STAR, STAR_ASSIGN, LITERAL_ASSERT, TYPE_EXTENSION_AND"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks
may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="tokens"
value="PACKAGE_DEF, IMPORT, STATIC_IMPORT, CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
STATIC_INIT, INSTANCE_INIT, METHOD_DEF, CTOR_DEF, VARIABLE_DEF, RECORD_DEF,
COMPACT_CTOR_DEF"/>
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
ANNOTATION_DEF, RECORD_DEF"/>
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9]*$"/>
<message key="name.invalidPattern"
value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
<message key="name.invalidPattern"
value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LambdaParameterName">
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
<message key="name.invalidPattern"
value="Lambda parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
<message key="name.invalidPattern"
value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
<message key="name.invalidPattern"
value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="PatternVariableName">
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
<message key="name.invalidPattern"
value="Pattern variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="RecordComponentName">
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
<message key="name.invalidPattern"
value="Record component name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="RecordTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Record type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
<message key="name.invalidPattern"
value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed"
value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded"
value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow"
value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded"
value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="4"/>
<property name="braceAdjustment" value="4"/>
<property name="caseIndent" value="4"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="6"/>
<property name="arrayInitIndent" value="6"/>
</module>
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="0"/>
<property name="tokens"
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, ANNOTATION_DEF, ANNOTATION_FIELD_DEF,
PARAMETER_DEF, VARIABLE_DEF, METHOD_DEF, PATTERN_VARIABLE_DEF, RECORD_DEF,
RECORD_COMPONENT_DEF"/>
</module>
<module name="NoWhitespaceBeforeCaseDefaultColon"/>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
<property name="tokens" value="IMPORT, STATIC_IMPORT, PACKAGE_DEF"/>
</module>
<module name="MethodParamPad">
<property name="tokens"
value="CTOR_DEF, LITERAL_NEW, METHOD_CALL, METHOD_DEF,
SUPER_CTOR_CALL, ENUM_CONSTANT_DEF, RECORD_DEF"/>
</module>
<module name="NoWhitespaceBefore">
<property name="tokens"
value="COMMA, SEMI, POST_INC, POST_DEC, DOT,
LABELED_STAT, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad">
<property name="tokens"
value="ANNOTATION, ANNOTATION_FIELD_DEF, CTOR_CALL, CTOR_DEF, DOT, ENUM_CONSTANT_DEF,
EXPR, LITERAL_CATCH, LITERAL_DO, LITERAL_FOR, LITERAL_IF, LITERAL_NEW,
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_WHILE, METHOD_CALL,
METHOD_DEF, QUESTION, RESOURCE_SPECIFICATION, SUPER_CTOR_CALL, LAMBDA,
RECORD_DEF"/>
</module>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens"
value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR,
LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF,
TYPE_EXTENSION_AND "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens"
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF,
RECORD_DEF, COMPACT_CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="NonEmptyAtclauseDescription"/>
<module name="InvalidJavadocPosition"/>
<module name="JavadocTagContinuationIndentation"/>
<module name="SummaryJavadoc">
<property name="forbiddenSummaryFragments"
value="^@return the *|^This method returns |^A [{]@code [a-zA-Z0-9]+[}]( is a )"/>
</module>
<module name="RequireEmptyLineBeforeBlockTagGroup"/>
<module name="AtclauseOrder">
<property name="tagOrder" value="@param, @return, @throws, @deprecated"/>
<property name="target"
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF, VARIABLE_DEF"/>
</module>
<module name="JavadocMethod">
<property name="accessModifiers" value="public"/>
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF, COMPACT_CTOR_DEF"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="public"/>
<property name="minLineCount" value="2"/>
<property name="allowedAnnotations" value="Override, Test"/>
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF,
COMPACT_CTOR_DEF"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="protected"/>
<property name="tokens"
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
RECORD_DEF, ANNOTATION_DEF"/>
<property name="excludeScope" value="nothing"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-z0-9]\w*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="SingleLineJavadoc"/>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation">
<property name="tokens" value="SINGLE_LINE_COMMENT, BLOCK_COMMENT_BEGIN"/>
</module>
<!-- https://checkstyle.org/filters/suppressionxpathfilter.html -->
<module name="SuppressionXpathFilter">
<property name="file" value="${org.checkstyle.google.suppressionxpathfilter.config}"
default="checkstyle-xpath-suppressions.xml" />
<property name="optional" value="true"/>
</module>
<module name="SuppressWarningsHolder" />
<module name="SuppressionCommentFilter">
<property name="offCommentFormat" value="CHECKSTYLE.OFF\: ([\w\|]+)" />
<property name="onCommentFormat" value="CHECKSTYLE.ON\: ([\w\|]+)" />
<property name="checkFormat" value="$1" />
</module>
<module name="SuppressWithNearbyCommentFilter">
<property name="commentFormat" value="CHECKSTYLE.SUPPRESS\: ([\w\|]+)"/>
<!-- $1 refers to the first match group in the regex defined in commentFormat -->
<property name="checkFormat" value="$1"/>
<!-- The check is suppressed in the next line of code after the comment -->
<property name="influenceFormat" value="1"/>
</module>
<module name="RequireThis">
<property name="checkMethods" value="false"/>
<property name="validateOnlyOverlapping" value="false"/>
</module>
</module>
</module>
+5
View File
@@ -0,0 +1,5 @@
FROM azul/zulu-openjdk:21-latest
EXPOSE 8099
WORKDIR /app
COPY build/libs/*.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
rootProject.name = "auth-service"
@@ -0,0 +1,15 @@
package com.cleverthis.authservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Auth-Service starter.
*/
@SpringBootApplication
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
}
@@ -0,0 +1,89 @@
package com.cleverthis.authservice.config;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpConfig;
import com.rabbitmq.client.ConnectionFactory;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeoutException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
/**
* Provide {@link CleverMicroAmqpClient} as a bean.
*/
@Configuration
@EnableConfigurationProperties(CleverMicroClientConfigProperties.class)
@Slf4j
public class CleverMicroClientConfig {
private final CleverMicroClientConfigProperties properties;
public CleverMicroClientConfig(
final CleverMicroClientConfigProperties configProperties
) {
this.properties = configProperties;
}
/**
* Post construct.
*/
@PostConstruct
public void init() {
// correct availability
if (this.properties.getInitialAvailability() <= 0) {
this.properties.setInitialAvailability(Runtime.getRuntime().availableProcessors() * 2);
}
log.info("CleverMicroClientConfig initial availability: {}",
this.properties.getInitialAvailability());
log.info("CleverMicroClient will use swarm info: serviceId={}, taskId={}",
this.properties.getSwarmServiceId(), this.properties.getSwarmTaskId());
}
/**
* Provide a RabbitMQ {@link ConnectionFactory}.
*/
@Bean
@Lazy
public ConnectionFactory connectionFactory() {
final var factory = new ConnectionFactory();
factory.setHost(this.properties.getRabbitmqHost());
factory.setPort(this.properties.getRabbitmqPort());
factory.setUsername(this.properties.getRabbitmqUsername());
factory.setPassword(this.properties.getRabbitmqPassword());
factory.setVirtualHost(this.properties.getRabbitmqVhost());
factory.setAutomaticRecoveryEnabled(true);
return factory;
}
/**
* Create a {@link CleverMicroAmqpClient} bean.
*/
@Bean
@Lazy
@ConditionalOnMissingBean
public CleverMicroAmqpClient cleverMicroAmqpClient(
final ConnectionFactory connectionFactory,
final ApplicationContext applicationContext
) throws IOException, TimeoutException {
final var client = new CleverMicroAmqpClient(
connectionFactory.newConnection(), CleverMicroAmqpConfig.builder()
.serviceName(applicationContext.getApplicationName())
.swarmServiceId(this.properties.getSwarmServiceId())
.swarmTaskId(this.properties.getSwarmTaskId())
.reportAvailabilityUsageCooldown(Duration.ofMillis(250))
.reportAvailabilityRecoveryCooldown(Duration.ofSeconds(1))
.reportAvailabilityRegularCooldown(Duration.ofSeconds(5))
.build()
);
client.getHandlerManager().getAvailability()
.release(this.properties.getInitialAvailability());
return client;
}
}
@@ -0,0 +1,65 @@
package com.cleverthis.authservice.config;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Config properties for {@link CleverMicroClientConfig}.
*/
@ConfigurationProperties(prefix = "clevermicro.client")
@Builder
@Getter
@Setter
public class CleverMicroClientConfigProperties {
/**
* Host of the rabbitmq server.
*/
@Builder.Default
private String rabbitmqHost = "localhost";
/**
* The port of the rabbitmq server.
*/
@Builder.Default
private int rabbitmqPort = 5672;
/**
* RabbitMQ username.
*/
@Builder.Default
private String rabbitmqUsername = "guest";
/**
* RabbitMQ password.
*/
@Builder.Default
private String rabbitmqPassword = "guest";
/**
* Vhost of the rabbitmq server.
*/
@Builder.Default
private String rabbitmqVhost = "/";
/**
* Initial availability.
* <p>
* Default: CPU cores * 2, since the workload for auth-service is mainly IO-intensive.
* </p>
* <p>Set to -1 to use the default value.</p>
* */
@Builder.Default
private int initialAvailability = Runtime.getRuntime().availableProcessors() * 2;
/**
* Docker swarm service id.
*/
private String swarmServiceId;
/**
* Docker swarm task id.
*/
private String swarmTaskId;
}
@@ -0,0 +1,37 @@
package com.cleverthis.authservice.config;
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
import org.keycloak.admin.client.Keycloak;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
/**
* Config for keycloak clients:
* {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService}
* and {@link KeycloakAdminReactiveClient}.
*/
@Configuration
@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
public class KeycloakClientConfig {
/**
* Create a Keycloak admin client.
* */
@Bean
@Lazy
@ConditionalOnMissingBean
public Keycloak keycloakAdminClient(
final KeycloakClientConfigProperties configProperties
) {
return Keycloak.getInstance(
configProperties.getKeycloakAdminHost(),
configProperties.getRealm(),
configProperties.getAdminAccountUsername(),
configProperties.getAdminAccountPassword(),
"admin-cli"
);
}
}
@@ -0,0 +1,71 @@
package com.cleverthis.authservice.config;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Config model for {@link KeycloakClientConfig}.
*/
@ConfigurationProperties(prefix = "keycloak")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class KeycloakClientConfigProperties {
/**
* Host name of your Keycloak instance.
*/
@Builder.Default
@NotBlank(message = "Keycloak server URL cannot be blank")
private String keycloakHost = "http://keycloak.dev.localhost";
/**
* Admin host name of your Keycloak instance.
*/
@Builder.Default
@NotBlank(message = "Keycloak server URL cannot be blank")
private String keycloakAdminHost = "http://keycloak-admin.dev.localhost";
/**
* The realm name.
*/
@Builder.Default
@NotBlank(message = "Keycloak realm cannot be blank")
private String realm = "clevermicro-dev";
/**
* The client id for calling OIDC endpoints.
*/
@Builder.Default
@NotBlank(message = "Keycloak client ID cannot be blank")
private String clientId = "auth-service";
/**
* The client secret.
*/
@Builder.Default
@NotBlank(message = "Keycloak client secret cannot be blank")
private String clientSecret = "";
/**
* The admin account for accessing the admin endpoints.
*/
@Builder.Default
@NotBlank(message = "Keycloak admin account username cannot be blank")
private String adminAccountUsername = "auth-service-account";
/**
* The admin account password.
*/
@Builder.Default
@NotBlank(message = "Keycloak admin account password cannot be blank")
private String adminAccountPassword = "";
}
@@ -0,0 +1,76 @@
package com.cleverthis.authservice.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
/**
* Configuration properties for mapping request paths to Keycloak Client IDs.
* Loaded from application.yml under 'auth-service.permission-mapping'.
*/
@Component
@ConfigurationProperties(prefix = "auth-service.permission-mapping")
@Data
@Validated
public class PermissionMappingConfigProperties {
@Valid
private List<Rule> rules = new ArrayList<>();
private String defaultKeycloakClientId; // Optional default
/**
* mapping request paths to Keycloak Client IDs.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Rule {
@NotBlank(message = "Path prefix cannot be blank in permission mapping rule")
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
}
}
}
@@ -0,0 +1,46 @@
package com.cleverthis.authservice.config;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
/**
* Configuration class for creating and configuring the WebClient bean used for
* making HTTP requests
* to Keycloak.
*/
@Configuration
public class WebClientConfig {
// Timeout values in milliseconds
private static final int CONNECT_TIMEOUT = 5000; // 5 seconds
private static final int READ_TIMEOUT = 10000; // 10 seconds
private static final int WRITE_TIMEOUT = 10000; // 10 seconds
/**
* Provide a {@link WebClient} for interacting with keycloak.
*/
@Bean
public WebClient keycloakWebClient() {
// Configure HttpClient with timeouts
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT)
.responseTimeout(Duration.ofMillis(READ_TIMEOUT)) // Max time to wait for response
.doOnConnected(conn -> conn.addHandlerLast(
new ReadTimeoutHandler(READ_TIMEOUT, TimeUnit.MILLISECONDS))
.addHandlerLast(
new WriteTimeoutHandler(WRITE_TIMEOUT, TimeUnit.MILLISECONDS)));
// Build WebClient with the configured HttpClient
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
@@ -0,0 +1,368 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService;
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;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
/**
* REST Controller for handling authentication related requests. Delegates the actual identity
* provider interaction to the IdentityManagerService.
*/
@RestController
@RequestMapping("/")
@Slf4j
public class AuthController {
// --- Constants for Header Names ---
private static final String HEADER_USER_ID = "X-User-Id";
private static final String HEADER_USER_NAME = "X-User-Name";
private static final String HEADER_USER_EMAIL = "X-User-Email";
private static final String HEADER_USER_GROUPS = "X-User-Groups";
// Add more headers as needed (e.g., X-User-Roles)
// --- Traefik Forwarded Headers ---
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri"; // Full original URI
// private static final String HEADER_X_FORWARDED_HOST = "X-Forwarded-Host";
// private static final String HEADER_X_FORWARDED_PROTO = "X-Forwarded-Proto";
private final IdentityManagerService identityManagerService;
private final PermissionMapLookupService permissionMapLookupService;
@Autowired
public AuthController(
IdentityManagerService identityManagerService,
PermissionMapLookupService permissionMapLookupService
) {
this.identityManagerService = identityManagerService;
this.permissionMapLookupService = permissionMapLookupService;
}
/**
* Handles user login requests using username and password.
*
* @param loginRequest DTO containing username and password.
* @return ResponseEntity containing the TokenResponse on success, or an error status handled
* globally.
*/
@PostMapping("/login")
public Mono<ResponseEntity<TokenResponse>> login(
@Valid @RequestBody LoginRequest loginRequest) {
log.info("Received login request for user: {}", loginRequest.getUsername());
return this.identityManagerService
.login(loginRequest.getUsername(), loginRequest.getPassword())
.map(ResponseEntity::ok) // Map successful response to ResponseEntity<TokenResponse>
.doOnError(e -> log.error("Login failed for user {}: " + "{}",
loginRequest.getUsername(), e.getMessage()));
}
/**
* Handles token verification requests. Expects the access token in the Authorization header
* (Bearer scheme). Returns full token introspection details.
*
* @param authorizationHeader The full Authorization header value (e.g., "Bearer token").
* @return ResponseEntity containing the VerificationResponse on success, or an error status
* handled globally.
*/
@PostMapping("/verify")
public Mono<ResponseEntity<VerificationResponse>> verify(
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) {
Mono<ResponseEntity<VerificationResponse>> result;
log.info("Received token verification request (/verify)");
String accessToken = extractBearerToken(authorizationHeader);
if (accessToken == null) {
log.warn("Verification request missing or invalid Bearer token format," + " token : {}",
authorizationHeader);
result = Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
} else {
result = this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok)
.doOnError(e -> log.error("Token verification failed: {}", e.getMessage()));
}
return result;
}
/**
* Extracts the token from the "Bearer token" Authorization header.
*
* @param authorizationHeader The full header value.
* @return The token string or null if header is invalid.
*/
private String extractBearerToken(String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
}
return null;
}
/**
* Handles user logout requests using a refresh token.
*
* @param logoutRequest DTO containing the refresh token.
* @return ResponseEntity with 204 No Content on success, or an error status handled globally.
*/
@PostMapping("/logout")
public Mono<ResponseEntity<Void>> logout(@Valid @RequestBody LogoutRequest logoutRequest) {
log.info("Received logout request");
return this.identityManagerService.logout(logoutRequest.getRefreshToken())
.thenReturn(ResponseEntity.noContent().<Void>build())
.doOnError(e -> log.error("Logout failed: {}", e.getMessage()));
}
/**
* Builds HttpHeaders containing user metadata based on the verification response.
*
* @param verificationResponse The response from token verification.
* @return HttpHeaders object with X-User-* headers.
*/
private HttpHeaders buildAuthHeaders(VerificationResponse verificationResponse) {
HttpHeaders headers = new HttpHeaders();
// Use 'sub' (subject) as the canonical User ID
if (verificationResponse.getSub() != null) {
headers.add(HEADER_USER_ID, verificationResponse.getSub());
}
// Use 'preferredUsername' as the display/login name
if (verificationResponse.getPreferredUsername() != null) {
headers.add(HEADER_USER_NAME, verificationResponse.getPreferredUsername());
}
if (verificationResponse.getEmail() != null) {
headers.add(HEADER_USER_EMAIL, verificationResponse.getEmail());
}
// Handle groups if present
if (verificationResponse.getGroups() != null
&& !verificationResponse.getGroups().isEmpty()) {
// Convert list of groups to a comma-separated string
headers.add(HEADER_USER_GROUPS,
StringUtils.collectionToCommaDelimitedString(verificationResponse.getGroups()));
}
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<?>> 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);
// 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();
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());
}
// 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
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: 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());
});
}
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.matchRuleByPath(path);
if (pathMatch.isPresent()) {
return pathMatch;
}
final var defaultValue = this.permissionMapLookupService.getDefaultKeycloakClientId();
if (defaultValue != null) {
log.debug(
"No rule matched for path '{}', using default Keycloak Client ID:{}",
path, defaultValue);
return Optional.of(PermissionMappingConfigProperties.Rule.builder()
.keycloakClientId(defaultValue)
.build());
}
} catch (URISyntaxException e) {
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
return Optional.empty();
}
return Optional.empty();
}
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
// needs definition.
// For now, assume full path if default.
try {
return new URI(fullUriString).getPath();
} catch (URISyntaxException e) {
log.error(
"Invalid URI syntax for X-Forwarded-Uri when extracting relative path:"
+ " {}",
fullUriString, e);
return ""; // Or throw
}
}
try {
URI uri = new URI(fullUriString);
String path = uri.getPath();
if (path.startsWith(pathPrefixToRemove)) {
String relativePath = path.substring(pathPrefixToRemove.length());
// Ensure it starts with a slash if not empty
return relativePath.startsWith("/") || relativePath.isEmpty() ? relativePath
: "/" + relativePath;
}
return path; // Fallback
} catch (URISyntaxException e) {
log.error("Invalid URI syntax for X-Forwarded-Uri when extracting relative path: {}",
fullUriString, e);
return ""; // Or throw
}
}
}
@@ -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,56 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.service.IdentityManagerService;
import jakarta.validation.Valid;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
/**
* REST Controller for handling registration related requests.
* provider interaction to the IdentityManagerService.
*/
@RestController
@RequestMapping("/users")
@Slf4j
public class UserRegistrationController {
private final IdentityManagerService identityManagerService;
@Autowired
public UserRegistrationController(IdentityManagerService identityManagerService) {
this.identityManagerService = identityManagerService;
}
/**
* Handles new user registration requests. The user will be created with emailVerified set to
* false. A conceptual verification email would be sent (implementation of email sending is
* TBD).
*
* @param registrationRequest DTO containing user registration details.
* @return ResponseEntity with 201 Created on success, or an error status.
*/
@PostMapping("/register")
public Mono<ResponseEntity<Void>> registerUser(
@Valid @RequestBody RegistrationRequest registrationRequest) {
log.info("Received registration request for email: {}", registrationRequest.getEmail());
return this.identityManagerService.registerUser(registrationRequest)
.then(Mono.fromCallable(
() -> ResponseEntity.status(HttpStatus.CREATED).<Void>build()))
.doOnError(e -> log.error("User registration failed for email {}: {}",
registrationRequest.getEmail(), e.getMessage()))
.onErrorResume(e -> {
log.error("Registration failed with an unexpected error: {}", e.getMessage());
return Mono
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
});
}
}
@@ -0,0 +1,148 @@
package com.cleverthis.authservice.controller.rabbitmq;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.BuiltinExchangeType;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
/**
* Abstract class for endpoint of auth-service over RabbitMQ.
*/
@Slf4j
public abstract class AbstractEndpoint implements AutoCloseable {
public static final String ENDPOINT_EXCHANGE = "cleverthis.clevermicro.auth.endpoints";
public static final BuiltinExchangeType ENDPOINT_EXCHANGE_TYPE = BuiltinExchangeType.DIRECT;
private final String handlerTag;
private final CleverMicroAmqpClient client;
protected final ObjectMapper objectMapper;
private final String queueName;
/**
* Initialize this abstract class.
*/
public AbstractEndpoint(
final CleverMicroAmqpClient client,
final ObjectMapper objectMapper,
final String routingKey
) throws IOException {
this.client = client;
this.objectMapper = objectMapper;
this.queueName = ENDPOINT_EXCHANGE + "." + routingKey;
client.getHandlerManager().declareExchange(ENDPOINT_EXCHANGE, ENDPOINT_EXCHANGE_TYPE);
client.getHandlerManager().declareQueue(
this.queueName, true, false, false, Map.of()
);
client.getHandlerManager().bindQueue(
this.queueName, ENDPOINT_EXCHANGE, routingKey, Map.of()
);
this.handlerTag = client.getHandlerManager().registerHandler(
this.queueName, "", 1,
ctx -> new Thread(() -> handleRabbitMessage(ctx)).start(),
tag -> log.info("Endpoint canceled for queue {}: handlerTag={}",
this.queueName, tag)
);
}
// VisibleForTesting
void handleRabbitMessage(final HandlerContext ctx) {
try {
final var req = parseRequest(ctx);
if (req == null) {
throw new EndpointBadRequestException(
"Malformed request, cannot be parsed");
}
handleRequest(ctx, req);
} catch (final EndpointException ex) {
final var resp = EndpointResponses.error(ex);
reply(ctx, resp);
} catch (final Throwable t) {
log.error(
"Error when handling request for queue {}, only refill availability",
this.queueName, t
);
final var resp = EndpointResponses.internalServerError(
"Error when handling the request", t);
reply(ctx, resp);
}
}
// VisibleForTesting
EndpointRequest parseRequest(final HandlerContext ctx) {
InputStream reqStream = null;
if (ctx.getMessageBodySingleStream().isPresent()) {
reqStream = ctx.getMessageBodySingleStream().get();
} else if (ctx.getMessageBodyMultiStream().isPresent()) {
final var multibody = ctx.getMessageBodyMultiStream().get();
reqStream = multibody.get("request");
}
// check null
if (reqStream == null) {
return null;
}
// parsing, need to convert to final
try (final var stream = reqStream) {
return this.objectMapper.readValue(
stream,
EndpointRequest.class
);
} catch (final IOException ex) {
log.error("Error when parsing single stream request for queue: {}", this.queueName, ex);
return null;
}
}
/**
* Handle a request.
*/
protected abstract void handleRequest(
final HandlerContext ctx,
final EndpointRequest request
) throws EndpointException;
/**
* Try to reply. Refill counter if error.
*/
protected void reply(final HandlerContext ctx, final Object response) {
final byte[] bytes;
try {
bytes = this.objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsBytes(response);
} catch (final JsonProcessingException ex) {
log.error("Error when serializing response, ignore reply and refill counter. Queue {}",
this.queueName, ex);
ctx.refillAvailability();
return;
}
try {
ctx.finish(bytes);
} catch (final IOException ex) {
// the counter should be refilled before exception
log.error("Error when replying request for queue {}", this.queueName, ex);
}
}
/**
* Close the endpoint and release resources.
* Should be called in {@link jakarta.annotation.PreDestroy} annotated methods.
*/
@Override
@PreDestroy
public void close() {
log.info("Closing Endpoint for queue: {}, consumerTag={}", this.queueName, this.handlerTag);
this.client.getHandlerManager().cancelHandler(this.handlerTag);
}
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.controller.rabbitmq.exception;
/**
* Exception to signal the request is malformed or invalid.
*/
public class EndpointBadRequestException extends EndpointException {
private static final String ENDPOINT_EXCEPTION = "cleverthis.clevermicro.bad_request";
public EndpointBadRequestException(
final String message,
final Throwable cause
) {
super(ENDPOINT_EXCEPTION, message, cause);
}
public EndpointBadRequestException(
final String message
) {
super(ENDPOINT_EXCEPTION, message);
}
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.controller.rabbitmq.exception;
import lombok.Getter;
/**
* Exception for endpoints.
*/
public class EndpointException extends Exception {
@Getter
private final String endpointException;
public EndpointException(
final String endpointException,
final String message,
final Throwable cause
) {
super(message, cause);
this.endpointException = endpointException;
}
public EndpointException(
final String endpointException,
final String message
) {
super(message);
this.endpointException = endpointException;
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.controller.rabbitmq.exception;
/**
* Exception to signal the handler is failed abnormally.
*/
public class EndpointInternalServerErrorException extends EndpointException {
private static final String ENDPOINT_EXCEPTION = "cleverthis.clevermicro.server_internal_error";
public EndpointInternalServerErrorException(
final String message,
final Throwable cause
) {
super(ENDPOINT_EXCEPTION, message, cause);
}
}
@@ -0,0 +1,39 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* The model for replied response.
*/
@EqualsAndHashCode(callSuper = true)
@Getter
@Setter
@ToString
@Builder
public final class EndpointErrorResponse extends EndpointResponse {
@JsonProperty("exception")
private final String exception;
@JsonProperty("message")
private final String message;
@JsonProperty("additional_info")
private final Object additionalInfo;
/**
* Create an error response.
*/
public EndpointErrorResponse(
final String exception,
final String message,
final Object additionalInfo
) {
super("ERROR");
this.exception = exception;
this.message = message;
this.additionalInfo = additionalInfo;
}
}
@@ -0,0 +1,27 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* The model for replied response.
*/
@EqualsAndHashCode(callSuper = true)
@Getter
@Setter
@ToString
@Builder
public class EndpointOkResponse extends EndpointResponse {
@JsonProperty("result")
private final List<?> result;
public EndpointOkResponse(final List<?> result) {
super("OK");
this.result = result;
}
}
@@ -0,0 +1,19 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.LinkedHashMap;
import lombok.Builder;
import lombok.Data;
/**
* The model for incoming requests.
*/
@Data
@Builder
public class EndpointRequest {
@JsonProperty("method")
private final String method;
@JsonProperty("args")
private final LinkedHashMap<String, JsonNode> args;
}
@@ -0,0 +1,19 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;
/**
* The model for replied response.
*/
@Getter
@Setter
public abstract class EndpointResponse {
@JsonProperty("type")
protected final String type;
protected EndpointResponse(final String type) {
this.type = type;
}
}
@@ -0,0 +1,84 @@
package com.cleverthis.authservice.controller.rabbitmq.model;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointInternalServerErrorException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* The model for replied response.
*/
public class EndpointResponses {
private EndpointResponses() {
}
/**
* Create an error response from endpoint exception.
*/
public static EndpointErrorResponse error(
final EndpointException ex
) {
final var sw = new StringWriter();
final var pw = new PrintWriter(sw);
ex.printStackTrace(pw);
pw.flush();
pw.close();
return EndpointErrorResponse.builder()
.exception(ex.getEndpointException())
.message(ex.getMessage())
.additionalInfo(Map.of(
"stacktrace", sw.toString()
))
.build();
}
/**
* Create an error response from general exception.
*/
public static EndpointErrorResponse internalServerError(
final String message,
final Throwable throwable
) {
return EndpointResponses.error(
new EndpointInternalServerErrorException(
message, throwable
)
);
}
/**
* Create an ok response from a result.
*/
public static <T> EndpointOkResponse ok(final T result) {
return EndpointOkResponse.builder()
.result(List.of(result))
.build();
}
/**
* Create an ok response from a list of results.
*/
public static <T> EndpointOkResponse ok(final Iterable<T> result) {
final var list = new LinkedList<T>();
result.forEach(list::add);
return EndpointOkResponse.builder()
.result(list)
.build();
}
/**
* Create an ok response from an array of results.
*/
public static <T> EndpointOkResponse ok(final T[] result) {
// here we make a copy so the changes to the input array
// will not change the result field.
final var list = new LinkedList<>(Arrays.asList(result));
return EndpointOkResponse.builder()
.result(list)
.build();
}
}
@@ -0,0 +1,70 @@
package com.cleverthis.authservice.controller.rabbitmq.v1.user;
import com.cleverthis.authservice.controller.rabbitmq.AbstractEndpoint;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses;
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.NoSuchElementException;
import org.keycloak.representations.idm.UserRepresentation;
import org.springframework.stereotype.Component;
/**
* Serve endpoints on `user-service-v1`.
*/
@Component
public class UserEndpointV1 extends AbstractEndpoint {
private static final String ROUTING_KEY = "user-service-v1";
private final KeycloakAdminReactiveClient keycloakAdminClient;
public UserEndpointV1(
final CleverMicroAmqpClient client,
final ObjectMapper objectMapper,
final KeycloakAdminReactiveClient keycloakAdminClient
) throws IOException {
super(client, objectMapper, ROUTING_KEY);
this.keycloakAdminClient = keycloakAdminClient;
}
@Override
protected void handleRequest(
final HandlerContext ctx, final EndpointRequest request
) throws EndpointException {
//noinspection SwitchStatementWithTooFewBranches
final Object result = switch (request.getMethod()) {
case "queryUserById" -> queryUserById(request);
default -> throw new EndpointBadRequestException("Method not found");
};
reply(ctx, EndpointResponses.ok(result));
}
private UserRepresentation queryUserById(
final EndpointRequest request
) throws EndpointException {
final var argId = request.getArgs().get("id");
if (argId == null) {
throw new EndpointBadRequestException("Missing parameter 'id'");
}
if (!argId.isTextual()) {
throw new EndpointBadRequestException(
"Invalid type for parameter 'id', required non-null string");
}
// this should be non-null
final var id = argId.textValue();
try {
return this.keycloakAdminClient.getUserDetails(id).block();
} catch (final NoSuchElementException ex) {
throw new EndpointException(
"cleverthis.clevermicro.auth.user_not_found",
"User id " + id + "not found",
ex
);
}
}
}
@@ -0,0 +1,29 @@
package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the Keycloak Admin token response (Client Credentials Grant).
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakAdminTokenResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("expires_in")
private int expiresIn;
@JsonProperty("refresh_expires_in")
private int refreshExpiresIn;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("not-before-policy")
private int notBeforePolicy;
@JsonProperty("scope")
private String scope;
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO to represent a Keycloak Client when fetched from Admin API.
* We need this to get the internal 'id' (UUID) of a client from its 'clientId'.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakClientRepresentation {
private String id; // This is the internal UUID
@JsonProperty("client_Id") // Maps the JSON field 'clientId' to this Java field
private String publicClientId; // The human-readable client_id
private String name;
private String description;
private boolean enabled;
private List<String> defaultRoles;
private Map<String, String> attributes;
}
@@ -0,0 +1,23 @@
package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO to represent a Keycloak Role when fetched from Admin API.
* We are primarily interested in the 'name' of the role.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakRoleRepresentation {
private String id;
private String name;
private String description;
private Boolean composite;
private Boolean clientRole;
private String containerId; // For client roles, this is the client's internal ID
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the /login request body.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
@NotBlank(message = "Username cannot be blank")
private String username;
@NotBlank(message = "Password cannot be blank")
private String password;
}
@@ -0,0 +1,18 @@
package com.cleverthis.authservice.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the /logout request body.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LogoutRequest {
@NotBlank(message = "Refresh token cannot be blank")
private String refreshToken;
}
@@ -0,0 +1,38 @@
package com.cleverthis.authservice.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the /register request body.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegistrationRequest {
@NotBlank(message = "First name cannot be blank")
@Size(min = 1, max = 50, message = "First name must be between 1 and 50 characters")
private String firstName;
@NotBlank(message = "Last name cannot be blank")
@Size(min = 1, max = 50, message = "Last name must be between 1 and 50 characters")
private String lastName;
@NotBlank(message = "Email cannot be blank")
@Email(message = "Email should be valid")
private String email;
@NotBlank(message = "Password cannot be blank")
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
// Add more password complexity validation if needed via custom validator or regex pattern
private String password;
// Optional: username, if different from email and the Keycloak setup requires it
// @NotBlank(message = "Username cannot be blank")
// private String username;
}
@@ -0,0 +1,45 @@
package com.cleverthis.authservice.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO representing a successful token response from Keycloak (or similar). Contains the fields
* typically returned by an OAuth2 token endpoint.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("expires_in")
private int expiresIn;
@JsonProperty("refresh_expires_in")
private int refreshExpiresIn;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("id_token")
private String idToken;
@JsonProperty("not-before-policy")
private int notBeforePolicy;
@JsonProperty("session_state")
private String sessionState;
@JsonProperty("scope")
private String scope;
}
@@ -0,0 +1,37 @@
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;
/**
* DTO representing the response from Keycloak's token introspection endpoint.
* Also serves as the response for our /verify endpoint and basis for /auth headers.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VerificationResponse {
private boolean active; // Standard introspection field: is the token active?
@JsonProperty("client_id")
private String clientId;
private String username; // Often same as preferred_username
private String scope;
private String sub; // Subject (user ID) - Use this for X-User-Id
private long exp; // Expiration timestamp
private long iat; // Issued at timestamp
private long nbf; // Not before timestamp
private List<String> aud; // Audience - Can be a list if token has multiple audiences
private String iss; // Issuer
private String typ; // Type (e.g., Bearer)
private String email; // Standard OIDC claim
@JsonProperty("email_verified")
private Boolean emailVerified; // Standard OIDC claim
@JsonProperty("preferred_username")
private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name
private List<String> groups;
}
@@ -0,0 +1,18 @@
package com.cleverthis.authservice.dto.group;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for adding an existing user to a group.
* This can be reused from the organization membership logic.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddGroupMemberRequest {
@NotBlank(message = "User ID cannot be blank")
private String userId; // The Keycloak user ID (sub)
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.group; // New package for group-related DTOs
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when creating a new sub-group (e.g., department, team)
* within an organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateGroupRequest {
@NotBlank(message = "Group name cannot be blank")
private String name;
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.dto.group;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing a group in API responses.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupResponse {
private String id;
private String name;
private String path; // The full path of the group (e.g., /tenant-org/departments/engineering)
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto.group;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when updating a sub-group.
* The name of a group is typically not updatable via this DTO; it's part of the path.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateGroupRequest {
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak Group when interacting
* with the Keycloak Admin API.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KeycloakGroupRepresentation {
private String id;
private String name;
private String path;
private String description; // Note: Description is often stored in attributes
private Map<String, List<String>> attributes;
private List<KeycloakGroupRepresentation> subGroups;
}
@@ -0,0 +1,22 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for creating an invitation request to join an organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakInvitationRequest {
private String email;
private String firstName;
private String lastName;
// You can also specify roles to be redirected to after accepting
// private List<String> redirectRoles;
}
@@ -0,0 +1,31 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
/**
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
* Admin API.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
// Include only non-null fields during serialization, useful for update operations
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KeycloakOrganizationRepresentation {
private String id;
private String name;
private Set<OrganizationDomainRepresentation> domains;
private String description;
private Boolean enabled;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak User, typically when listing members of an
* organization. It includes only the most relevant fields.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakUserRepresentation {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean enabled;
private Long createdTimestamp;
}
@@ -0,0 +1,17 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for adding an existing user to an Organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class AddMemberRequest {
@NotBlank(message = "User ID cannot be blank")
private String userId; // The Keycloak user ID (sub)
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when creating a new Organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateOrganizationRequest {
@NotBlank(message = "Organization name cannot be blank")
private String name; // This will become the internal name in Keycloak
@NotEmpty(message = "At least one organization domain must be provided")
private List<String> domains;
private String description;
private Map<String, List<String>> attributes; // For custom metadata like displayName
}
@@ -0,0 +1,26 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for inviting a new or existing user to an Organization via email.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class InviteUserRequest {
@NotBlank(message = "Email cannot be blank")
@Email(message = "A valid email address is required")
private String email;
// Optional fields if inviting a brand new user
private String firstName;
private String lastName;
// You could also add a list of roles/groups to assign upon joining
// private List<String> rolesToAssign;
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.dto.organization;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing a member of an Organization. This mirrors Keycloak's UserRepresentation for
* relevant fields.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class OrganizationMemberResponse {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean enabled;
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.organization;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
/**
* DTO for representing an Organization in API responses.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationResponse {
private String id;
private String name;
private Set<OrganizationDomainRepresentation> domains;
private String description;
private Boolean enabled;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto.organization;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when updating an Organization.
* Name is typically not updatable, but description and attributes are.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateOrganizationRequest {
private List<String> domains;
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.exception;
/**
* Custom exception for authentication failures.
*/
public class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
public AuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,127 @@
package com.cleverthis.authservice.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
/**
* Global Exception Handler to translate custom exceptions into appropriate HTTP responses using
* Problem Details (RFC 7807).
*/
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
/**
* Map {@link AuthenticationException} to HTTP 401.
*/
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleAuthenticationException(AuthenticationException ex) {
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
problemDetail.setTitle("Authentication Failed");
// Add more details if needed
// problemDetail.setProperty("details", "Check username/password or Keycloak status.");
return problemDetail;
}
/**
* Map {@link TokenVerificationException} to HTTP 401.
*/
@ExceptionHandler(TokenVerificationException.class)
public ProblemDetail handleTokenVerificationException(TokenVerificationException ex) {
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
problemDetail.setTitle("Token Verification Failed");
return problemDetail;
}
/**
* Map {@link LogoutException} to HTTP 400 or 500.
*/
@ExceptionHandler(LogoutException.class)
public ProblemDetail handleLogoutException(LogoutException ex) {
// Depending on the cause, might return 400 or 500
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
problemDetail.setTitle("Logout Failed");
return problemDetail;
}
/**
* Map {@link PermissionCheckException} to HTTP 403.
*/
@ExceptionHandler(PermissionCheckException.class) // New Handler
public ProblemDetail handlePermissionCheckException(PermissionCheckException ex) {
// Usually a permission check failure should result in 403 Forbidden
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage());
problemDetail.setTitle("Access Denied");
return problemDetail;
}
/**
* Map {@link ServiceUnavailableException} to HTTP 503.
*/
@ExceptionHandler(ServiceUnavailableException.class) // New Handler
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
// Error communicating with Keycloak Admin API or other critical backend
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.");
problemDetail.setTitle("Service Unavailable");
return problemDetail;
}
/**
* Map {@link UserAlreadyExistsException} to HTTP 409.
*/
@ExceptionHandler(UserAlreadyExistsException.class)
public ProblemDetail handleUserAlreadyExistsException(UserAlreadyExistsException ex) {
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("User Registration Conflict");
return problemDetail;
}
/**
* Map {@link RegistrationException} to HTTP 500.
*/
@ExceptionHandler(RegistrationException.class)
public ProblemDetail handleRegistrationException(RegistrationException ex) {
// Could be BAD_REQUEST or INTERNAL_SERVER_ERROR depending on the cause
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
problemDetail.setTitle("User Registration Failed");
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) {
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");
return problemDetail;
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.exception;
/**
* Custom exception for logout failures.
*/
public class LogoutException extends RuntimeException {
public LogoutException(String message) {
super(message);
}
public LogoutException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.exception;
/**
* Custom exception for errors during permission checks.
*/
public class PermissionCheckException extends RuntimeException {
public PermissionCheckException(String message) {
super(message);
}
public PermissionCheckException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,18 @@
package com.cleverthis.authservice.exception;
/**
* RegistrationException.
*/
public class RegistrationException extends RuntimeException {
private static final long serialVersionUID = 2301783309630884721L;
public RegistrationException(String message) {
super(message);
}
public RegistrationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -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);
}
}
@@ -0,0 +1,16 @@
package com.cleverthis.authservice.exception;
/**
* Custom exception for when a required external service (like Keycloak Admin API)
* is unavailable or returns an error.
*/
public class ServiceUnavailableException extends RuntimeException {
public ServiceUnavailableException(String message) {
super(message);
}
public ServiceUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.exception;
/**
* Custom exception for token verification failures.
*/
public class TokenVerificationException extends RuntimeException {
public TokenVerificationException(String message) {
super(message);
}
public TokenVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,17 @@
package com.cleverthis.authservice.exception;
/**
* UserAlreadyExistsException.
*/
public class UserAlreadyExistsException extends RuntimeException {
private static final long serialVersionUID = 2297555492902445706L;
public UserAlreadyExistsException(String message) {
super(message);
}
public UserAlreadyExistsException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,213 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import java.util.List;
import 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
* swapping the underlying implementation (e.g., Keycloak, Okta) without changing the controller
* logic.
*/
public interface IdentityManagerService {
/**
* Authenticates a user using username and password (Resource Owner Password Credentials Grant).
*
* @param username The user's username.
* @param password The user's password.
* @return A Mono emitting the TokenResponse upon successful authentication. Emits an error
* (e.g., AuthenticationException) if authentication fails.
*/
Mono<TokenResponse> login(String username, String password);
/**
* Verifies the validity of an access token and retrieves associated user information.
*
* @param accessToken The access token (without "Bearer " prefix).
* @return A Mono emitting the VerificationResponse containing token details and user info.
* Emits an error if the token is invalid, expired, or introspection fails.
*/
Mono<VerificationResponse> verifyToken(String accessToken);
/**
* Logs out a user session by revoking the refresh token.
*
* @param refreshToken The refresh token associated with the user session.
* @return A Mono completing successfully upon successful logout/revocation. Emits an error if
* revocation fails.
*/
Mono<Void> logout(String refreshToken);
/**
* Registers a new user in the identity provider.
* The user is typically created with emailVerified set to false.
* The implementation is responsible for initiating the email verification process
* (e.g., generating a token, triggering an email).
*
* @param registrationRequest DTO containing user registration details.
* @return A Mono completing successfully if registration initiation is successful.
* 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);
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import java.util.Optional;
/**
* Service for looking up the permission map.
*/
public interface PermissionMapLookupService {
/**
* The default client id when no rules matched.
* If not provided, might be null or empty.
*/
String getDefaultKeycloakClientId();
/**
* Given the path, find the client id of the first rule matching the path prefix.
*/
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(String path);
}
@@ -0,0 +1,115 @@
package com.cleverthis.authservice.service.impl;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.service.PermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* {@link PermissionMapLookupService} with fallback.
* <p>
* It will first look up consul, if not available,
* fallback to {@code application.yaml}
* </p>
*/
@Service
@Slf4j
@EnableScheduling
public final class FallbackPermissionMapLookupService implements PermissionMapLookupService {
/**
* The consul kv key for permission mapping.
*/
static final String PERMISSION_MAPPING_CONSUL_KEY =
"cleverthis/clevermicro/auth-service/config/permission-mapping";
private final PermissionMappingConfigProperties permissionMappingConfigProperties;
private final ConsulClient consulClient;
private final ObjectMapper objectMapper;
/**
* Create an instance of {@link FallbackPermissionMapLookupService}.
*
* @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 PermissionMappingConfigProperties permissionMappingConfigProperties,
final ConsulClient consulClient,
final ObjectMapper objectMapper
) {
this.permissionMappingConfigProperties = permissionMappingConfigProperties;
this.consulClient = consulClient;
this.objectMapper = objectMapper;
refreshConsul(); // initial refresh
}
private final AtomicReference<PermissionMappingConfigProperties> consulValue =
new AtomicReference<>();
@Scheduled(initialDelay = 1000, fixedRate = 1000)
void refreshConsul() {
try {
// clear cache
this.consulValue.set(null);
// fetch value
final var resp = this.consulClient.getKVValue(PERMISSION_MAPPING_CONSUL_KEY);
if (resp == null) {
return; // skip if no response
}
final var kvValue = resp.getValue();
if (kvValue.getDecodedValue() == null) {
// skip if no value provided
return;
}
try { // try decoding
final var config = this.objectMapper.readValue(
kvValue.getDecodedValue(), PermissionMappingConfigProperties.class);
this.consulValue.set(config);
} catch (final JsonProcessingException ex) {
// got a corrupted value, leaving the cache empty
// will fall back to local value when query
log.error("Corrupted config value from consul for key {}: {}",
PERMISSION_MAPPING_CONSUL_KEY, kvValue.getValue(), ex);
}
} catch (final Throwable ex) {
// catch-all
log.error("Unknown error when updating consul value for permission mapping", ex);
}
}
PermissionMappingConfigProperties getConfig() {
final var consulValue = this.consulValue.get();
if (consulValue != null) {
return consulValue;
} else {
return this.permissionMappingConfigProperties;
}
}
@Override
public String getDefaultKeycloakClientId() {
return getConfig().getDefaultKeycloakClientId();
}
@Override
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);
}
}
return Optional.empty();
}
}
@@ -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));
}
}
@@ -0,0 +1,432 @@
package com.cleverthis.authservice.service.impl;
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.PermissionCheckException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService;
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.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.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
/**
* Keycloak-specific implementation of the IdentityManagerService. Uses WebClient to interact with
* Keycloak's REST API endpoints.
*/
@Service
@Slf4j
public class KeycloakIdentityManagerService implements IdentityManagerService {
private final WebClient webClient;
private final KeycloakAdminReactiveClient keycloakAdminClient; // Inject KeycloakAdminClient
private final String keycloakServerUrl;
private final String realm;
private final String clientId;
private final String clientSecret;
private String getTokenEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
.path("/realms/{realm}/protocol/openid-connect/token")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getIntrospectionEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
.path("/realms/{realm}/protocol/openid-connect/token/introspect")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getRevocationEndpoint() {
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(
@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
public Mono<TokenResponse> login(String username, String password) {
log.debug("Attempting login for user: {}", username);
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.clientId);
formData.add("client_secret", this.clientSecret);
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()));
}
@Override
public Mono<VerificationResponse> verifyToken(String accessToken) {
log.debug("Verifying access token");
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.clientId);
formData.add("client_secret", this.clientSecret);
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()));
}
@Override
public Mono<Void> logout(String refreshToken) {
log.debug("Attempting logout (token revocation)");
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", this.clientId);
formData.add("client_secret", this.clientSecret);
formData.add("token", refreshToken);
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()));
}
@Override
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
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());
}
}
+79
View File
@@ -0,0 +1,79 @@
# Spring Boot application configuration
server:
port: ${AUTH_SERVICE_PORT:8099} # Port the auth-service will run on
spring:
application:
name: auth-service
cloud:
consul:
host: ${CONSUL_HOST:consul}
port: ${CONSUL_PORT:8500}
config:
import-check:
enabled: false
# Keycloak Configuration (adjust values as needed)
keycloak:
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:
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")
# The 'pathPrefix' will be matched against the X-Forwarded-Uri
rules:
- pathPrefix: "/auth-service/" # If X-Forwarded-Uri starts with /cleverswarm/
keycloakClientId: "auth-service" # ...then permissions are checked against this Keycloak client
# Add more rules as needed
# 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
# 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
@@ -0,0 +1,370 @@
package com.cleverthis.authservice;
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
import com.cleverthis.authservice.controller.AuthController;
import com.cleverthis.authservice.dto.LoginRequest;
import com.cleverthis.authservice.dto.LogoutRequest;
import com.cleverthis.authservice.dto.TokenResponse;
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.TokenVerificationException;
import com.cleverthis.authservice.service.IdentityManagerService;
import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
import com.ecwid.consul.v1.ConsulClient;
import java.util.ArrayList;
import java.util.List;
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.HttpHeaders;
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 AuthController using WebFluxTest and Mockito. Focuses on testing the
* controller logic and request/response handling.
*/
@WebFluxTest(AuthController.class)
@Import({GlobalExceptionHandler.class,
PermissionMappingConfigProperties.class,
FallbackPermissionMapLookupService.class})
class AuthControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
@MockitoBean
private PermissionMappingConfigProperties permissionMappingConfigProperties;
// required by the fallback permission mapping lookup service
// provide a mocked one
@MockitoBean
private ConsulClient consulClient;
private TokenResponse mockTokenResponse;
private VerificationResponse mockUserVerificationResponse;
// --- Constants for Header Names (mirroring controller) ---
private static final String HEADER_USER_ID = "X-User-Id";
private static final String HEADER_USER_NAME = "X-User-Name";
private static final String HEADER_USER_EMAIL = "X-User-Email";
private static final String HEADER_USER_GROUPS = "X-User-Groups";
// --- Traefik Forwarded Headers ---
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri";
@BeforeEach
void setUp() {
this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer",
"id-789", 0, "state", "openid");
this.mockUserVerificationResponse =
new VerificationResponse(true, "test-client", "testuser", "openid", "user-sub-123",
System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000,
System.currentTimeMillis() / 1000, List.of("aud1"), "iss", "Bearer",
"test@example.com", true, "testuser", List.of("groupA"));
// Setup mock PermissionMappingConfig - Default to having some rules
PermissionMappingConfigProperties.Rule rule1 = new PermissionMappingConfigProperties.Rule();
rule1.setPathPrefix("/serviceA/");
rule1.setKeycloakClientId("serviceA-client");
PermissionMappingConfigProperties.Rule rule2 = new PermissionMappingConfigProperties.Rule();
rule2.setPathPrefix("/serviceB/items/");
rule2.setKeycloakClientId("serviceB-client");
List<PermissionMappingConfigProperties.Rule> rules = new ArrayList<>();
rules.add(rule1);
rules.add(rule2);
// Mock getRules() to return the list
when(this.permissionMappingConfigProperties.getRules()).thenReturn(rules);
// Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
// specific tests
when(this.permissionMappingConfigProperties.getDefaultKeycloakClientId()).thenReturn(null);
}
// --- /login Tests ---
@Test
void login_Success_ReturnsTokenResponse() {
LoginRequest loginRequest = new LoginRequest("testuser", "password");
when(this.identityManagerService.login("testuser", "password"))
.thenReturn(Mono.just(this.mockTokenResponse));
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isOk() // Expect 200
.expectBody(TokenResponse.class)
.value(TokenResponse::getAccessToken, equalTo("access-123"))
.value(TokenResponse::getRefreshToken, equalTo("refresh-456"));
}
@Test
void login_ServiceThrowsAuthException_ReturnsUnauthorized() {
LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass");
// Mock the service to throw the specific exception handled by GlobalExceptionHandler
when(this.identityManagerService.login("wronguser", "wrongpass"))
.thenReturn(Mono.error(new AuthenticationException("Invalid credentials")));
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isUnauthorized();
}
@Test
void login_InvalidRequest_ReturnsBadRequest() {
LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request
// No need to mock service for validation errors
// The framework and GlobalExceptionHandler (via parent) should handle this
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
.bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
// --- /verify Tests ---
@Test
void verify_Success_ReturnsVerificationResponse() {
String validToken = "valid-token-123";
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
this.webTestClient.post().uri("/verify")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus()
.isOk() // Expect 200
.expectBody(VerificationResponse.class)
.value(VerificationResponse::isActive, equalTo(true))
.value(VerificationResponse::getUsername, equalTo("testuser"));
}
@Test
void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() {
String invalidToken = "invalid-token-456";
// Mock the service to throw the specific exception
when(this.identityManagerService.verifyToken(invalidToken))
.thenReturn(Mono.error(new TokenVerificationException("Token expired")));
this.webTestClient.post().uri("/verify")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange()
.expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler)
}
@Test
void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity
// No service mocking needed, framework handles missing required header
this.webTestClient.post().uri("/verify").exchange().expectStatus().isBadRequest();
}
@Test
void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
// Controller logic handles this case specifically
this.webTestClient.post().uri("/verify")
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme
.exchange().expectStatus().isUnauthorized(); // Expect 401 (handled by controller
}
// --- /auth (ForwardAuth) Tests ---
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
@Test
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
String userToken = "user-token-granted";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data"; // Matches rule1
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
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)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
}
@Test
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
String validToken = "valid-token-fw-nogroups";
VerificationResponse responseWithoutGroups = new VerificationResponse(true, "test-client",
"testuser", "openid", "user-sub-123", System.currentTimeMillis() / 1000 + 300,
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
List.of("aud1"), "iss", "Bearer", "test@example.com", true, "testuser", null);
when(this.identityManagerService.verifyToken(validToken))
.thenReturn(Mono.just(responseWithoutGroups));
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)
.header(HEADER_X_FORWARDED_METHOD, "GET")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/nogroups").exchange().expectStatus()
.isOk().expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
}
@Test
void forwardAuth_PermissionDenied_NoMatchingRole() {
String userToken = "user-token-denied";
String originalMethod = "POST";
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
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)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isForbidden()
.expectBody().isEmpty();
}
@Test
void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
String invalidUserToken = "invalid-user-token";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data";
when(this.identityManagerService.verifyToken(invalidUserToken))
.thenReturn(Mono.error(new TokenVerificationException("Token is expired")));
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidUserToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.isUnauthorized();
}
@Test
void forwardAuth_ServiceThrowsGenericExceptionDuringVerification_ReturnsUnauthorized() {
String token = "token-causing-error";
String originalMethod = "GET";
String originalUri = "/serviceA/api/data";
when(this.identityManagerService.verifyToken(token)).thenReturn(
Mono.error(new RuntimeException("Internal service error during verification")));
this.webTestClient.post().uri("/auth").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.isUnauthorized();
}
@Test
void forwardAuth_NoMatchingPathRule_ReturnsForbidden() {
String userToken = "user-token-no-rule";
String originalMethod = "GET";
String originalUri = "/unknownService/api/data"; // No rule matches this prefix
// Mock getRules to return an empty list for this specific test case, or ensure default
// setup handles it
// For this test, let's ensure the default setup from setUp() (which has rules for
// /serviceA/)
// does not match "/unknownService/"
when(this.identityManagerService.verifyToken(userToken))
.thenReturn(Mono.just(this.mockUserVerificationResponse));
// No call to checkUserClientRolePermission should happen if no rule matches
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
.isForbidden(); // Controller logic for no rule match
}
@Test
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
this.webTestClient.post().uri("/auth").header(HEADER_X_FORWARDED_METHOD, "GET")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
// Missing Authorization header
.exchange().expectStatus().isBadRequest();
}
@Test
void forwardAuth_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
// This tests the controller's specific check for "Bearer " prefix
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials")
.header(HEADER_X_FORWARDED_METHOD, "GET")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data").exchange().expectStatus()
.isUnauthorized();
}
@Test
void forwardAuth_MissingXforwardedMethodHeader_ReturnsBadRequest() {
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
// Missing X-Forwarded-Method
.exchange().expectStatus().isBadRequest();
}
@Test
void forwardAuth_MissingXforwardedUriHeader_ReturnsBadRequest() {
this.webTestClient.post().uri("/auth")
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
.header(HEADER_X_FORWARDED_METHOD, "GET")
// Missing X-Forwarded-Uri
.exchange().expectStatus().isBadRequest();
}
// --- /logout Tests ---
@Test
void logout_Success_ReturnsNoContent() {
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke");
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
.thenReturn(Mono.empty());
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204
}
@Test
void logout_ServiceThrowsException_ReturnsInternalServerError() {
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error");
// Mock service to throw the specific exception
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
.thenReturn(Mono.error(new LogoutException("Keycloak unavailable")));
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError();
}
@Test
void logout_InvalidRequest_ReturnsBadRequest() {
LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token)
// No service mocking needed
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
}
}
@@ -0,0 +1,209 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.IntraOrganizationGroupController;
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.ResourceNotFoundException;
import com.cleverthis.authservice.service.IdentityManagerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Unit tests for the IntraOrganizationGroupController. This class tests the API
* layer for sub-group management, mocking the service layer.
*/
@WebFluxTest(IntraOrganizationGroupController.class)
@Import(GlobalExceptionHandler.class)
public class IntraOrganizationGroupControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
private GroupResponse mockGroupResponse;
/**
* Sets up mock data before each test.
*/
@BeforeEach
void setUp() {
mockGroupResponse = new GroupResponse("group-id-eng", "engineering",
"/tenant-id-123/engineering",
"Engineering Department", Map.of("cost-center", List.of("ENG-101")));
}
/**
* Tests successful creation of a sub-group.
*/
@Test
void createSubGroup_Success_ReturnsCreated() {
String parentGroupId = "org-id-123";
CreateGroupRequest request = new CreateGroupRequest("engineering",
"Engineering Department", null);
when(identityManagerService.createSubGroup(eq(parentGroupId), any(CreateGroupRequest.class)))
.thenReturn(Mono.just(mockGroupResponse));
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
.exchange().expectStatus().isCreated()
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
}
/**
* Tests creation of a sub-group with invalid input.
*/
@Test
void createSubGroup_InvalidInput_ReturnsBadRequest() {
String parentGroupId = "org-id-123";
// Blank name is invalid
CreateGroupRequest request = new CreateGroupRequest("", null, null);
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isBadRequest();
}
/**
* Tests successful listing of sub-groups.
*/
@Test
void listSubGroups_Success_ReturnsListOfGroups() {
String parentGroupId = "org-id-123";
when(identityManagerService.listSubGroups(parentGroupId))
.thenReturn(Mono.just(Collections.singletonList(mockGroupResponse)));
webTestClient.get().uri("/groups/{parentGroupId}/children", parentGroupId).exchange()
.expectStatus().isOk()
.expectBodyList(GroupResponse.class).hasSize(1);
}
/**
* Tests successful retrieval of a group by its ID.
*/
@Test
void getGroupById_Exists_ReturnsGroup() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
when(identityManagerService.getGroupById(groupId)).thenReturn(Mono.just(mockGroupResponse));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}", parentGroupId, groupId)
.exchange().expectStatus().isOk()
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
}
/**
* Tests retrieval of a non-existent group.
*/
@Test
void getGroupById_NotFound_ReturnsNotFound() {
String parentGroupId = "org-id-123";
String groupId = "non-existent-group";
when(identityManagerService.getGroupById(groupId))
.thenReturn(Mono.error(new ResourceNotFoundException("Group not found")));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId).exchange()
.expectStatus().isNotFound();
}
/**
* Tests successful update of a group.
*/
@Test
void updateGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
UpdateGroupRequest request = new UpdateGroupRequest("An updated description", null);
when(identityManagerService.updateGroup(eq(groupId), any(UpdateGroupRequest.class)))
.thenReturn(Mono.empty());
webTestClient.put().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
.exchange().expectStatus().isNoContent();
}
/**
* Tests successful deletion of a group.
*/
@Test
void deleteGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
when(identityManagerService.deleteGroup(groupId)).thenReturn(Mono.empty());
webTestClient.delete().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successfully adding a member to a group.
*/
@Test
void addMemberToGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
AddGroupMemberRequest request = new AddGroupMemberRequest("user-id-to-add");
when(identityManagerService.addMemberToGroup(groupId, request.getUserId())).thenReturn(Mono.empty());
webTestClient.post().uri("/groups/{parentGroupId}/children/{groupId}/members",
parentGroupId, groupId).contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isNoContent();
}
/**
* Tests successful listing of group members.
*/
@Test
void listGroupMembers_Success_ReturnsListOfMembers() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
List<OrganizationMemberResponse> members = List.of(
new OrganizationMemberResponse("user-id-123", "abed.spring",
"Abed", "Spring", "abed@test.com", true));
when(identityManagerService.getGroupMembers(groupId)).thenReturn(Mono.just(members));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}/members",
parentGroupId, groupId).exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class).isEqualTo(members);
}
/**
* Tests successfully removing a member from a group.
*/
@Test
void removeMemberFromGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
String userId = "user-to-remove";
when(identityManagerService.removeMemberFromGroup(groupId, userId)).thenReturn(Mono.empty());
webTestClient.delete()
.uri("/groups/{parentGroupId}/children/{groupId}/members/{userId}",
parentGroupId, groupId, userId)
.exchange().expectStatus().isNoContent();
}
}
@@ -0,0 +1,208 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.OrganizationController;
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.ResourceNotFoundException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
/**
* Unit tests for the OrganizationController. This class tests the API layer for organization
* management, mocking the service layer.
*/
@WebFluxTest(OrganizationController.class)
@Import(GlobalExceptionHandler.class)
public class OrganizationControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
private OrganizationResponse mockOrgResponse;
private List<OrganizationResponse> mockOrgResponseList;
/**
* Sets up mock data before each test.
*/
@BeforeEach
void setUp() {
// Create the correct domain representation object
final var domain = new OrganizationDomainRepresentation("test-org.com");
domain.setVerified(true);
this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain),
"A test organization", true, Map.of("displayName", List.of("Test Org")));
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
}
/**
* Tests successful creation of an organization.
*/
@Test
void createOrganization_Success_ReturnsCreatedWithBody() {
CreateOrganizationRequest request = new CreateOrganizationRequest("test-org",
List.of("test-org.com"), "A test org", null);
when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class)))
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isCreated()
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
}
/**
* Tests organization creation with invalid input, expecting a Bad Request.
*/
@Test
void createOrganization_InvalidInput_ReturnsBadRequest() {
CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null);
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
/**
* Tests successful retrieval of all organizations.
*/
@Test
void getOrganizations_Success_ReturnsListOfOrgs() {
when(this.identityManagerService.getOrganizations())
.thenReturn(Mono.just(this.mockOrgResponseList));
this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk()
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
}
/**
* Tests successful retrieval of a single organization by its ID.
*/
@Test
void getOrganizationById_Exists_ReturnsOrg() {
when(this.identityManagerService.getOrganizationById("org-id-123"))
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isOk().expectBody(OrganizationResponse.class)
.isEqualTo(this.mockOrgResponse);
}
/**
* Tests retrieval of a non-existent organization, expecting Not Found.
*/
@Test
void getOrganizationById_NotFound_ReturnsNotFound() {
when(this.identityManagerService.getOrganizationById("non-existent-id"))
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange()
.expectStatus().isNotFound();
}
/**
* Tests successful update of an organization.
*/
@Test
void updateOrganization_Success_ReturnsNoContent() {
UpdateOrganizationRequest request =
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
when(this.identityManagerService.updateOrganization(eq("org-id-123"),
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successful deletion of an organization.
*/
@Test
void deleteOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty());
this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isNoContent();
}
/**
* Tests successful invitation of a user to an organization.
*/
@Test
void inviteUserToOrganization_Success_ReturnsOk() {
InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User");
when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"),
any(InviteUserRequest.class))).thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isOk();
}
/**
* Tests successful retrieval of organization members.
*/
@Test
void getOrganizationMembers_Success_ReturnsListOfMembers() {
List<OrganizationMemberResponse> members =
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
"test@test.com", true));
when(this.identityManagerService.getOrganizationMembers("org-id-123"))
.thenReturn(Mono.just(members));
this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
.isEqualTo(members);
}
/**
* Tests successfully adding a member to an organization.
*/
@Test
void addMemberToOrganization_Success_ReturnsNoContent() {
AddMemberRequest request = new AddMemberRequest("user-to-add-id");
when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id"))
.thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successfully removing a member from an organization.
*/
@Test
void removeMemberFromOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.removeMemberFromOrganization("org-id-123",
"user-to-remove-id")).thenReturn(Mono.empty());
this.webTestClient.delete()
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
.exchange().expectStatus().isNoContent();
}
}
@@ -0,0 +1,104 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.UserRegistrationController;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService;
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;
/**
* Test class for /register in UserRegistrationController.
*/
@WebFluxTest(UserRegistrationController.class)
@Import(GlobalExceptionHandler.class)
public class UserRegistrationControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean // Mocks the IdentityManagerService
private IdentityManagerService identityManagerService;
@Test
void registerUser_Success_ReturnsCreated() {
RegistrationRequest request =
new RegistrationRequest("Test", "User", "newuser@example.com", "Password123!");
// Mock the service layer to complete successfully
when(this.identityManagerService.registerUser(any(RegistrationRequest.class)))
.thenReturn(Mono.empty()); // registerUser returns Mono<Void>
// Expect 201 CREATED
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isCreated();
}
@Test
void registerUser_EmailAlreadyExists_ReturnsConflict() {
RegistrationRequest request =
new RegistrationRequest("Test", "User", "existinguser@example.com", "Password123!");
// Mock the service layer to throw UserAlreadyExistsException
when(this.identityManagerService.registerUser(any(RegistrationRequest.class)))
.thenReturn(Mono.error(new UserAlreadyExistsException(
"User with email existinguser@example.com already exists.")));
// Expect 409 CONFLICT (handled by GlobalExceptionHandler)
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().is5xxServerError();
}
@Test
void registerUser_InvalidEmailFormat_ReturnsBadRequest() {
// Invalid email
RegistrationRequest request =
new RegistrationRequest("Test", "User", "invalidemail", "Password123!");
// No service mocking needed as @Valid annotation should trigger validation error
// Expect 400 BAD_REQUEST
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
@Test
void registerUser_PasswordTooShort_ReturnsBadRequest() {
// Password too short
RegistrationRequest request =
new RegistrationRequest("Test", "User", "user@example.com", "pass");
// Expect 400 BAD_REQUEST
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
@Test
void registerUser_MissingFirstName_ReturnsBadRequest() {
RegistrationRequest request =
new RegistrationRequest(null, "User", "user@example.com", "Password123!");
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
@Test
void registerUser_GenericRegistrationFailure_ReturnsInternalServerError() {
RegistrationRequest request =
new RegistrationRequest("Test", "User", "erroruser@example.com", "Password123!");
// Mock the service layer to throw a generic RegistrationException
when(this.identityManagerService.registerUser(any(RegistrationRequest.class))).thenReturn(
Mono.error(new RegistrationException("A generic Keycloak error occurred.")));
// Expect 500 (handled by GlobalExceptionHandler for RegistrationException)
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().is5xxServerError();
}
}
@@ -0,0 +1,40 @@
package com.cleverthis.authservice.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {KeycloakClientConfigPropertiesTest.class})
@EnableConfigurationProperties({KeycloakClientConfigProperties.class})
@TestPropertySource(properties = {
"keycloak.keycloak-host=https://keycloak.example.com",
"keycloak.keycloak-admin-host=https://keycloak-admin.example.com",
"keycloak.realm=cm-test",
"keycloak.client-id=test-service",
"keycloak.client-secret=secret-here",
"keycloak.admin-account-username=admin",
"keycloak.admin-account-password=admin-passwd"
})
class KeycloakClientConfigPropertiesTest {
@Autowired
private KeycloakClientConfigProperties config;
@Test
void testConfigParsing() {
assertEquals("https://keycloak.example.com", this.config.getKeycloakHost());
assertEquals("https://keycloak-admin.example.com", this.config.getKeycloakAdminHost());
assertEquals("cm-test", this.config.getRealm());
assertEquals("test-service", this.config.getClientId());
assertEquals("secret-here", this.config.getClientSecret());
assertEquals("admin", this.config.getAdminAccountUsername());
assertEquals("admin-passwd", this.config.getAdminAccountPassword());
}
}
@@ -0,0 +1,12 @@
package com.cleverthis.authservice.config;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import org.junit.jupiter.api.Test;
class KeycloakClientConfigTest {
@Test
void testConstructor() {
assertDoesNotThrow(KeycloakClientConfig::new);
}
}
@@ -0,0 +1,15 @@
package com.cleverthis.authservice.config;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
public class WebClientConfigTest {
private final WebClientConfig config = new WebClientConfig();
@Test
public void testKeycloakWebClientNotNull() {
assertNotNull(this.config.keycloakWebClient(), "WebClient bean should not be null");
}
}
@@ -0,0 +1,238 @@
package com.cleverthis.authservice.controller.rabbitmq;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.notNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointErrorResponse;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointOkResponse;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerSubmodule;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.BuiltinExchangeType;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class AbstractEndpointTest {
private final CleverMicroAmqpClient client = Mockito.mock(CleverMicroAmqpClient.class);
final ObjectMapper objectMapper = Mockito.spy(new ObjectMapper());
private class TestEndpoint extends AbstractEndpoint {
TestEndpoint() throws Exception {
super(
AbstractEndpointTest.this.client,
AbstractEndpointTest.this.objectMapper,
"test-key"
);
}
// The throws is needed for mocked object to throw exception during test
@SuppressWarnings("RedundantThrows")
@Override
protected void handleRequest(
final HandlerContext ctx, final EndpointRequest request
) throws EndpointException {
}
}
private final HandlerSubmodule handlerSubmodule = Mockito.mock(HandlerSubmodule.class);
@BeforeEach
void setup() {
when(this.client.getHandlerManager()).thenReturn(this.handlerSubmodule);
}
@Test
void testInitAndClose() throws Exception {
when(this.client.getHandlerManager().registerHandler(
anyString(), eq(""), eq(1),
notNull(), notNull()
)).thenReturn("test-tag");
final var testEndpoint = new TestEndpoint();
verify(this.client.getHandlerManager()).declareExchange(
"cleverthis.clevermicro.auth.endpoints", BuiltinExchangeType.DIRECT
);
verify(this.client.getHandlerManager()).declareQueue(
"cleverthis.clevermicro.auth.endpoints.test-key",
true, false, false, Map.of()
);
verify(this.client.getHandlerManager()).bindQueue(
"cleverthis.clevermicro.auth.endpoints.test-key",
"cleverthis.clevermicro.auth.endpoints",
"test-key", Map.of()
);
testEndpoint.close();
verify(this.client.getHandlerManager()).cancelHandler("test-tag");
}
@Test
void testHandler() throws Throwable {
final var testEndpoint = Mockito.spy(new TestEndpoint());
final var handlerContext = Mockito.mock(HandlerContext.class);
// silent reply method, it will be tested separately
doNothing().when(testEndpoint).reply(eq(handlerContext), notNull());
// parse req null
doReturn(null).when(testEndpoint).parseRequest(notNull());
testEndpoint.handleRabbitMessage(handlerContext);
verify(testEndpoint).reply(eq(handlerContext), argThat(it -> {
final var resp = (EndpointErrorResponse) it;
return resp.getException().equals("cleverthis.clevermicro.bad_request")
&& resp.getMessage().equals("Malformed request, cannot be parsed");
}));
// path: parse req ok
Mockito.clearInvocations(testEndpoint);
// handle req ok
doReturn(EndpointRequest.builder().build()).when(testEndpoint).parseRequest(notNull());
doNothing().when(testEndpoint).handleRequest(notNull(), notNull());
assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext));
verify(testEndpoint, never()).reply(notNull(), notNull());
// handle req endpoint exception
Mockito.clearInvocations(testEndpoint);
doThrow(new EndpointException("test", "message"))
.when(testEndpoint).handleRequest(notNull(), notNull());
assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext));
verify(testEndpoint).reply(notNull(), argThat(it -> {
final var resp = (EndpointErrorResponse) it;
return resp.getException().equals("test")
&& resp.getMessage().equals("message");
}));
// handle req any exception
Mockito.clearInvocations(testEndpoint);
doThrow(new RuntimeException("test"))
.when(testEndpoint).handleRequest(notNull(), notNull());
assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext));
verify(testEndpoint).reply(notNull(), argThat(it -> {
final var resp = (EndpointErrorResponse) it;
return resp.getException().equals("cleverthis.clevermicro.server_internal_error");
}));
}
@Test
void testParseRequest_SingleStream() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var inputStream = Mockito.mock(InputStream.class);
when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream));
final var req = EndpointRequest.builder().method("testMethod").build();
doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class);
final var result = testEndpoint.parseRequest(handlerContext);
assertEquals(req, result);
}
@Test
void testParseRequest_MultiStream() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var inputStream = Mockito.mock(InputStream.class);
when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.of(
Map.of("request", inputStream)
));
final var req = EndpointRequest.builder().method("testMethod").build();
doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class);
final var result = testEndpoint.parseRequest(handlerContext);
assertEquals(req, result);
}
@Test
void testReply_SuccessfulSerialization() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var response = Map.of("key", "value");
assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response));
verify(handlerContext).finish("""
{
"key" : "value"
}
""".trim().getBytes(StandardCharsets.UTF_8));
}
@Test
void testReply_SerializationFailure() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var response = mock(EndpointOkResponse.class);
// throw error when accessing fields, causing jackson failed to serialize
when(response.getResult()).thenThrow(new RuntimeException("test"));
assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response));
verify(handlerContext, never()).finish(any());
verify(handlerContext).refillAvailability();
}
@Test
void testReply_ReplyFailure() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var response = Map.of("key", "value");
doThrow(new IOException("Reply error")).when(handlerContext).finish(notNull());
assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response));
}
@Test
void testParseRequest_InvalidRequest() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
final var inputStream = Mockito.mock(InputStream.class);
when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream));
doThrow(new IOException("test")).when(this.objectMapper)
.readValue(inputStream, EndpointRequest.class);
final var result = testEndpoint.parseRequest(handlerContext);
assertNull(result);
}
@Test
void testParseRequest_NoStream() throws Exception {
//noinspection resource
final var testEndpoint = new TestEndpoint();
final var handlerContext = Mockito.mock(HandlerContext.class);
when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.empty());
when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.empty());
final var result = testEndpoint.parseRequest(handlerContext);
assertNull(result);
}
}
@@ -0,0 +1,119 @@
package com.cleverthis.authservice.controller.rabbitmq.v1.user;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException;
import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest;
import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses;
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient;
import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.idm.UserRepresentation;
import reactor.core.publisher.Mono;
class UserEndpointV1Test {
private final CleverMicroAmqpClient client = mock();
private final ObjectMapper objectMapper = spy(new ObjectMapper());
private final KeycloakAdminReactiveClient keycloakAdminClient = mock();
private UserEndpointV1 userEndpointV1;
@BeforeEach
void setup() throws IOException {
when(this.client.getHandlerManager()).thenReturn(mock());
this.userEndpointV1 = spy(new UserEndpointV1(
this.client, this.objectMapper, this.keycloakAdminClient));
}
@Test
void testHandler_MethodNotFound() {
final var req = mock(EndpointRequest.class);
when(req.getMethod()).thenReturn("method that doesn't exist");
final var ex = assertThrows(EndpointBadRequestException.class,
() -> this.userEndpointV1.handleRequest(mock(), req));
assertEquals("Method not found", ex.getMessage());
}
private EndpointRequest createRequest(String method, Map<String, ?> args) {
final String json;
try {
json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(args);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
final LinkedHashMap<String, JsonNode> convertedArgs;
try {
convertedArgs = this.objectMapper.readValue(json, new TypeReference<>() {
});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return EndpointRequest.builder().method(method).args(convertedArgs).build();
}
@Test
void testHandler_QueryUserById() throws Exception {
final var handlerContext = mock(HandlerContext.class);
// normal path
var req = createRequest("queryUserById", Map.of("id", "test-id"));
final var returnedUser = new UserRepresentation();
returnedUser.setId("test-id");
returnedUser.setUsername("test-username");
when(this.keycloakAdminClient.getUserDetails("test-id"))
.thenReturn(Mono.just(returnedUser));
this.userEndpointV1.handleRequest(handlerContext, req);
verify(handlerContext).finish(
this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(
EndpointResponses.ok(returnedUser)
)
);
// missing args
req = createRequest("queryUserById", Map.of("not-id", "test-id"));
{ // scope for final copy
final var finalReq = req;
assertThrows(EndpointBadRequestException.class,
() -> this.userEndpointV1.handleRequest(handlerContext, finalReq));
}
// invalid args
req = createRequest("queryUserById", Map.of("id", 123));
{ // scope for final copy
final var finalReq = req;
assertThrows(EndpointBadRequestException.class,
() -> this.userEndpointV1.handleRequest(handlerContext, finalReq));
}
// user not found
when(this.keycloakAdminClient.getUserDetails("test-id"))
.thenReturn(Mono.error(new NoSuchElementException("test exception")));
req = createRequest("queryUserById", Map.of("id", "test-id"));
{ // scope for final copy
final var finalReq = req;
final var ex = assertThrows(EndpointException.class,
() -> this.userEndpointV1.handleRequest(handlerContext, finalReq));
assertEquals("cleverthis.clevermicro.auth.user_not_found", ex.getEndpointException());
}
}
}
@@ -0,0 +1,145 @@
package com.cleverthis.authservice.exception;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
class GlobalExceptionHandlerTest {
private GlobalExceptionHandler globalExceptionHandler;
@BeforeEach
void setUp() {
this.globalExceptionHandler = new GlobalExceptionHandler();
}
@Test
void testHandleAuthenticationException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Authentication failed";
final var exception = new AuthenticationException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleAuthenticationException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Authentication Failed", result.getTitle());
}
@Test
void testHandleTokenVerificationException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Token verification failed";
final var exception = new TokenVerificationException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleTokenVerificationException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Token Verification Failed", result.getTitle());
}
@Test
void testHandleLogoutException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Logout failed";
final var exception = new LogoutException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleLogoutException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Logout Failed", result.getTitle());
}
@Test
void testHandlePermissionCheckException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Access denied";
final var exception = new PermissionCheckException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handlePermissionCheckException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.FORBIDDEN.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("Access Denied", result.getTitle());
}
@Test
void testHandleServiceUnavailableException_ReturnsProblemDetail() {
// Arrange
final var exception = new ServiceUnavailableException("test");
// Act
final var result = this.globalExceptionHandler.handleServiceUnavailableException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.SERVICE_UNAVAILABLE.value(), result.getStatus());
assertEquals("An external service required for authorization is currently unavailable.",
result.getDetail());
assertEquals("Service Unavailable", result.getTitle());
}
@Test
void testHandleUserAlreadyExistsException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "User already exists";
final var exception = new UserAlreadyExistsException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleUserAlreadyExistsException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.CONFLICT.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("User Registration Conflict", result.getTitle());
}
@Test
void testHandleRegistrationException_ReturnsProblemDetail() {
// Arrange
final var errorMessage = "Registration failed";
final var exception = new RegistrationException(errorMessage);
// Act
final var result = this.globalExceptionHandler.handleRegistrationException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
assertEquals(errorMessage, result.getDetail());
assertEquals("User Registration Failed", result.getTitle());
}
@Test
void testHandleGenericException_ReturnsProblemDetail() {
// Arrange
final var exception = new Exception("Internal error");
// Act
final var result = this.globalExceptionHandler.handleGenericException(exception);
// Assert
assertNotNull(result);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
assertEquals("An internal error occurred.", result.getDetail());
assertEquals("Internal Server Error", result.getTitle());
}
}
@@ -0,0 +1,98 @@
package com.cleverthis.authservice.service.impl;
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.PermissionMappingConfigProperties;
import com.ecwid.consul.v1.ConsulClient;
import com.ecwid.consul.v1.Response;
import com.ecwid.consul.v1.kv.model.GetValue;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class FallbackPermissionMapLookupServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final ConsulClient consulClient = Mockito.mock(ConsulClient.class);
/**
* Test {@link FallbackPermissionMapLookupService#refreshConsul()}}
* and {@link FallbackPermissionMapLookupService#getConfig()}
* to see if the refresh and fallback is working.
*/
@Test
void testRefreshConsulAndGetConfigFallBack() throws JsonProcessingException {
final var service = new FallbackPermissionMapLookupService(
null, this.consulClient, this.objectMapper);
Mockito.reset(this.consulClient);
// null response from consul client
service.refreshConsul();
Mockito.verify(this.consulClient)
.getKVValue(FallbackPermissionMapLookupService.PERMISSION_MAPPING_CONSUL_KEY);
assertNull(service.getConfig()); // fallback is null
// have response but null value
final var resp = Mockito.mock(Response.class);
when(this.consulClient.getKVValue(
FallbackPermissionMapLookupService.PERMISSION_MAPPING_CONSUL_KEY))
.thenReturn(resp);
service.refreshConsul();
Mockito.verify(resp).getValue();
assertNull(service.getConfig()); // fallback is null
// has value but null content
final var value = Mockito.mock(GetValue.class);
when(resp.getValue()).thenReturn(value);
service.refreshConsul();
Mockito.verify(value).getDecodedValue();
assertNull(service.getConfig()); // fallback is null
// has content, but not valid json
when(value.getDecodedValue()).thenReturn("not-json");
Assertions.assertDoesNotThrow(service::refreshConsul);
assertNull(service.getConfig()); // fallback is null
// has json content
when(value.getDecodedValue())
.thenReturn(
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
service.refreshConsul();
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
}
/**
* Test {@link FallbackPermissionMapLookupService#getDefaultKeycloakClientId()}
* to ensure it returns the correct default Keycloak client ID when the config is set.
*/
@Test
void testGetDefaultKeycloakClientId() {
final var defaultConfig = new PermissionMappingConfigProperties();
defaultConfig.setDefaultKeycloakClientId("default-client-id");
final var service = new FallbackPermissionMapLookupService(
defaultConfig, this.consulClient, this.objectMapper);
assertEquals("default-client-id", service.getDefaultKeycloakClientId());
}
@Test
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(
ruleB,
service.matchRuleByPath("/b/items/123").orElseThrow());
assertEquals(
Optional.empty(),
service.matchRuleByPath("/c/items/123"));
}
}
@@ -0,0 +1,974 @@
package com.cleverthis.authservice.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.exception.RegistrationException;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.mockito.Mockito;
class KeycloakAdminReactiveClientTest {
private KeycloakAdminReactiveClient adminClient;
private final Keycloak keycloak = mock(Keycloak.class);
@BeforeEach
void setup() {
when(this.keycloak.realm("test-realm")).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations()).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups()).thenReturn(mock());
when(this.keycloak.realm("test-realm").users()).thenReturn(mock());
final var configProperties = KeycloakClientConfigProperties.builder()
.realm("test-realm")
.build();
this.adminClient =
Mockito.spy(new KeycloakAdminReactiveClient(this.keycloak, configProperties));
}
@Test
void testCreateOrganization_Success() {
// Arrange
final var orgToCreate = new OrganizationRepresentation();
orgToCreate.setId("test-id");
orgToCreate.setName("Test Organization");
final var response = mock(Response.class);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily())
.thenReturn(Response.Status.Family.SUCCESSFUL);
when(response.readEntity(OrganizationRepresentation.class)).thenReturn(orgToCreate);
when(this.keycloak.realm("test-realm")
.organizations().create(orgToCreate))
.thenReturn(response);
// Act
final var result = this.adminClient.createOrganization(orgToCreate).block();
// Assert
assertNotNull(result);
assertEquals("Test Organization", result.getName());
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations()).create(orgToCreate);
}
@Test
void testCreateOrganization_Failure() {
// Arrange
final var orgToCreate = new OrganizationRepresentation();
orgToCreate.setId("test-id");
orgToCreate.setName("Test Organization");
final var response = mock(Response.class);
when(response.getStatusInfo()).thenReturn(mock());
// keycloak return non-200
when(response.getStatusInfo().getFamily())
.thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Error occurred");
when(
this.keycloak.realm("test-realm").organizations()
.create(orgToCreate))
.thenReturn(response);
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.createOrganization(orgToCreate).block());
// client read exception
when(response.getStatusInfo().getFamily())
.thenReturn(Response.Status.Family.SUCCESSFUL);
when(response.readEntity(OrganizationRepresentation.class))
.thenThrow(new ProcessingException("test"));
when(
this.keycloak.realm("test-realm").organizations()
.create(orgToCreate))
.thenReturn(response);
// Act & Assert
Assertions.assertThrows(ProcessingException.class,
() -> this.adminClient.createOrganization(orgToCreate).block());
}
@Test
void testGetOrganizations_Success() {
// Arrange
final var organization1 = new OrganizationRepresentation();
organization1.setId("org-1");
organization1.setName("Organization 1");
final var organization2 = new OrganizationRepresentation();
organization2.setId("org-2");
organization2.setName("Organization 2");
when(this.keycloak.realm("test-realm")
.organizations().list(null, Integer.MAX_VALUE))
.thenReturn(List.of(organization1, organization2));
// Act
final var result = this.adminClient.getOrganizations().block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("Organization 1", result.get(0).getName());
assertEquals("Organization 2", result.get(1).getName());
verify(this.keycloak.realm("test-realm").organizations()).list(null, Integer.MAX_VALUE);
}
@Test
void testGetOrganizationById_Success() {
// Arrange
final var organization = new OrganizationRepresentation();
organization.setId("org-1");
organization.setName("Test Organization");
when(this.keycloak.realm("test-realm")
.organizations().get("org-1"))
.thenReturn(mock());
when(this.keycloak.realm("test-realm")
.organizations().get("org-1").toRepresentation())
.thenReturn(organization);
// Act
final var result = this.adminClient.getOrganizationById("org-1").block();
// Assert
assertNotNull(result);
assertEquals("org-1", result.getId());
assertEquals("Test Organization", result.getName());
verify(this.keycloak.realm("test-realm").organizations().get("org-1"))
.toRepresentation();
}
@Test
void testGetOrganizationById_NotFound() {
// Arrange
when(this.keycloak.realm("test-realm")
.organizations().get("non-existing-id"))
.thenReturn(mock());
when(this.keycloak.realm("test-realm")
.organizations().get("non-existing-id").toRepresentation())
.thenThrow(new NotFoundException("Organization not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getOrganizationById("non-existing-id").block());
verify(this.keycloak.realm("test-realm").organizations().get("non-existing-id"))
.toRepresentation();
}
@Test
void testUpdateOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var orgToUpdate = new OrganizationRepresentation();
orgToUpdate.setId("org-1");
orgToUpdate.setName("Updated Organization");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.updateOrganization(orgId, orgToUpdate).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId)).update(orgToUpdate);
}
@Test
void testUpdateOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var orgToUpdate = new OrganizationRepresentation();
orgToUpdate.setId("org-1");
orgToUpdate.setName("Updated Organization");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.updateOrganization(orgId, orgToUpdate).block());
}
@Test
void testUpdateOrganization_Failure_ProcessingException() {
// Arrange
final var orgId = "org-1";
final var orgToUpdate = new OrganizationRepresentation();
orgToUpdate.setId("org-1");
orgToUpdate.setName("Updated Organization");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class))
.thenThrow(new ProcessingException("Processing error"));
// Act & Assert
Assertions.assertThrows(ProcessingException.class,
() -> this.adminClient.updateOrganization(orgId, orgToUpdate).block());
}
@Test
void testDeleteOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).delete())
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.deleteOrganization(orgId).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId)).delete();
}
@Test
void testDeleteOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).delete())
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.deleteOrganization(orgId).block());
}
@Test
void testDeleteOrganization_Failure_ServerError() {
// Arrange
final var orgId = "org-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).delete())
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenReturn("Server error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.deleteOrganization(orgId).block());
}
@Test
void testInviteUserToOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName()))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.inviteUserToOrganization(orgId, invitation).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName());
}
@Test
void testInviteUserToOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName()))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.inviteUserToOrganization(orgId, invitation).block());
}
@Test
void testInviteUserToOrganization_Failure_Exception() {
// Arrange
final var orgId = "org-1";
final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.inviteUser(invitation.getEmail(), invitation.getFirstName(),
invitation.getLastName()))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.inviteUserToOrganization(orgId, invitation).block());
}
@Test
void testGetOrganizationMembers_Success() {
// Arrange
final var orgId = "org-1";
final var member1 = new MemberRepresentation();
member1.setId("user-1");
member1.setUsername("member1");
final var member2 = new MemberRepresentation();
member2.setId("user-2");
member2.setUsername("member2");
when(this.keycloak.realm("test-realm").organizations().get(orgId))
.thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId)
.members().list(null, Integer.MAX_VALUE))
.thenReturn(List.of(member1, member2));
// Act
final var result = this.adminClient.getOrganizationMembers(orgId).block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("user-1", result.get(0).getId());
assertEquals("user-2", result.get(1).getId());
verify(this.keycloak.realm("test-realm").organizations().get(orgId)
.members()).list(null, Integer.MAX_VALUE);
}
@Test
void testGetOrganizationMembers_Failure_NotFound() {
// Arrange
final var orgId = "non-existing-org";
when(this.keycloak.realm("test-realm").organizations().get(orgId))
.thenThrow(new NotFoundException("Organization not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getOrganizationMembers(orgId).block());
verify(this.keycloak.realm("test-realm").organizations()).get(orgId);
}
@Test
void testAddMemberToOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.addMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.addMemberToOrganization(orgId, userId).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.addMember(userId);
}
@Test
void testAddMemberToOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.addMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.addMemberToOrganization(orgId, userId).block());
}
@Test
void testAddMemberToOrganization_Failure_UnexpectedException() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.addMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.addMemberToOrganization(orgId, userId).block());
}
@Test
void testRemoveMemberFromOrganization_Success() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.removeMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.removeMemberFromOrganization(orgId, userId).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").organizations().get(orgId).members())
.removeMember(userId);
}
@Test
void testRemoveMemberFromOrganization_Failure_ClientError() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.removeMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.removeMemberFromOrganization(orgId, userId).block());
}
@Test
void testRemoveMemberFromOrganization_Failure_Exception() {
// Arrange
final var orgId = "org-1";
final var userId = "user-1";
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn(
mock());
when(this.keycloak.realm("test-realm").organizations().get(orgId).members()
.removeMember(userId))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.removeMemberFromOrganization(orgId, userId).block());
}
@Test
void testCreateSubGroup_Success() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup = new GroupRepresentation();
subGroup.setName("Sub Group 1");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.createSubGroup(parentGroupId, subGroup).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").groups().group(parentGroupId)).subGroup(subGroup);
}
@Test
void testCreateSubGroup_Failure_ClientError() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup = new GroupRepresentation();
subGroup.setName("Sub Group 1");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.readEntity(String.class)).thenReturn("Client error occurred");
// Act & Assert
Assertions.assertThrows(ServiceUnavailableException.class,
() -> this.adminClient.createSubGroup(parentGroupId, subGroup).block());
}
@Test
void testCreateSubGroup_Failure_Exception() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup = new GroupRepresentation();
subGroup.setName("Sub Group 1");
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error"));
// Act & Assert
Assertions.assertThrows(RuntimeException.class,
() -> this.adminClient.createSubGroup(parentGroupId, subGroup).block());
}
@Test
void testGetGroupById_Success() {
// Arrange
final var groupId = "group-1";
final var group = new GroupRepresentation();
group.setId(groupId);
group.setName("Test Group");
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation())
.thenReturn(group);
// Act
final var result = this.adminClient.getGroupById(groupId).block();
// Assert
assertNotNull(result);
assertEquals("group-1", result.getId());
assertEquals("Test Group", result.getName());
verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation();
}
@Test
void testGetGroupById_NotFound() {
// Arrange
final var groupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation())
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getGroupById(groupId).block());
verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation();
}
@Test
void testListSubGroups_Success() {
// Arrange
final var parentGroupId = "parent-group-1";
final var subGroup1 = new GroupRepresentation();
subGroup1.setId("sub-group-1");
subGroup1.setName("Sub Group 1");
final var subGroup2 = new GroupRepresentation();
subGroup2.setId("sub-group-2");
subGroup2.setName("Sub Group 2");
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(parentGroupId)
.getSubGroups(null, Integer.MAX_VALUE, false))
.thenReturn(List.of(subGroup1, subGroup2));
// Act
final var result = this.adminClient.listSubGroups(parentGroupId).block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("sub-group-1", result.get(0).getId());
assertEquals("sub-group-2", result.get(1).getId());
verify(this.keycloak.realm("test-realm").groups().group(parentGroupId))
.getSubGroups(null, Integer.MAX_VALUE, false);
}
@Test
void testListSubGroups_NotFound() {
// Arrange
final var parentGroupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(parentGroupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.listSubGroups(parentGroupId).block());
verify(this.keycloak.realm("test-realm").groups()).group(parentGroupId);
}
@Test
void testUpdateGroup_Success() {
// Arrange
final var groupId = "group-1";
final var groupToUpdate = new GroupRepresentation();
groupToUpdate.setId("group-1");
groupToUpdate.setName("Updated Group");
when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock());
// Act
this.adminClient.updateGroup(groupId, groupToUpdate).block();
// Assert
verify(this.keycloak.realm("test-realm").groups().group(groupId)).update(groupToUpdate);
}
@Test
void testUpdateGroup_NotFound() {
// Arrange
final var groupId = "non-existing-group";
final var groupToUpdate = new GroupRepresentation();
groupToUpdate.setId("non-existing-group");
groupToUpdate.setName("Non-existing Group");
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.updateGroup(groupId, groupToUpdate).block());
verify(this.keycloak.realm("test-realm").groups()).group(groupId);
}
@Test
void testDeleteGroup_Success() {
// Arrange
final var groupId = "group-1";
when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock());
// Act
this.adminClient.deleteGroup(groupId).block();
// Assert
verify(this.keycloak.realm("test-realm").groups().group(groupId)).remove();
}
@Test
void testDeleteGroup_NotFound() {
// Arrange
final var groupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.deleteGroup(groupId).block());
verify(this.keycloak.realm("test-realm").groups()).group(groupId);
}
@Test
void testAddMemberToGroup_Success() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
// Act
this.adminClient.addMemberToGroup(groupId, userId).block();
// Assert
verify(this.keycloak.realm("test-realm").users().get(userId)).joinGroup(groupId);
}
@Test
void testAddMemberToGroup_ClientError() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
final var userResource = mock(UserResource.class);
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource);
doThrow(new ClientErrorException(404)).when(userResource).joinGroup(groupId);
// Act & Assert
Assertions.assertThrows(IllegalArgumentException.class,
() -> this.adminClient.addMemberToGroup(groupId, userId).block());
}
@Test
void testGetGroupMembers_Success() {
// Arrange
final var groupId = "group-1";
final var member1 = new UserRepresentation();
member1.setId("user-1");
member1.setUsername("member1");
final var member2 = new UserRepresentation();
member2.setId("user-2");
member2.setUsername("member2");
when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").groups().group(groupId).members())
.thenReturn(List.of(member1, member2));
// Act
final var result = this.adminClient.getGroupMembers(groupId).block();
// Assert
assertNotNull(result);
assertEquals(2, result.size());
assertEquals("user-1", result.get(0).getId());
assertEquals("user-2", result.get(1).getId());
verify(this.keycloak.realm("test-realm").groups().group(groupId)).members();
}
@Test
void testGetGroupMembers_GroupNotFound() {
// Arrange
final var groupId = "non-existing-group";
when(this.keycloak.realm("test-realm").groups().group(groupId))
.thenThrow(new NotFoundException("Group not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getGroupMembers(groupId).block());
verify(this.keycloak.realm("test-realm").groups()).group(groupId);
}
@Test
void testRemoveMemberFromGroup_Success() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
// Act
this.adminClient.removeMemberFromGroup(groupId, userId).block();
// Assert
verify(this.keycloak.realm("test-realm").users().get(userId)).leaveGroup(groupId);
}
@Test
void testRemoveMemberFromGroup_Failure_ClientError() {
// Arrange
final var groupId = "group-1";
final var userId = "user-1";
final var userResource = mock(UserResource.class);
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource);
doThrow(new ClientErrorException(404)).when(userResource).leaveGroup(groupId);
// Act & Assert
Assertions.assertThrows(IllegalArgumentException.class,
() -> this.adminClient.removeMemberFromGroup(groupId, userId).block());
}
@Test
void testRegisterUser_Success() {
// Arrange
final var registrationRequest = new RegistrationRequest(
"John", "Doe", "john@example.com", "password123"
);
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class)))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL);
// Act
this.adminClient.registerUser(registrationRequest).block();
// Assert
//noinspection resource
verify(this.keycloak.realm("test-realm").users())
.create(argThat(user -> user.getUsername().equals("john@example.com")));
}
@Test
void testRegisterUser_UserAlreadyExists() {
// Arrange
final var registrationRequest = new RegistrationRequest(
"John", "Doe", "john@example.com", "password123"
);
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class)))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR);
when(response.getStatus()).thenReturn(409);
// Act & Assert
Assertions.assertThrows(UserAlreadyExistsException.class,
() -> this.adminClient.registerUser(registrationRequest).block());
}
@Test
void testRegisterUser_ServerError() {
// Arrange
final var registrationRequest = new RegistrationRequest(
"John", "Doe", "john@example.com", "password123"
);
final var response = mock(Response.class);
when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class)))
.thenReturn(response);
when(response.getStatusInfo()).thenReturn(mock());
when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR);
when(response.readEntity(String.class)).thenReturn("Internal Server Error");
// Act & Assert
Assertions.assertThrows(RegistrationException.class,
() -> this.adminClient.registerUser(registrationRequest).block());
}
@Test
void testGetUserDetails_Success() {
// Arrange
final var userId = "user-1";
final var userRepresentation = new UserRepresentation();
userRepresentation.setId(userId);
userRepresentation.setUsername("user1");
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation())
.thenReturn(userRepresentation);
// Act
final var result = this.adminClient.getUserDetails(userId).block();
// Assert
assertNotNull(result);
assertEquals("user-1", result.getId());
assertEquals("user1", result.getUsername());
verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation();
}
@Test
void testGetUserDetails_UserNotFound() {
// Arrange
final var userId = "non-existing-user";
when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock());
when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation())
.thenThrow(new NotFoundException("User not found"));
// Act & Assert
Assertions.assertThrows(NoSuchElementException.class,
() -> this.adminClient.getUserDetails(userId).block());
verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation();
}
}
@@ -0,0 +1,634 @@
package com.cleverthis.authservice.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.TokenVerificationException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.mockito.Mockito;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class KeycloakIdentityManagerServiceTest {
private MockWebServer mockBackEnd;
private KeycloakAdminReactiveClient adminClient;
private KeycloakIdentityManagerService service;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void setup() throws IOException {
// isolate requests by creating one mock server per test
this.mockBackEnd = new MockWebServer();
this.mockBackEnd.start();
final var webClient = WebClient.builder().build();
final var configProperties = KeycloakClientConfigProperties.builder()
.keycloakHost("http://localhost:" + this.mockBackEnd.getPort() + "/")
.realm("test-realm")
.clientId("test-client")
.clientSecret("client-password")
.build();
this.adminClient = Mockito.mock(KeycloakAdminReactiveClient.class);
this.service = Mockito.spy(new KeycloakIdentityManagerService(
webClient, this.adminClient, configProperties));
}
@AfterEach
void tearDown() throws IOException {
this.mockBackEnd.shutdown();
}
@Test
void login_success() throws JsonProcessingException, InterruptedException {
// Arrange
final var username = "test-user";
final var password = "test-password";
final var mockTokenResponse = new TokenResponse("access-token", 3600, 3600,
"refresh-token", "bearer", "id-token", 0, "session-id", "openid");
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(this.objectMapper.writeValueAsString(mockTokenResponse))
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.login(username, password);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> response.getAccessToken().equals("access-token") &&
response.getRefreshToken().equals("refresh-token"))
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
}
@Test
void login_failure_invalidCredentials() {
// Arrange
final var username = "wrong-user";
final var password = "wrong-password";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(401)
.setBody(
"{\"error\":\"invalid_grant\",\"error_description\":\"Invalid user credentials\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.login(username, password);
// Assert
StepVerifier.create(result)
.expectErrorMatches(throwable -> throwable instanceof AuthenticationException &&
throwable.getMessage()
.contains("Login failed: Invalid credentials or Keycloak error."))
.verify();
}
@Test
void verifyToken_success() throws JsonProcessingException, InterruptedException {
// Arrange
final var accessToken = "valid-access-token";
final var mockVerificationResponse = VerificationResponse.builder()
.clientId("test-client-id")
.active(true)
.emailVerified(true)
.preferredUsername("test-user")
.sub("user-sub")
.build();
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.verifyToken(accessToken);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "test-client-id".equals(response.getClientId()) &&
"test-user".equals(response.getPreferredUsername()) &&
Boolean.TRUE.equals(response.getEmailVerified()))
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token/introspect",
recordedRequest.getPath());
}
@Test
void verifyToken_failure_inactiveToken() throws JsonProcessingException {
// Arrange
final var accessToken = "inactive-access-token";
final var mockVerificationResponse = VerificationResponse.builder()
.sub("user-sub")
.active(false)
.username("test-user")
.build();
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.verifyToken(accessToken);
// Assert
StepVerifier.create(result)
.expectErrorMatches(throwable -> throwable instanceof TokenVerificationException &&
throwable.getMessage().contains("Token is invalid or expired."))
.verify();
}
@Test
void logout_success() throws InterruptedException {
// Arrange
final var refreshToken = "valid-refresh-token";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.logout(refreshToken);
// Assert
StepVerifier.create(result)
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/revoke",
recordedRequest.getPath());
}
@Test
void logout_failure_invalidToken() {
// Arrange
final var refreshToken = "invalid-refresh-token";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(400)
.setBody(
"{\"error\":\"invalid_request\",\"error_description\":\"Invalid token or token has been revoked\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result = this.service.logout(refreshToken);
// Assert
StepVerifier.create(result)
.verifyComplete(); // Expecting no error due to handling of 400 as a soft failure
}
@Test
void checkResourcePermission_success() throws InterruptedException {
// Arrange
final var accessToken = "valid-token";
final var clientId = "resource-client";
final var resourceUri = "/resources/123";
final var scope = "read";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(200)
.setBody("{\"response\":\"success\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result =
this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
// Assert
StepVerifier.create(result)
.expectNext(true)
.verifyComplete();
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
assertEquals("Bearer valid-token", recordedRequest.getHeader("Authorization"));
}
@Test
void checkResourcePermission_permissionDenied() {
// Arrange
final var accessToken = "valid-token";
final var clientId = "resource-client";
final var resourceUri = "/resources/123";
final var scope = "read";
this.mockBackEnd.enqueue(new MockResponse()
.setResponseCode(403)
.setBody("{\"error\":\"permission_denied\"}")
.addHeader("Content-Type", "application/json"));
// Act
final var result =
this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
// Assert
StepVerifier.create(result)
.expectNext(false)
.verifyComplete();
}
@Test
void createOrganization_success() {
// Arrange
final var request = new CreateOrganizationRequest(
"Test Organization", List.of("test.org"), "", Map.of()
);
final var mockResponse = new OrganizationRepresentation();
mockResponse.setId("123");
mockResponse.setName("Test Organization");
mockResponse.addDomain(new OrganizationDomainRepresentation("test.org"));
mockResponse.setDescription("");
mockResponse.setEnabled(true);
Mockito.when(this.adminClient.createOrganization(any()))
.thenReturn(Mono.just(mockResponse));
// Act
final var result = this.service.createOrganization(request);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "123".equals(response.getId()) &&
"Test Organization".equals(response.getName()))
.verifyComplete();
}
@Test
void getOrganizations_success() {
// Arrange
final var mockOrganizations = List.of(
new OrganizationRepresentation() {{
setId("org1");
setName("Organization 1");
addDomain(new OrganizationDomainRepresentation("domain1.org"));
}},
new OrganizationRepresentation() {{
setId("org2");
setName("Organization 2");
addDomain(new OrganizationDomainRepresentation("domain2.org"));
}}
);
Mockito.when(this.adminClient.getOrganizations())
.thenReturn(Mono.just(mockOrganizations));
// Act
final var result = this.service.getOrganizations();
// Assert
StepVerifier.create(result)
.expectNextMatches(orgList -> orgList.size() == 2
&& "Organization 1".equals(orgList.getFirst().getName())
&& "domain1.org".equals(
orgList.getFirst().getDomains().iterator().next().getName())
&& "Organization 2".equals(orgList.get(1).getName()))
.verifyComplete();
}
@Test
void getOrganizationById_success() {
// Arrange
final var orgId = "abc123";
final var orgRepresentation = new OrganizationRepresentation();
orgRepresentation.setId(orgId);
orgRepresentation.setName("Test Organization");
orgRepresentation.addDomain(new OrganizationDomainRepresentation("test.org"));
orgRepresentation.setDescription("Test Description");
orgRepresentation.setEnabled(true);
Mockito.when(this.adminClient.getOrganizationById(orgId))
.thenReturn(Mono.just(orgRepresentation));
// Act
final var result = this.service.getOrganizationById(orgId);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "abc123".equals(response.getId())
&& "Test Organization".equals(response.getName())
&& "Test Description".equals(response.getDescription())
&& Boolean.TRUE.equals(response.getEnabled())
&& response.getDomains().iterator().next().getName().equals("test.org"))
.verifyComplete();
}
@Test
void updateOrganization_success() {
// Arrange
final var orgId = "org123";
final var updateRequest = new UpdateOrganizationRequest(
List.of("updated.org"), "Updated Description", Map.of("key", List.of("value"))
);
final var existingOrg = new OrganizationRepresentation();
existingOrg.setId(orgId);
existingOrg.setDescription("Old Description");
existingOrg.addDomain(new OrganizationDomainRepresentation("old.org"));
Mockito.when(this.adminClient.getOrganizationById(orgId))
.thenReturn(Mono.just(existingOrg));
Mockito.when(this.adminClient.updateOrganization(orgId, existingOrg))
.thenReturn(Mono.empty());
// Act
final var result = this.service.updateOrganization(orgId, updateRequest);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).updateOrganization(orgId, existingOrg);
assertEquals("Updated Description", existingOrg.getDescription());
assertEquals("value", existingOrg.getAttributes().get("key").getFirst());
assertEquals(1, existingOrg.getDomains().size());
assertEquals("updated.org", existingOrg.getDomains().iterator().next().getName());
}
@Test
void deleteOrganization_success() {
// Arrange
final var orgId = "test-org-id";
Mockito.when(this.adminClient.deleteOrganization(orgId))
.thenReturn(Mono.empty());
// Act
final var result = this.service.deleteOrganization(orgId);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).deleteOrganization(orgId);
}
@Test
void inviteUserToOrganization_success() {
// Arrange
final var orgId = "test-org-id";
final var request = new InviteUserRequest("test@example.com", "John", "Wick");
final var invitationRequest = new KeycloakInvitationRequest();
invitationRequest.setEmail(request.getEmail());
invitationRequest.setFirstName(request.getFirstName());
invitationRequest.setLastName(request.getLastName());
Mockito.when(this.adminClient.inviteUserToOrganization(any(), any()))
.thenReturn(Mono.empty());
// Act
final var result = this.service.inviteUserToOrganization(orgId, request);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).inviteUserToOrganization(orgId, invitationRequest);
}
@Test
void getOrganizationMembers_success() {
// Arrange
final var orgId = "test-org-id";
final var mockMembers = List.of(
// I asked Gemini, and this is an antipattern that no one mentions
// when I learned Java. Basically, it will create a new anonymous
// class for every instance. In this case, we have two.
// Gemini suggests we use a method to create a user and then put it in the list,
// but a dedicated method is not as compact as double brace initialization.
// This test is generated by gemini 2.5 pro. I think it's ok to use here
// because it's a unit test, and I blame keycloak to not offer a builder.
// DO NOT use this pattern in the main source set.
new MemberRepresentation() {{
setId("user1");
setUsername("user1Username");
setFirstName("User1");
setLastName("One");
setEmail("user1@example.com");
setEnabled(true);
}},
new MemberRepresentation() {{
setId("user2");
setUsername("user2Username");
setFirstName("User2");
setLastName("Two");
setEmail("user2@example.com");
setEnabled(true);
}}
);
Mockito.when(this.adminClient.getOrganizationMembers(orgId))
.thenReturn(Mono.just(mockMembers));
// Act
final var result = this.service.getOrganizationMembers(orgId);
// Assert
StepVerifier.create(result)
.expectNextMatches(members -> members.size() == 2
&& "user1".equals(members.getFirst().getId())
&& "user1@example.com".equals(members.getFirst().getEmail())
&& "user2".equals(members.get(1).getId())
&& "user2@example.com".equals(members.get(1).getEmail()))
.verifyComplete();
Mockito.verify(this.adminClient).getOrganizationMembers(orgId);
}
@Test
void addMemberToOrganization_success() {
// Arrange
final var orgId = "test-org-id";
final var userId = "test-user-id";
Mockito.when(this.adminClient.addMemberToOrganization(orgId, userId))
.thenReturn(Mono.empty());
// Act
final var result = this.service.addMemberToOrganization(orgId, userId);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).addMemberToOrganization(orgId, userId);
}
@Test
void removeMemberFromOrganization_success() {
// Arrange
final var orgId = "test-org-id";
final var userId = "test-user-id";
Mockito.when(this.adminClient.removeMemberFromOrganization(orgId, userId))
.thenReturn(Mono.empty());
// Act
final var result = this.service.removeMemberFromOrganization(orgId, userId);
// Assert
StepVerifier.create(result)
.verifyComplete();
Mockito.verify(this.adminClient).removeMemberFromOrganization(orgId, userId);
}
@Test
void createSubGroup_success() {
// Arrange
final var parentGroupId = "parent-group-id";
final var request = new CreateGroupRequest(
"Test SubGroup", "", Map.of("key", List.of("value")));
final var mockGroupRepresentation = new GroupRepresentation();
mockGroupRepresentation.setId("sub-group-id");
mockGroupRepresentation.setName(request.getName());
mockGroupRepresentation.setAttributes(request.getAttributes());
Mockito.when(this.adminClient.createSubGroup(parentGroupId, mockGroupRepresentation))
.thenReturn(Mono.empty());
// Act
final var result = this.service.createSubGroup(parentGroupId, request);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> "Test SubGroup".equals(response.getName()) &&
response.getAttributes().get("key").contains("value"))
.verifyComplete();
Mockito.verify(this.adminClient).createSubGroup(Mockito.eq(parentGroupId), Mockito.any());
}
@Test
void getGroupById_success() {
// Arrange
final var groupId = "test-group-id";
final var mockGroup = new GroupRepresentation();
mockGroup.setId(groupId);
mockGroup.setName("Test Group");
mockGroup.setPath("/test-group");
mockGroup.setDescription("Test Group Description");
mockGroup.setAttributes(Map.of("key", List.of("value1", "value2")));
Mockito.when(this.adminClient.getGroupById(groupId))
.thenReturn(Mono.just(mockGroup));
// Act
final var result = this.service.getGroupById(groupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(response -> response.getId().equals(groupId) &&
response.getName().equals("Test Group") &&
response.getPath().equals("/test-group") &&
response.getDescription().equals("Test Group Description") &&
response.getAttributes().get("key").contains("value1"))
.verifyComplete();
Mockito.verify(this.adminClient).getGroupById(groupId);
}
@Test
void listSubGroups_success() {
// Arrange
final var parentGroupId = "parent-group-id";
final var subGroups = List.of(
new GroupRepresentation() {{
setId("sub-group-1");
setName("SubGroup1");
}},
new GroupRepresentation() {{
setId("sub-group-2");
setName("SubGroup2");
}}
);
Mockito.when(this.adminClient.listSubGroups(parentGroupId))
.thenReturn(Mono.just(subGroups));
// Act
final var result = this.service.listSubGroups(parentGroupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(groups -> groups.size() == 2 &&
"SubGroup1".equals(groups.getFirst().getName()) &&
"sub-group-2".equals(groups.get(1).getId()))
.verifyComplete();
Mockito.verify(this.adminClient).listSubGroups(parentGroupId);
}
@Test
void getGroupMembers_success() {
// Arrange
final var groupId = "test-group-id";
final var mockMembers = List.of(
new UserRepresentation() {{
setId("user1");
setUsername("user1Username");
setFirstName("User1");
setLastName("One");
setEmail("user1@example.com");
setEnabled(true);
}},
new UserRepresentation() {{
setId("user2");
setUsername("user2Username");
setFirstName("User2");
setLastName("Two");
setEmail("user2@example.com");
setEnabled(true);
}}
);
Mockito.when(this.adminClient.getGroupMembers(groupId))
.thenReturn(Mono.just(mockMembers));
// Act
final var result = this.service.getGroupMembers(groupId);
// Assert
StepVerifier.create(result)
.expectNextMatches(members -> members.size() == 2
&& "user1".equals(members.getFirst().getId())
&& "user1@example.com".equals(members.getFirst().getEmail())
&& "user2".equals(members.get(1).getId())
&& "user2@example.com".equals(members.get(1).getEmail()))
.verifyComplete();
Mockito.verify(this.adminClient).getGroupMembers(groupId);
}
}
+12
View File
@@ -0,0 +1,12 @@
spring:
cloud:
consul:
config:
enabled: false
otel:
logs:
exporter: none
traces:
exporter: none
metrics:
exporter: none