Skip to content

Commit d46b10a

Browse files
Merge pull request #2 from vymalo/features/rabbitmq-webhook
Add rabbitmq listener client config
2 parents 9ceb541 + 1257dc5 commit d46b10a

17 files changed

+372
-153
lines changed

.github/workflows/releases.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ jobs:
5555
prerelease: false
5656
allowUpdates: true
5757
bodyFile: CHANGELOG.md
58-
tag: v0.1.0
58+
tag: v0.2.0

CHANGELOG.md

+3-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
# Changelog
22

3-
## v0.1.0
3+
## v0.2.0
44

5-
- Used Gradle because of performance, used the latest version (8) at time of writing this
6-
- Developed a plugin using Kotlin, OpenApi with Kotlin generator
7-
- A keycloak plugin to handle sending requests on new events
8-
- Used shadow to filter required dependencies for the final jar (hence the second jar ending in `-all`)
9-
- Setup Github Ci
5+
- Added RabbitMQ client
6+
- Use Gson as Serialization lib for http

Dev.Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ ARG TAG=21.0.2
22

33
FROM quay.io/keycloak/keycloak:${TAG}
44

5-
ENV WEBHOOK_PLUGIN_VERSION 0.1.0
5+
ENV WEBHOOK_PLUGIN_VERSION 0.2.0
66

77
ENV KEYCLOAK_DIR /opt/keycloak
88
ENV KC_PROXY edge

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ARG TAG=21.0.2
2-
ARG PLUGIN_VERSION=0.1.0
2+
ARG PLUGIN_VERSION=0.2.0
33

44
FROM curlimages/curl AS DOWNLOADER
55

README.md

+15-4
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,19 @@ This plugin call a webhook whenever an event is created from within Keycloak.
44

55

66
## Configuration
7+
- `WEBHOOK_EVENTS_TAKEN (optional)` is the list of events created from Keycloak, that are listened by the plugin. Example: `"LOGIN,REGISTER,LOGOUT"`. If not specified, will take all.
78

8-
- `WEBHOOK_BASE_PATH` is the endpoint where the webhook request is going to be sent. Example: https://localhost:3000
9-
- `WEBHOOK_TAKE_EVENTS (optional)` is the list of events created from Keycloak, that are listened by the plugin. Example: `"LOGIN,REGISTER,LOGOUT"`
10-
- `WEBHOOK_AUTH_USERNAME (optional)` is the basic auth username. Example "admin".
11-
- `WEBHOOK_AUTH_PASSWORD (optional)` is the basic auth password. Example "password".
9+
### Using the http client
10+
- `WEBHOOK_HTTP_BASE_PATH` is the endpoint where the webhook request is going to be sent. Example: https://localhost:3000
11+
- `WEBHOOK_HTTP_AUTH_USERNAME (optional)` is the basic auth username. Example "admin".
12+
- `WEBHOOK_HTTP_AUTH_PASSWORD (optional)` is the basic auth password. Example "password".
13+
14+
### Using the AMQP client
15+
This part is heavily inspired from the [keycloak-event-listener-rabbitmq](https://github.com/aznamier/keycloak-event-listener-rabbitmq) plugin.
16+
- `WEBHOOK_AMQP_HOST` is the host url of the rabbitmq server. This key will indicate that the amqp client is being used.
17+
- `WEBHOOK_AMQP_USERNAME` is the username of the rabbitmq server
18+
- `WEBHOOK_AMQP_PASSWORD` is the password of the rabbitmq server
19+
- `WEBHOOK_AMQP_PORT` is the port of the rabbitmq server
20+
- `WEBHOOK_AMQP_VHOST (optional)` is the vhost of the rabbitmq server
21+
- `WEBHOOK_AMQP_EXCHANGE` is the exchange of the rabbitmq server
22+
- `WEBHOOK_AMQP_SSL (optional)` is to indicate if we're using SSL or not. Values are "yes" or "no"

build.gradle.kts

+14-8
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ plugins {
88
}
99

1010
group = "com.vymalo.keycloak.webhook"
11-
version = "0.1.0"
11+
version = "0.2.0"
12+
13+
val gsonVersion = "2.10.1"
14+
val amqpVersion = "5.17.0"
15+
val okhttp3Version = "4.10.0"
1216

1317
repositories {
1418
mavenCentral()
@@ -23,9 +27,9 @@ dependencies {
2327
implementation("org.keycloak", "keycloak-server-spi", "21.0.2")
2428
implementation("org.keycloak", "keycloak-server-spi-private", "21.0.2")
2529

26-
api("com.squareup.moshi", "moshi-kotlin", "1.13.0")
27-
api("com.squareup.moshi", "moshi-adapters", "1.13.0")
28-
api("com.squareup.okhttp3", "okhttp", "4.10.0")
30+
api("com.squareup.okhttp3", "okhttp", okhttp3Version)
31+
api("com.rabbitmq", "amqp-client", amqpVersion)
32+
api("com.google.code.gson", "gson", gsonVersion)
2933

3034
api("org.slf4j", "slf4j-log4j12", "1.7.36")
3135
}
@@ -56,7 +60,8 @@ openApiGenerate {
5660

5761
configOptions.set(
5862
mutableMapOf(
59-
"dateLibrary" to "java8"
63+
"dateLibrary" to "java8",
64+
"serializationLibrary" to "gson"
6065
)
6166
)
6267
}
@@ -78,9 +83,10 @@ tasks {
7883

7984
val shadowJar by existing(ShadowJar::class) {
8085
dependencies {
81-
include(dependency("com.squareup.moshi:.*"))
82-
include(dependency("com.squareup.okhttp3:.*"))
83-
include(dependency("org.jetbrains.kotlin:kotlin-reflect:.*"))
86+
include(dependency("com.squareup.okhttp3:okhttp:$okhttp3Version"))
87+
include(dependency("org.jetbrains.kotlin:kotlin-reflect:1.8.0"))
88+
include(dependency("com.rabbitmq:amqp-client:$amqpVersion"))
89+
include(dependency("com.google.code.gson:gson:$gsonVersion"))
8490
}
8591
}
8692
}

docker-compose.yaml

+39-6
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,52 @@ services:
66
context: .
77
dockerfile: Dev.Dockerfile
88
ports:
9-
- '9100:9100'
9+
- '9200:9200'
1010
environment:
11-
WEBHOOK_TAKE_EVENTS: "LOGIN,REGISTER,LOGOUT"
12-
WEBHOOK_BASE_PATH: "https://localhost:3000"
13-
WEBHOOK_AUTH_USERNAME: "admin"
14-
WEBHOOK_AUTH_PASSWORD: "password"
11+
# WEBHOOK_EVENTS_TAKEN: "LOGIN,REGISTER,LOGOUT"
12+
13+
WEBHOOK_HTTP_BASE_PATH: "http://watcher:3000/api"
14+
WEBHOOK_HTTP_AUTH_USERNAME: "admin"
15+
WEBHOOK_HTTP_AUTH_PASSWORD: "password"
16+
17+
WEBHOOK_AMQP_HOST: rabbitmq
18+
WEBHOOK_AMQP_USERNAME: username
19+
WEBHOOK_AMQP_PASSWORD: password
20+
WEBHOOK_AMQP_PORT: 5672
21+
WEBHOOK_AMQP_EXCHANGE: keycloak
22+
WEBHOOK_AMQP_VHOST: "/"
1523

1624
KEYCLOAK_ADMIN: admin
1725
KEYCLOAK_ADMIN_PASSWORD: password
1826
KEYCLOAK_FRONTEND_URL: http://localhost:9100/auth
27+
1928
KC_HTTP_PORT: 9100
2029
KC_METRICS_ENABLED: 'true'
2130
KC_LOG_CONSOLE_COLOR: 'true'
2231
KC_HEALTH_ENABLED: 'true'
2332
command:
24-
- start-dev
33+
- start-dev
34+
35+
rabbitmq:
36+
image: docker.io/bitnami/rabbitmq
37+
ports:
38+
- '4369:4369'
39+
- '5551:5551'
40+
- '5552:5552'
41+
- '5672:5672'
42+
- '25672:25672'
43+
- '15672:15672'
44+
environment:
45+
- RABBITMQ_USERNAME=username
46+
- RABBITMQ_PASSWORD=password
47+
48+
watcher:
49+
image: ssegning/api-watcher
50+
ports:
51+
- "3000:3000"
52+
volumes:
53+
- watcher_data:/app/data
54+
55+
volumes:
56+
watcher_data:
57+

openapi/webhook.open-api.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ info:
99
1010
license:
1111
name: MIT
12-
version: 0.1.0
12+
version: 0.2.0
1313
externalDocs:
1414
description: Find out more about calling setting up an external service to verify phone number
1515
url: https://blog.ssegning.com
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,83 @@
11
package com.vymalo.keycloak.webhook
22

3-
import com.vymalo.keycloak.webhook.service.WebhookService
3+
import com.vymalo.keycloak.openapi.client.model.WebhookRequest
4+
import com.vymalo.keycloak.webhook.service.WebhookHandler
45
import org.keycloak.events.Event
56
import org.keycloak.events.EventListenerProvider
67
import org.keycloak.events.admin.AdminEvent
8+
import org.slf4j.LoggerFactory
9+
import java.math.BigDecimal
10+
11+
class WebhookEventListenerProvider(
12+
private val handlers: Set<WebhookHandler>?,
13+
private val takeList: Set<String>?
14+
) : EventListenerProvider {
15+
companion object {
16+
private val LOG = LoggerFactory.getLogger(WebhookEventListenerProvider::class.java)
17+
}
718

8-
class WebhookEventListenerProvider(private val service: WebhookService) : EventListenerProvider {
919
override fun close() {
1020
}
1121

12-
override fun onEvent(event: Event) = service.send(event)
22+
override fun onEvent(event: Event) = send(
23+
event.id,
24+
event.time,
25+
event.realmId,
26+
event.clientId,
27+
event.userId,
28+
event.ipAddress,
29+
event.type.toString(),
30+
event.error,
31+
event.details
32+
)
33+
34+
override fun onEvent(event: AdminEvent, includeRepresentation: Boolean) = send(
35+
event.id,
36+
event.time,
37+
event.realmId,
38+
event.authDetails?.clientId,
39+
event.authDetails?.userId,
40+
event.authDetails?.ipAddress,
41+
"${event.resourceType}-${event.operationType}",
42+
event.error,
43+
null,
44+
)
1345

14-
override fun onEvent(event: AdminEvent, includeRepresentation: Boolean) = service.send(event)
46+
private fun send(
47+
id: String,
48+
time: Long?,
49+
realmId: String,
50+
clientId: String?,
51+
userId: String?,
52+
ipAddress: String?,
53+
type: String,
54+
error: String?,
55+
details: Map<String, Any>?
56+
) {
57+
if (takeList != null && type !in takeList) {
58+
LOG.debug("Event {} not in the taken list. Will be skipped ({}).", type, takeList)
59+
return
60+
}
61+
62+
val request = WebhookRequest(
63+
id = id,
64+
time = if (time == null) null else BigDecimal(time),
65+
clientId = clientId,
66+
userId = userId,
67+
realmId = realmId,
68+
ipAddress = ipAddress,
69+
type = type,
70+
details = details,
71+
error = error,
72+
)
73+
74+
handlers?.forEach {
75+
try {
76+
LOG.debug("Sending [{}] webhook for event type {}: {}", it.handler(), type, request)
77+
it.sendWebhook(request)
78+
} catch (e: Throwable) {
79+
LOG.error("Could not send webhook", e)
80+
}
81+
}
82+
}
1583
}
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package com.vymalo.keycloak.webhook
22

3-
import com.vymalo.keycloak.openapi.client.handler.WebhookApi
4-
import com.vymalo.keycloak.openapi.client.infrastructure.ApiClient
5-
import com.vymalo.keycloak.webhook.helper.getConfig
6-
import com.vymalo.keycloak.webhook.service.HttpWebhookService
3+
import com.vymalo.keycloak.webhook.helper.*
4+
import com.vymalo.keycloak.webhook.model.ClientConfig
5+
import com.vymalo.keycloak.webhook.service.AmqpWebhookHandler
6+
import com.vymalo.keycloak.webhook.service.HttpWebhookHandler
7+
import com.vymalo.keycloak.webhook.service.WebhookHandler
78
import org.keycloak.Config
89
import org.keycloak.events.EventListenerProvider
910
import org.keycloak.events.EventListenerProviderFactory
@@ -19,43 +20,89 @@ class WebhookEventListenerProviderFactory : EventListenerProviderFactory, Server
1920
private val LOG = LoggerFactory.getLogger(WebhookEventListenerProviderFactory::class.java)
2021

2122
const val PROVIDER_ID = "listener-webhook"
22-
23-
private const val takeListKey = "WEBHOOK_TAKE_EVENTS"
24-
private const val baseBathKey = "WEBHOOK_BASE_PATH"
25-
private const val authUsernameKey = "WEBHOOK_AUTH_USERNAME"
26-
private const val authPasswordKey = "WEBHOOK_AUTH_PASSWORD"
2723
}
2824

29-
private var takeList: Set<String>? = null
30-
private var basePath: String = "scheme://fake-host:3290"
25+
private var clientConfig: ClientConfig = ClientConfig()
26+
private var handlers: HashSet<WebhookHandler>? = null
3127

3228
override fun create(session: KeycloakSession): EventListenerProvider {
33-
val api = WebhookApi(basePath)
34-
val service = HttpWebhookService(api, takeList)
35-
return WebhookEventListenerProvider(service)
29+
ensureParametersInit()
30+
return WebhookEventListenerProvider(handlers, clientConfig.takeList)
31+
}
32+
33+
@Synchronized
34+
private fun ensureParametersInit() {
35+
synchronized(clientConfig) {
36+
if (handlers == null) {
37+
val handlers = HashSet<WebhookHandler>()
38+
val amqpConfig = clientConfig.amqp
39+
if (amqpConfig != null) {
40+
try {
41+
val handler = AmqpWebhookHandler(amqp = amqpConfig)
42+
handlers.add(handler)
43+
} catch (e: Throwable) {
44+
LOG.error("Error while creating AMQP handler", e)
45+
}
46+
}
47+
48+
val httpConfig = clientConfig.http
49+
if (httpConfig != null) {
50+
val handler = HttpWebhookHandler(httpConfig)
51+
handlers.add(handler)
52+
}
53+
54+
LOG.info("Added {} listener webhook handler", handlers.map { it.handler() })
55+
this.handlers = handlers
56+
}
57+
}
3658
}
3759

3860
override fun init(config: Config.Scope) {
39-
val tl = getConfig(takeListKey)
61+
val tl = eventsTakenKey.cf()
4062
if (tl != null) {
41-
takeList = tl.trim().split(",").toSet()
42-
LOG.debug("Will handle these events : {}", takeList)
63+
clientConfig.takeList = tl.trim().split(",").toSet()
64+
LOG.debug("Will handle these events : {}", clientConfig.takeList)
4365
}
4466

45-
basePath = getConfig(baseBathKey)!!
46-
LOG.debug("Will send events to this endpoint : {}", basePath)
67+
val basePath = httpBaseBathKey.cf()
68+
if (basePath != null) {
69+
LOG.debug("Will send http requests to {}", basePath)
70+
clientConfig.http = ClientConfig.Companion.Http(
71+
username = httpAuthUsernameKey.cf(),
72+
password = httpAuthPasswordKey.cf(),
73+
baseUrl = basePath,
74+
)
75+
}
4776

48-
ApiClient.username = getConfig(authUsernameKey)
49-
ApiClient.password = getConfig(authPasswordKey)
77+
val amqpHost = amqpHostKey.cf()
78+
if (amqpHost != null) {
79+
clientConfig.amqp = ClientConfig.Companion.Amqp(
80+
username = amqpUsernameKey.cff(),
81+
password = amqpPasswordKey.cff(),
82+
host = amqpHost,
83+
port = amqpPortKey.cff(),
84+
vHost = amqpVHostKey.cf(),
85+
ssl = amqpSsl.bf(),
86+
exchange = amqpExchangeKey.cff()
87+
)
88+
}
89+
LOG.info("Webhook plugin init!")
5090
}
5191

52-
override fun postInit(factory: KeycloakSessionFactory?) = noop()
92+
override fun postInit(factory: KeycloakSessionFactory) {}
5393

54-
override fun close() = noop()
94+
override fun close() {
95+
handlers?.forEach {
96+
try {
97+
it.close()
98+
} catch (e: Throwable) {
99+
LOG.error("Could not close correctly", e)
100+
}
101+
}
102+
}
55103

56104
override fun getId(): String = PROVIDER_ID
57105

58-
override fun getOperationalInfo() = mapOf("version" to "0.1.0")
106+
override fun getOperationalInfo() = mapOf("version" to "0.2.0")
59107

60-
private fun noop() {}
61108
}

0 commit comments

Comments
 (0)