From bfc0f2cab857e62f443bf0bc3bfdc1d205dd3b8f Mon Sep 17 00:00:00 2001
From: Thomas Darimont
Date: Tue, 21 Oct 2025 00:37:00 +0200
Subject: [PATCH 001/153] Initial support for Shared Signals Framework
- Add new experimental SSF feature flag
- Add SET parsing infrastructure
- Add support for Subject Identifier parsing
- Add receiver management
- Add transmitter stream / keys management
- Add support for SET PUSH Delivery
Fixes #43614
Signed-off-by: Thomas Darimont
---
.../java/org/keycloak/common/Profile.java | 2 +
.../java/org/keycloak/protocol/ssf/Ssf.java | 8 +
.../keycloak/protocol/ssf/SsfException.java | 15 +
.../ssf/SsfRealmResourceProvider.java | 116 +++++++
.../ssf/event/ErrorSecurityEventToken.java | 23 ++
.../protocol/ssf/event/InitiatingEntity.java | 22 ++
.../ssf/event/SecurityEventToken.java | 69 ++++
.../protocol/ssf/event/SecurityEvents.java | 139 ++++++++
.../ssf/event/delivery/DeliveryMethod.java | 43 +++
.../ssf/event/delivery/push/PushEndpoint.java | 138 ++++++++
.../listener/DefaultSsfEventListener.java | 62 ++++
.../ssf/event/listener/SsfEventListener.java | 10 +
.../event/parser/DefaultSsfEventParser.java | 70 ++++
.../ssf/event/parser/SsfEventParser.java | 9 +
.../ssf/event/parser/SsfParsingException.java | 14 +
.../processor/DefaultSsfEventProcessor.java | 188 +++++++++++
.../ssf/event/processor/SsfEventContext.java | 48 +++
.../event/processor/SsfEventProcessor.java | 6 +
.../ssf/event/subjects/AccountSubjectId.java | 30 ++
.../ssf/event/subjects/AliasesSubjectId.java | 36 ++
.../ssf/event/subjects/ComplexSubjectId.java | 128 +++++++
.../ssf/event/subjects/DidSubjectId.java | 33 ++
.../ssf/event/subjects/EmailSubjectId.java | 33 ++
.../ssf/event/subjects/GenericSubjectId.java | 16 +
.../ssf/event/subjects/IssuerSubjectId.java | 45 +++
.../ssf/event/subjects/JwtSubjectId.java | 45 +++
.../ssf/event/subjects/OpaqueSubjectId.java | 33 ++
.../event/subjects/PhoneNumberSubjectId.java | 33 ++
.../subjects/SamlAssertionSubjectId.java | 29 ++
.../ssf/event/subjects/SubjectId.java | 48 +++
.../subjects/SubjectIdJsonDeserializer.java | 49 +++
.../ssf/event/subjects/SubjectIds.java | 36 ++
.../subjects/SubjectParsingException.java | 17 +
.../ssf/event/subjects/SubjectUserLookup.java | 51 +++
.../ssf/event/subjects/UriSubjectId.java | 33 ++
.../ssf/event/types/GenericSsfEvent.java | 21 ++
.../SecurityEventMapJsonDeserializer.java | 41 +++
.../protocol/ssf/event/types/SsfEvent.java | 116 +++++++
.../ssf/event/types/StreamUpdatedEvent.java | 45 +++
.../ssf/event/types/VerificationEvent.java | 30 ++
.../types/caep/AssuranceLevelChange.java | 87 +++++
.../ssf/event/types/caep/CaepEvent.java | 10 +
.../types/caep/ChangeTypeDeserializer.java | 44 +++
.../event/types/caep/CredentialChange.java | 183 +++++++++++
.../types/caep/DeviceComplianceChange.java | 73 ++++
.../event/types/caep/RiskLevelChanged.java | 86 +++++
.../event/types/caep/SessionEstablished.java | 108 ++++++
.../event/types/caep/SessionPresented.java | 76 +++++
.../ssf/event/types/caep/SessionRevoked.java | 21 ++
.../event/types/caep/TokenClaimsChanged.java | 41 +++
.../risc/AccountCredentialChangeRequired.java | 16 +
.../ssf/event/types/risc/AccountDisabled.java | 35 ++
.../ssf/event/types/risc/AccountEnabled.java | 16 +
.../ssf/event/types/risc/AccountPurged.java | 16 +
.../types/risc/CredentialCompromise.java | 32 ++
.../event/types/risc/IdentifierChanged.java | 36 ++
.../event/types/risc/IdentifierRecycled.java | 16 +
.../protocol/ssf/event/types/risc/OptIn.java | 16 +
.../ssf/event/types/risc/OptOutCancelled.java | 16 +
.../ssf/event/types/risc/OptOutEffective.java | 16 +
.../ssf/event/types/risc/OptOutInitiated.java | 16 +
.../event/types/risc/RecoveryActivated.java | 16 +
.../risc/RecoveryInformationChanged.java | 16 +
.../ssf/event/types/risc/RiscEvent.java | 10 +
.../types/scim/AsyncCompletionEvent.java | 13 +
.../ssf/event/types/scim/EventFeedAdded.java | 13 +
.../event/types/scim/EventFeedRemoved.java | 13 +
.../scim/ProvisioningActivatedEvent.java | 13 +
.../scim/ProvisioningCreatedEventFull.java | 13 +
.../scim/ProvisioningCreatedEventNotice.java | 13 +
.../scim/ProvisioningDeactivatedEvent.java | 13 +
.../types/scim/ProvisioningDeletedEvent.java | 13 +
.../scim/ProvisioningPatchEventFull.java | 13 +
.../scim/ProvisioningPatchEventNotice.java | 13 +
.../types/scim/ProvisioningPutEventFull.java | 13 +
.../scim/ProvisioningPutEventNotice.java | 13 +
.../ssf/event/types/scim/ScimEvent.java | 10 +
.../types/scim/ScimProvisioningEvent.java | 8 +
.../ssf/keys/TransmitterKeyManager.java | 24 ++
.../ssf/keys/TransmitterKeyProvider.java | 26 ++
.../keys/TransmitterKeyProviderFactory.java | 54 +++
.../ssf/keys/TransmitterPublicKeyLoader.java | 31 ++
.../ssf/receiver/DefaultSsfReceiver.java | 182 ++++++++++
.../receiver/DefaultSsfReceiverFactory.java | 67 ++++
.../protocol/ssf/receiver/ReceiverConfig.java | 168 ++++++++++
.../ssf/receiver/ReceiverKeyModel.java | 53 +++
.../protocol/ssf/receiver/ReceiverModel.java | 311 ++++++++++++++++++
.../protocol/ssf/receiver/SsfReceiver.java | 28 ++
.../ssf/receiver/SsfReceiverFactory.java | 11 +
.../ReceiverManagementEndpoint.java | 134 ++++++++
.../receiver/management/ReceiverManager.java | 289 ++++++++++++++++
.../management/ReceiverRepresentation.java | 180 ++++++++++
.../management/ReceiverStreamManager.java | 87 +++++
.../management/SsfStreamException.java | 27 ++
.../management/SsfVerificationEndpoint.java | 53 +++
.../streamclient/DefaultSsfStreamClient.java | 99 ++++++
.../streamclient/SsfStreamClient.java | 14 +
.../streamclient/SsfStreamException.java | 27 ++
.../DefaultSsfTransmitterClient.java | 126 +++++++
.../SsfTransmitterClient.java | 14 +
.../DefaultSsfVerificationClient.java | 47 +++
.../DefaultVerificationStore.java | 64 ++++
.../SsfStreamVerificationException.java | 17 +
.../verification/SsfVerificationClient.java | 12 +
.../verification/VerificationRequest.java | 36 ++
.../verification/VerificationState.java | 43 +++
.../verification/VerificationStore.java | 13 +
.../protocol/ssf/spi/DefaultSsfProvider.java | 236 +++++++++++++
.../protocol/ssf/spi/SsfProvider.java | 51 +++
.../protocol/ssf/spi/SsfProviderFactory.java | 6 +
.../org/keycloak/protocol/ssf/spi/SsfSpi.java | 28 ++
.../AbstractDeliveryMethodRepresentation.java | 89 +++++
.../ssf/stream/CreateStreamRequest.java | 50 +++
.../PollDeliveryMethodRepresentation.java | 12 +
.../PushDeliveryMethodRepresentation.java | 41 +++
.../ssf/stream/SsfStreamRepresentation.java | 144 ++++++++
.../stream/SsfStreamStatusRepresentation.java | 39 +++
.../protocol/ssf/stream/StreamStatus.java | 19 ++
.../ssf/support/SsfFailureResponse.java | 45 +++
.../protocol/ssf/support/SsfResponseUtil.java | 16 +
.../transmitter/SsfTransmitterMetadata.java | 178 ++++++++++
...ycloak.protocol.ssf.spi.SsfProviderFactory | 1 +
.../services/org.keycloak.provider.Spi | 1 +
...ices.resource.RealmResourceProviderFactory | 3 +-
124 files changed, 6301 insertions(+), 1 deletion(-)
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/Ssf.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/SsfException.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/SsfRealmResourceProvider.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/ErrorSecurityEventToken.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/InitiatingEntity.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEventToken.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEvents.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/delivery/DeliveryMethod.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/delivery/push/PushEndpoint.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/listener/DefaultSsfEventListener.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/listener/SsfEventListener.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/parser/DefaultSsfEventParser.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfEventParser.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfParsingException.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/processor/DefaultSsfEventProcessor.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventContext.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventProcessor.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AccountSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AliasesSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/ComplexSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/DidSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/EmailSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/GenericSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/IssuerSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/JwtSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/OpaqueSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/PhoneNumberSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/SamlAssertionSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/SubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/SubjectIdJsonDeserializer.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/SubjectIds.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/SubjectParsingException.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/SubjectUserLookup.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/subjects/UriSubjectId.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/GenericSsfEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/SecurityEventMapJsonDeserializer.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/SsfEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/StreamUpdatedEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/VerificationEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/AssuranceLevelChange.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/CaepEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/ChangeTypeDeserializer.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/CredentialChange.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/DeviceComplianceChange.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/RiskLevelChanged.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/SessionEstablished.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/SessionPresented.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/SessionRevoked.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/caep/TokenClaimsChanged.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/AccountCredentialChangeRequired.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/AccountDisabled.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/AccountEnabled.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/AccountPurged.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/CredentialCompromise.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/IdentifierChanged.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/IdentifierRecycled.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/OptIn.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/OptOutCancelled.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/OptOutEffective.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/OptOutInitiated.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/RecoveryActivated.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/RecoveryInformationChanged.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/risc/RiscEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/AsyncCompletionEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/EventFeedAdded.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/EventFeedRemoved.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningActivatedEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningCreatedEventFull.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningCreatedEventNotice.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningDeactivatedEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningDeletedEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningPatchEventFull.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningPatchEventNotice.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningPutEventFull.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ProvisioningPutEventNotice.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ScimEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/event/types/scim/ScimProvisioningEvent.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/keys/TransmitterKeyManager.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/keys/TransmitterKeyProvider.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/keys/TransmitterKeyProviderFactory.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/keys/TransmitterPublicKeyLoader.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/DefaultSsfReceiver.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/DefaultSsfReceiverFactory.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/ReceiverConfig.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/ReceiverKeyModel.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/ReceiverModel.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/SsfReceiver.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/SsfReceiverFactory.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/management/ReceiverManagementEndpoint.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/management/ReceiverManager.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/management/ReceiverRepresentation.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/management/ReceiverStreamManager.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/management/SsfStreamException.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/management/SsfVerificationEndpoint.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/streamclient/DefaultSsfStreamClient.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/streamclient/SsfStreamClient.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/streamclient/SsfStreamException.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/transmitterclient/DefaultSsfTransmitterClient.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/transmitterclient/SsfTransmitterClient.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/DefaultSsfVerificationClient.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/DefaultVerificationStore.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/SsfStreamVerificationException.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/SsfVerificationClient.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/VerificationRequest.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/VerificationState.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/receiver/verification/VerificationStore.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/spi/DefaultSsfProvider.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/spi/SsfProvider.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/spi/SsfProviderFactory.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/spi/SsfSpi.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/AbstractDeliveryMethodRepresentation.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/CreateStreamRequest.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/PollDeliveryMethodRepresentation.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/PushDeliveryMethodRepresentation.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/SsfStreamRepresentation.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/SsfStreamStatusRepresentation.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/stream/StreamStatus.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/support/SsfFailureResponse.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/support/SsfResponseUtil.java
create mode 100644 services/src/main/java/org/keycloak/protocol/ssf/transmitter/SsfTransmitterMetadata.java
create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.protocol.ssf.spi.SsfProviderFactory
diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index 0142ea3ac02b..8c25523581f6 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -160,6 +160,8 @@ public enum Feature {
DB_TIDB("TiDB database type", Type.EXPERIMENTAL),
+ SSF("Shared Signals Framework", Type.EXPERIMENTAL),
+
HTTP_OPTIMIZED_SERIALIZERS("Optimized JSON serializers for better performance of the HTTP layer", Type.PREVIEW),
OPENAPI("OpenAPI specification served at runtime", Type.EXPERIMENTAL, CLIENT_ADMIN_API_V2),
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/Ssf.java b/services/src/main/java/org/keycloak/protocol/ssf/Ssf.java
new file mode 100644
index 000000000000..ea62c1c0bdec
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/Ssf.java
@@ -0,0 +1,8 @@
+package org.keycloak.protocol.ssf;
+
+public class Ssf {
+
+ public static final String APPLICATION_SECEVENT_JWT_TYPE = "application/secevent+jwt";
+
+ public static final String SECEVENT_JWT_TYPE = "secevent+jwt";
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/SsfException.java b/services/src/main/java/org/keycloak/protocol/ssf/SsfException.java
new file mode 100644
index 000000000000..0e6278fbce39
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/SsfException.java
@@ -0,0 +1,15 @@
+package org.keycloak.protocol.ssf;
+
+public class SsfException extends RuntimeException {
+
+ public SsfException() {
+ }
+
+ public SsfException(String message) {
+ super(message);
+ }
+
+ public SsfException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/SsfRealmResourceProvider.java b/services/src/main/java/org/keycloak/protocol/ssf/SsfRealmResourceProvider.java
new file mode 100644
index 000000000000..91812044e728
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/SsfRealmResourceProvider.java
@@ -0,0 +1,116 @@
+package org.keycloak.protocol.ssf;
+
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+import org.keycloak.Config;
+import org.keycloak.common.Profile;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.protocol.ssf.event.delivery.push.PushEndpoint;
+import org.keycloak.protocol.ssf.receiver.management.ReceiverManagementEndpoint;
+import org.keycloak.protocol.ssf.spi.SsfProvider;
+import org.keycloak.provider.EnvironmentDependentProviderFactory;
+import org.keycloak.services.managers.AppAuthManager;
+import org.keycloak.services.managers.AuthenticationManager;
+import org.keycloak.services.resource.RealmResourceProvider;
+import org.keycloak.services.resource.RealmResourceProviderFactory;
+import org.keycloak.utils.KeycloakSessionUtil;
+
+public class SsfRealmResourceProvider implements RealmResourceProvider {
+
+ protected static final Logger log = Logger.getLogger(SsfRealmResourceProvider.class);
+
+ @Override
+ public Object getResource() {
+ return this;
+ }
+
+ protected AuthenticationManager.AuthResult authenticate() {
+ var session = KeycloakSessionUtil.getKeycloakSession();
+ var authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
+ var auth = authenticator.authenticate();
+ if (auth == null) {
+ throw new WebApplicationException(Response.Status.UNAUTHORIZED);
+ }
+ return auth;
+ }
+
+ // Receiver Endpoints below
+
+ /**
+ * $ISSUER/ssf/push/caepdev
+ *
+ * For example: https://tdworkshops.ngrok.dev/auth/realms/ssf-demo/ssf/push/caepdev
+ *
+ * @return
+ */
+ @Path("/push")
+ public PushEndpoint pushEndpoint() {
+ authenticate();
+ return SsfProvider.current().pushEndpoint();
+ }
+
+ // Receiver Management Endpoints below
+
+ /**
+ * $ISSUER/ssf/management
+ *
+ * For example: https://tdworkshops.ngrok.dev/auth/realms/ssf-demo/ssf/management
+ *
+ * @return
+ */
+ @Path("/management")
+ public ReceiverManagementEndpoint receiverManagementEndpoint() {
+ // TODO check manage permissions
+ authenticate();
+ return SsfProvider.current().receiverManagementEndpoint();
+ }
+
+
+ @Override
+ public void close() {
+ // NOOP
+ }
+
+ // @AutoService(RealmResourceProviderFactory.class)
+ public static class Factory implements RealmResourceProviderFactory, EnvironmentDependentProviderFactory {
+
+ private static final SsfRealmResourceProvider INSTANCE = new SsfRealmResourceProvider();
+
+ /**
+ * Exposes the SSF endpoints via $ISSUER/ssf
+ *
+ * @return
+ */
+ @Override
+ public String getId() {
+ return "ssf";
+ }
+
+ @Override
+ public RealmResourceProvider create(KeycloakSession keycloakSession) {
+ return INSTANCE;
+ }
+
+ @Override
+ public void init(Config.Scope scope) {
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
+ // NOOP
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public boolean isSupported(Config.Scope config) {
+ return Profile.isFeatureEnabled(Profile.Feature.SSF);
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/ErrorSecurityEventToken.java b/services/src/main/java/org/keycloak/protocol/ssf/event/ErrorSecurityEventToken.java
new file mode 100644
index 000000000000..1698ab9c7098
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/ErrorSecurityEventToken.java
@@ -0,0 +1,23 @@
+package org.keycloak.protocol.ssf.event;
+
+import org.keycloak.protocol.ssf.support.SsfFailureResponse;
+
+public class ErrorSecurityEventToken extends SecurityEventToken {
+
+ protected final SsfFailureResponse failureResponse;
+
+ public ErrorSecurityEventToken(String errorCode, String message) {
+ this.failureResponse = new SsfFailureResponse(errorCode, message);
+ }
+
+ public SsfFailureResponse getFailureResponse() {
+ return failureResponse;
+ }
+
+ @Override
+ public String toString() {
+ return "ErrorSecurityEventToken{" +
+ "failureResponse=" + failureResponse +
+ '}';
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/InitiatingEntity.java b/services/src/main/java/org/keycloak/protocol/ssf/event/InitiatingEntity.java
new file mode 100644
index 000000000000..8be83f073e23
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/InitiatingEntity.java
@@ -0,0 +1,22 @@
+package org.keycloak.protocol.ssf.event;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum InitiatingEntity {
+ ADMIN("admin"),
+ USER("user"),
+ POLICY("policy"),
+ SYSTEM("system"),
+ ;
+
+ private final String code;
+
+ InitiatingEntity(String code) {
+ this.code = code;
+ }
+
+ @JsonValue
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEventToken.java b/services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEventToken.java
new file mode 100644
index 000000000000..2471b90b6075
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEventToken.java
@@ -0,0 +1,69 @@
+package org.keycloak.protocol.ssf.event;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import org.keycloak.protocol.ssf.event.subjects.SubjectId;
+import org.keycloak.protocol.ssf.event.subjects.SubjectIdJsonDeserializer;
+import org.keycloak.representations.JsonWebToken;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class SecurityEventToken extends JsonWebToken {
+
+ @JsonProperty("sub_id")
+ @JsonDeserialize(using = SubjectIdJsonDeserializer.class)
+ protected SubjectId subjectId;
+
+ @JsonProperty("txn")
+ protected String txn;
+
+ @JsonProperty("events")
+ protected Map> events;
+
+ public SecurityEventToken txn(String txn) {
+ setTxn(txn);
+ return this;
+ }
+
+ public SubjectId getSubjectId() {
+ return subjectId;
+ }
+
+ public void setSubjectId(SubjectId subjectId) {
+ this.subjectId = subjectId;
+ }
+
+ public SecurityEventToken subjectId(SubjectId subjectId) {
+ setSubjectId(subjectId);
+ return this;
+ }
+
+ public Map> getEvents() {
+ if (events == null) {
+ events = new LinkedHashMap<>();
+ }
+ return events;
+ }
+
+ public void setEvents(Map> events) {
+ this.events = events;
+ }
+
+ public String getTxn() {
+ return txn;
+ }
+
+ public void setTxn(String txn) {
+ this.txn = txn;
+ }
+
+ @Override
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEvents.java b/services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEvents.java
new file mode 100644
index 000000000000..19fe291ce6c7
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/SecurityEvents.java
@@ -0,0 +1,139 @@
+package org.keycloak.protocol.ssf.event;
+
+
+import org.keycloak.protocol.ssf.event.types.GenericSsfEvent;
+import org.keycloak.protocol.ssf.event.types.SsfEvent;
+import org.keycloak.protocol.ssf.event.types.VerificationEvent;
+import org.keycloak.protocol.ssf.event.types.caep.AssuranceLevelChange;
+import org.keycloak.protocol.ssf.event.types.caep.CaepEvent;
+import org.keycloak.protocol.ssf.event.types.caep.CredentialChange;
+import org.keycloak.protocol.ssf.event.types.caep.DeviceComplianceChange;
+import org.keycloak.protocol.ssf.event.types.caep.SessionEstablished;
+import org.keycloak.protocol.ssf.event.types.caep.SessionPresented;
+import org.keycloak.protocol.ssf.event.types.caep.SessionRevoked;
+import org.keycloak.protocol.ssf.event.types.caep.TokenClaimsChanged;
+import org.keycloak.protocol.ssf.event.types.risc.AccountCredentialChangeRequired;
+import org.keycloak.protocol.ssf.event.types.risc.AccountDisabled;
+import org.keycloak.protocol.ssf.event.types.risc.AccountEnabled;
+import org.keycloak.protocol.ssf.event.types.risc.AccountPurged;
+import org.keycloak.protocol.ssf.event.types.risc.CredentialCompromise;
+import org.keycloak.protocol.ssf.event.types.risc.IdentifierChanged;
+import org.keycloak.protocol.ssf.event.types.risc.IdentifierRecycled;
+import org.keycloak.protocol.ssf.event.types.risc.OptIn;
+import org.keycloak.protocol.ssf.event.types.risc.OptOutCancelled;
+import org.keycloak.protocol.ssf.event.types.risc.OptOutEffective;
+import org.keycloak.protocol.ssf.event.types.risc.OptOutInitiated;
+import org.keycloak.protocol.ssf.event.types.risc.RecoveryActivated;
+import org.keycloak.protocol.ssf.event.types.risc.RecoveryInformationChanged;
+import org.keycloak.protocol.ssf.event.types.risc.RiscEvent;
+import org.keycloak.protocol.ssf.event.types.scim.AsyncCompletionEvent;
+import org.keycloak.protocol.ssf.event.types.scim.EventFeedAdded;
+import org.keycloak.protocol.ssf.event.types.scim.EventFeedRemoved;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningActivatedEvent;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningCreatedEventFull;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningCreatedEventNotice;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningDeactivatedEvent;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningDeletedEvent;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningPatchEventFull;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningPatchEventNotice;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningPutEventFull;
+import org.keycloak.protocol.ssf.event.types.scim.ProvisioningPutEventNotice;
+import org.keycloak.protocol.ssf.event.types.scim.ScimEvent;
+
+import javax.sound.midi.Patch;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class SecurityEvents {
+
+ public final static Map> CAEP_EVENTS_TYPES;
+ public final static Map> RISC_EVENTS_TYPES;
+ public static final Map> SCIM_EVENTS_TYPES;
+
+ static {
+ var caepEventTypes = new HashMap>();
+ List.of( //
+ new AssuranceLevelChange(), //
+ new CredentialChange(), //
+ new DeviceComplianceChange(), //
+ new SessionEstablished(), //
+ new SessionPresented(), //
+ new SessionRevoked(), //
+ new TokenClaimsChanged() //
+ ).forEach(caepEvent -> caepEventTypes.put(caepEvent.getEventType(), caepEvent.getClass()));
+ CAEP_EVENTS_TYPES = Collections.unmodifiableMap(caepEventTypes);
+
+ var riscEventTypes = new HashMap>();
+ List.of( //
+ new AccountCredentialChangeRequired(), //
+ new AccountDisabled(), //
+ new AccountEnabled(), //
+ new AccountPurged(), //
+ new CredentialCompromise(), //
+ new IdentifierChanged(), //
+ new IdentifierRecycled(), //
+ new OptIn(), //
+ new OptOutInitiated(), //
+ new OptOutCancelled(), //
+ new OptOutEffective(), //
+ new RecoveryActivated(), //
+ new RecoveryInformationChanged() //
+ ).forEach(riscEvent -> riscEventTypes.put(riscEvent.getEventType(), riscEvent.getClass()));
+ RISC_EVENTS_TYPES = Collections.unmodifiableMap(riscEventTypes);
+
+ var scimEventTypes = new HashMap>();
+ List.of(//
+ new AsyncCompletionEvent(), //
+ new EventFeedAdded(), //
+ new EventFeedRemoved(), //
+ new ProvisioningActivatedEvent(), //
+ new ProvisioningCreatedEventFull(), //
+ new ProvisioningCreatedEventNotice(), //
+ new ProvisioningDeactivatedEvent(), //
+ new ProvisioningDeletedEvent(), //
+ new ProvisioningPatchEventFull(), //
+ new ProvisioningPatchEventNotice(), //
+ new ProvisioningPutEventFull(), //
+ new ProvisioningPutEventNotice() //
+ );
+ SCIM_EVENTS_TYPES = Collections.unmodifiableMap(scimEventTypes);
+ }
+
+ public static boolean isCaepEvent(SsfEvent rawSsfEvent) {
+ return CAEP_EVENTS_TYPES.containsKey(rawSsfEvent.getEventType());
+ }
+
+ public static boolean isRiscEvent(SsfEvent rawSsfEvent) {
+ return RISC_EVENTS_TYPES.containsKey(rawSsfEvent.getEventType());
+ }
+
+ public static boolean isVerificationEventType(String eventType) {
+ return VerificationEvent.TYPE.equals(eventType);
+ }
+
+ public static Class extends SsfEvent> getSecurityEventType(String eventType) {
+
+ if (isVerificationEventType(eventType)) {
+ return VerificationEvent.class;
+ }
+
+ var caepEventType = CAEP_EVENTS_TYPES.get(eventType);
+ if (caepEventType != null) {
+ return caepEventType;
+ }
+
+ var riscEventType = RISC_EVENTS_TYPES.get(eventType);
+ if (riscEventType != null) {
+ return riscEventType;
+ }
+
+ var scimEventType = SCIM_EVENTS_TYPES.get(eventType);
+ if (scimEventType != null) {
+ return scimEventType;
+ }
+
+ return GenericSsfEvent.class;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/delivery/DeliveryMethod.java b/services/src/main/java/org/keycloak/protocol/ssf/event/delivery/DeliveryMethod.java
new file mode 100644
index 000000000000..620441699118
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/delivery/DeliveryMethod.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * 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
+ *
+ * http://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.
+ */
+package org.keycloak.protocol.ssf.event.delivery;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import java.net.URI;
+
+public enum DeliveryMethod {
+
+ PUSH("urn:ietf:rfc:8935")
+ , POLL("urn:ietf:rfc:8936")
+ ;
+
+ private final String specUrn;
+
+ DeliveryMethod(String specUrn) {
+ this.specUrn = specUrn;
+ }
+
+ @JsonValue
+ public String getSpecUrn() {
+ return specUrn;
+ }
+
+ public URI toUri() {
+ return URI.create(specUrn);
+ }
+ }
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/delivery/push/PushEndpoint.java b/services/src/main/java/org/keycloak/protocol/ssf/event/delivery/push/PushEndpoint.java
new file mode 100644
index 000000000000..34d540c924da
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/delivery/push/PushEndpoint.java
@@ -0,0 +1,138 @@
+package org.keycloak.protocol.ssf.event.delivery.push;
+
+import jakarta.ws.rs.HeaderParam;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.protocol.ssf.Ssf;
+import org.keycloak.protocol.ssf.event.SecurityEventToken;
+import org.keycloak.protocol.ssf.event.parser.SsfParsingException;
+import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
+import org.keycloak.protocol.ssf.receiver.ReceiverModel;
+import org.keycloak.protocol.ssf.spi.SsfProvider;
+import org.keycloak.protocol.ssf.support.SsfFailureResponse;
+
+import java.util.Set;
+
+import static org.keycloak.protocol.ssf.support.SsfResponseUtil.newSharedSignalFailureResponse;
+import static org.keycloak.utils.KeycloakSessionUtil.getKeycloakSession;
+
+/**
+ * Implements RFC 8935 Push-Based Security Event Token (SET) Delivery Using HTTP
+ *
+ * https://www.rfc-editor.org/rfc/rfc8935.html
+ */
+public class PushEndpoint {
+
+ protected static final Logger log = Logger.getLogger(PushEndpoint.class);
+
+ protected final SsfProvider ssfProvider;
+
+ public PushEndpoint(SsfProvider ssfProvider) {
+ this.ssfProvider = ssfProvider;
+ }
+
+ @Path("{receiverAlias}")
+ @POST
+ @Produces(MediaType.APPLICATION_JSON)
+// @Consumes(APPLICATION_SECEVENT_JWT_TYPE) // some SSF providers don't set the correct content-type
+ public Response ingestSecurityEventToken(@PathParam("receiverAlias") String receiverAlias, //
+ String encodedSecurityEventToken, //
+ @HeaderParam(HttpHeaders.AUTHORIZATION) String authToken, //
+ @HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType //
+ ) {
+
+ KeycloakSession session = getKeycloakSession();
+ KeycloakContext context = session.getContext();
+
+ ReceiverModel receiverModel = lookupReceiverModel(receiverAlias, context);
+ if (receiverModel == null) {
+ throw newSharedSignalFailureResponse(Response.Status.BAD_REQUEST, SsfFailureResponse.ERROR_INVALID_REQUEST, "Invalid receiver");
+ }
+
+ checkPushAuthorizationToken(authToken, receiverModel);
+
+ if (!Ssf.APPLICATION_SECEVENT_JWT_TYPE.equals(contentType)) {
+ log.warnf("Received PUSH request with unsupported content type '%s'.", contentType);
+ }
+
+ // parse security event token
+ var processingContext = ssfProvider.createSecurityEventProcessingContext(null, receiverAlias);
+
+ // TODO validate security event token
+ SecurityEventToken securityEventToken = parseSecurityEventToken(encodedSecurityEventToken, processingContext);
+
+ if (securityEventToken == null) {
+ throw newSharedSignalFailureResponse(Response.Status.BAD_REQUEST, SsfFailureResponse.ERROR_INVALID_REQUEST, "Invalid security event token");
+ }
+ RealmModel realm = context.getRealm();
+ log.debugf("Ingest security event token. realm=%s receiverAlias=%s jti=%s", realm.getName(), receiverAlias, securityEventToken.getId());
+
+ checkIssuer(receiverModel, securityEventToken, securityEventToken.getIssuer());
+
+ checkAudience(receiverModel, securityEventToken, securityEventToken.getAudience());
+
+ processingContext.setSecurityEventToken(securityEventToken);
+
+ handleSecurityEvent(processingContext);
+
+ if (!processingContext.isProcessedSuccessfully()) {
+ // See 2.3. Failure Response https://www.rfc-editor.org/rfc/rfc8935.html#section-2.3
+ return Response.serverError().type(MediaType.APPLICATION_JSON).build();
+ }
+
+ // See 2.2. Success Response https://www.rfc-editor.org/rfc/rfc8935.html#section-2.2
+ return Response.accepted().type(MediaType.APPLICATION_JSON).build();
+ }
+
+ protected ReceiverModel lookupReceiverModel(String receiverAlias, KeycloakContext context) {
+ return ssfProvider.receiverManager().getReceiverModel(context, receiverAlias);
+ }
+
+ protected void checkPushAuthorizationToken(String authToken, ReceiverModel receiverModel) {
+ String pushAuthorizationToken = receiverModel.getPushAuthorizationToken();
+ if (pushAuthorizationToken != null) {
+ if (validatePushAuthToken(receiverModel, authToken, pushAuthorizationToken)) {
+ throw newSharedSignalFailureResponse(Response.Status.UNAUTHORIZED, SsfFailureResponse.ERROR_AUTHENTICATION_FAILED, "Invalid auth token");
+ }
+ }
+ }
+
+ protected SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfEventContext processingContext) {
+ try {
+ return ssfProvider.parseSecurityEventToken(encodedSecurityEventToken, processingContext);
+ } catch (SsfParsingException sepe) {
+ // see https://www.rfc-editor.org/rfc/rfc8935.html#section-2.4
+ throw newSharedSignalFailureResponse(Response.Status.BAD_REQUEST, SsfFailureResponse.ERROR_INVALID_REQUEST, sepe.getMessage());
+ }
+ }
+
+ protected void handleSecurityEvent(SsfEventContext processingContext) {
+ ssfProvider.processSecurityEvents(processingContext);
+ }
+
+ protected void checkIssuer(ReceiverModel receiverModel, SecurityEventToken securityEventToken, String issuer) {
+ if (!receiverModel.getIssuer().equals(issuer)) {
+ throw newSharedSignalFailureResponse(Response.Status.BAD_REQUEST, SsfFailureResponse.ERROR_INVALID_ISSUER, "Invalid issuer");
+ }
+ }
+
+ protected void checkAudience(ReceiverModel receiverModel, SecurityEventToken securityEventToken, String[] audience) {
+ if (!receiverModel.getAudience().containsAll(Set.of(audience))) {
+ throw newSharedSignalFailureResponse(Response.Status.BAD_REQUEST, SsfFailureResponse.ERROR_INVALID_AUDIENCE, "Invalid audience");
+ }
+ }
+
+ protected boolean validatePushAuthToken(ReceiverModel receiverModel, String authToken, String pushAuthorizationToken) {
+ return !("Bearer " + pushAuthorizationToken).equals(authToken);
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/listener/DefaultSsfEventListener.java b/services/src/main/java/org/keycloak/protocol/ssf/event/listener/DefaultSsfEventListener.java
new file mode 100644
index 000000000000..131376c509a4
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/listener/DefaultSsfEventListener.java
@@ -0,0 +1,62 @@
+package org.keycloak.protocol.ssf.event.listener;
+
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
+import org.keycloak.protocol.ssf.event.subjects.SubjectId;
+import org.keycloak.protocol.ssf.event.subjects.SubjectUserLookup;
+import org.keycloak.protocol.ssf.event.types.SsfEvent;
+import org.keycloak.protocol.ssf.event.types.caep.SessionRevoked;
+
+import java.util.List;
+
+public class DefaultSsfEventListener implements SsfEventListener {
+
+ protected static final Logger log = Logger.getLogger(DefaultSsfEventListener.class);
+
+ protected final KeycloakSession session;
+
+ public DefaultSsfEventListener(KeycloakSession session) {
+ this.session = session;
+ }
+
+ @Override
+ public void onEvent(SsfEventContext eventContext, String eventId, SsfEvent event) {
+ String eventType = event.getEventType();
+ SubjectId subjectId = event.getSubjectId();
+ var eventClass = event.getClass();
+ log.infof("Security event received. eventId=%s eventType=%s subjectId=%s eventClass=%s", eventId, eventType, subjectId, eventClass.getName());
+
+ KeycloakContext context = session.getContext();
+ RealmModel realm = context.getRealm();
+
+ UserModel user = lookupUser(realm, subjectId);
+ handleSecurityEvent(event, realm, subjectId, user);
+ }
+
+ protected UserModel lookupUser(RealmModel realm, SubjectId subjectId) {
+ return SubjectUserLookup.lookupUser(session, realm, subjectId);
+ }
+
+ protected void handleSecurityEvent(SsfEvent ssfEvent, RealmModel realm, SubjectId subjectId, UserModel user) {
+
+ if (user == null) {
+ return;
+ }
+
+ if (ssfEvent instanceof SessionRevoked) {
+ List sessions = session.sessions().getUserSessionsStream(realm, user).toList();
+ if (!sessions.isEmpty()) {
+ for (var userSession : sessions) {
+ session.sessions().removeUserSession(realm, userSession);
+ }
+ log.debugf("Removed %s sessions for user. realm=%s userId=%s", sessions.size(), realm.getName(), user.getId());
+ }
+ }
+ }
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/listener/SsfEventListener.java b/services/src/main/java/org/keycloak/protocol/ssf/event/listener/SsfEventListener.java
new file mode 100644
index 000000000000..39055c04f7c2
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/listener/SsfEventListener.java
@@ -0,0 +1,10 @@
+package org.keycloak.protocol.ssf.event.listener;
+
+import org.keycloak.protocol.ssf.event.types.SsfEvent;
+import org.keycloak.protocol.ssf.event.processor.SsfEventContext;
+
+public interface SsfEventListener {
+
+ void onEvent(SsfEventContext eventContext, String eventId, SsfEvent event);
+
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/parser/DefaultSsfEventParser.java b/services/src/main/java/org/keycloak/protocol/ssf/event/parser/DefaultSsfEventParser.java
new file mode 100644
index 000000000000..11385699d50d
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/parser/DefaultSsfEventParser.java
@@ -0,0 +1,70 @@
+package org.keycloak.protocol.ssf.event.parser;
+
+import org.jboss.logging.Logger;
+import org.keycloak.crypto.KeyWrapper;
+import org.keycloak.crypto.SignatureProvider;
+import org.keycloak.jose.jws.JWSHeader;
+import org.keycloak.jose.jws.JWSInput;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.ssf.event.SecurityEventToken;
+import org.keycloak.protocol.ssf.receiver.SsfReceiver;
+
+import java.nio.charset.StandardCharsets;
+
+public class DefaultSsfEventParser implements SsfEventParser {
+
+ protected static final Logger log = Logger.getLogger(DefaultSsfEventParser.class);
+
+ protected final KeycloakSession session;
+
+ public DefaultSsfEventParser(KeycloakSession session) {
+ this.session = session;
+ }
+
+ @Override
+ public SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfReceiver receiver) {
+
+ try {
+ // custom decode method to use keys from ReceiverComponent
+ var securityEventToken = decode(encodedSecurityEventToken, receiver);
+ return securityEventToken;
+ } catch (Exception e) {
+ throw new SsfParsingException("Could not parse security event token", e);
+ }
+ }
+
+ protected SecurityEventToken decode(String encodedSecurityEventToken, SsfReceiver receiver) {
+
+ if (encodedSecurityEventToken == null) {
+ return null;
+ }
+
+ try {
+ JWSInput jws = new JWSInput(encodedSecurityEventToken);
+ JWSHeader header = jws.getHeader();
+ String kid = header.getKeyId();
+ String alg = header.getRawAlgorithm();
+
+ KeyWrapper key = receiver.getKeys()
+ .filter(kw -> kw.getKid().equals(kid) && kw.getAlgorithm().equals(alg))
+ .findFirst()
+ .orElse(null);
+ if (key == null) {
+ throw new SsfParsingException("Could not find key with kid " + kid);
+ }
+
+ SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, alg);
+ if (signatureProvider == null) {
+ throw new SsfParsingException("Could not find verifier for alg " + alg);
+ }
+
+ byte[] tokenBytes = jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8);
+ boolean valid = signatureProvider.verifier(key)
+ .verify(tokenBytes, jws.getSignature());
+ return valid ? jws.readJsonContent(SecurityEventToken.class) : null;
+ } catch (Exception e) {
+ log.debug("Failed to decode token", e);
+ return null;
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfEventParser.java b/services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfEventParser.java
new file mode 100644
index 000000000000..d03998b7f383
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfEventParser.java
@@ -0,0 +1,9 @@
+package org.keycloak.protocol.ssf.event.parser;
+
+import org.keycloak.protocol.ssf.event.SecurityEventToken;
+import org.keycloak.protocol.ssf.receiver.SsfReceiver;
+
+public interface SsfEventParser {
+
+ SecurityEventToken parseSecurityEventToken(String encodedSecurityEventToken, SsfReceiver receiver);
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfParsingException.java b/services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfParsingException.java
new file mode 100644
index 000000000000..595403e1a1ac
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/parser/SsfParsingException.java
@@ -0,0 +1,14 @@
+package org.keycloak.protocol.ssf.event.parser;
+
+import org.keycloak.protocol.ssf.SsfException;
+
+public class SsfParsingException extends SsfException {
+
+ public SsfParsingException(String message) {
+ super(message);
+ }
+
+ public SsfParsingException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/processor/DefaultSsfEventProcessor.java b/services/src/main/java/org/keycloak/protocol/ssf/event/processor/DefaultSsfEventProcessor.java
new file mode 100644
index 000000000000..dd8066013f63
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/processor/DefaultSsfEventProcessor.java
@@ -0,0 +1,188 @@
+package org.keycloak.protocol.ssf.event.processor;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.jboss.logging.Logger;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.RealmModel;
+import org.keycloak.protocol.ssf.event.SecurityEventToken;
+import org.keycloak.protocol.ssf.event.SecurityEvents;
+import org.keycloak.protocol.ssf.event.listener.SsfEventListener;
+import org.keycloak.protocol.ssf.event.parser.SsfParsingException;
+import org.keycloak.protocol.ssf.event.subjects.OpaqueSubjectId;
+import org.keycloak.protocol.ssf.event.subjects.SubjectId;
+import org.keycloak.protocol.ssf.event.types.SsfEvent;
+import org.keycloak.protocol.ssf.event.types.StreamUpdatedEvent;
+import org.keycloak.protocol.ssf.event.types.VerificationEvent;
+import org.keycloak.protocol.ssf.receiver.ReceiverModel;
+import org.keycloak.protocol.ssf.receiver.verification.SsfStreamVerificationException;
+import org.keycloak.protocol.ssf.receiver.verification.VerificationState;
+import org.keycloak.protocol.ssf.receiver.verification.VerificationStore;
+import org.keycloak.protocol.ssf.spi.SsfProvider;
+
+import java.util.Map;
+
+public class DefaultSsfEventProcessor implements SsfEventProcessor {
+
+ protected static final Logger log = Logger.getLogger(DefaultSsfEventProcessor.class);
+
+ protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ protected final SsfEventListener ssfEventListener;
+
+ protected final VerificationStore verificationStore;
+
+ public DefaultSsfEventProcessor(SsfProvider ssfProvider, SsfEventListener ssfEventListener, VerificationStore verificationStore) {
+ this.ssfEventListener = ssfEventListener;
+ this.verificationStore = verificationStore;
+ }
+
+ @Override
+ public void processSecurityEvents(SsfEventContext eventContext) {
+
+ SecurityEventToken securityEventToken = eventContext.getSecurityEventToken();
+ Map> events = securityEventToken.getEvents();
+ for (var entry : events.entrySet()) {
+ String eventId = securityEventToken.getId();
+ String securityEventType = entry.getKey();
+ Map securityEventData = entry.getValue();
+
+ try {
+ SsfEvent ssfEvent = convertEventPayloadToSecurityEvent(securityEventType, securityEventData, securityEventToken);
+
+ if (ssfEvent instanceof VerificationEvent verificationEvent) {
+ // handle verification event
+
+ if (events.size() > 1) {
+ log.warnf("Found more than one security event for token with verification request. %s", eventId);
+ }
+
+ boolean verified = handleVerificationEvent(eventContext, verificationEvent, eventId);
+ if (verified) {
+ break;
+ }
+ } else if (ssfEvent instanceof StreamUpdatedEvent streamUpdatedEvent) {
+ // handle stream updated event
+ boolean streamUpdated = handleStreamUpdatedEvent(eventContext, streamUpdatedEvent, eventId);
+ if (streamUpdated) {
+ break;
+ }
+ } else {
+ // handle generic SSF event
+ handleEvent(eventContext, eventId, ssfEvent);
+ }
+ } catch (final SsfParsingException spe) {
+ eventContext.setProcessedSuccessfully(false);
+ throw spe;
+ }
+ }
+
+ eventContext.setProcessedSuccessfully(true);
+ }
+
+ protected SsfEvent convertEventPayloadToSecurityEvent(String securityEventType, Map securityEventData, SecurityEventToken securityEventToken) {
+
+ Class extends SsfEvent> eventClass = getEventType(securityEventType);
+
+ if (eventClass == null) {
+ throw new SsfParsingException("Could not parse security event. Unknown event type: " + securityEventType);
+ }
+
+ try {
+ SsfEvent ssfEvent = convertToSsfEvent(securityEventData, eventClass);
+ ssfEvent.setEventType(securityEventType);
+ if (ssfEvent.getSubjectId() == null) {
+ // use subjectId from SET if none was provided for the event explicitly.
+ ssfEvent.setSubjectId(securityEventToken.getSubjectId());
+ }
+
+ return ssfEvent;
+ } catch (Exception e) {
+ throw new SsfParsingException("Could not parse security event.", e);
+ }
+ }
+
+ protected SsfEvent convertToSsfEvent(Map securityEventData, Class extends SsfEvent> eventClass) {
+ return OBJECT_MAPPER.convertValue(securityEventData, eventClass);
+ }
+
+ protected Class extends SsfEvent> getEventType(String securityEventType) {
+ return SecurityEvents.getSecurityEventType(securityEventType);
+ }
+
+ protected boolean handleVerificationEvent(SsfEventContext processingContext, VerificationEvent verificationEvent, String jti) {
+
+ KeycloakContext keycloakContext = processingContext.getSession().getContext();
+
+ String streamId = extractStreamIdFromVerificationEvent(processingContext, verificationEvent);
+
+ RealmModel realm = keycloakContext.getRealm();
+ ReceiverModel receiverModel = processingContext.getReceiver().getReceiverModel();
+
+ if (!receiverModel.getStreamId().equals(streamId)) {
+ log.debugf("Verification failed! StreamId mismatch. jti=%s expectedStreamId=%s actualStreamId=%s", jti, receiverModel.getStreamId(), streamId);
+ return false;
+ }
+
+ VerificationState verificationState = getVerificationState(realm, receiverModel);
+
+ String givenState = verificationEvent.getState();
+ String expectedState = verificationState == null ? null : verificationState.getState();
+
+ if (givenState.equals(expectedState)) {
+ log.debugf("Verification successful!. jti=%s state=%s", jti, givenState);
+ verificationStore.clearVerificationState(realm, receiverModel);
+ return true;
+ }
+
+ log.warnf("Verification failed. jti=%s state=%s", jti, givenState);
+ throw new SsfStreamVerificationException("Verification state mismatch.");
+ }
+
+ protected boolean handleStreamUpdatedEvent(SsfEventContext processingContext, StreamUpdatedEvent streamUpdatedEvent, String jti) {
+
+ KeycloakContext keycloakContext = processingContext.getSession().getContext();
+ RealmModel realm = keycloakContext.getRealm();
+
+ OpaqueSubjectId opaqueSubjectId = (OpaqueSubjectId) processingContext.getSecurityEventToken().getSubjectId();
+
+ log.debugf("Handling stream updated event. realm=%s jti=%s streamId=%s newStatus=%s", realm.getName(), jti, opaqueSubjectId.getId(), streamUpdatedEvent.getStatus());
+
+ return false;
+ }
+
+
+ protected VerificationState getVerificationState(RealmModel realm, ReceiverModel receiverModel) {
+ return verificationStore.getVerificationState(realm, receiverModel);
+ }
+
+ protected String extractStreamIdFromVerificationEvent(SsfEventContext processingContext, SsfEvent ssfEvent) {
+ // see: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-7.1.4.2
+
+ String streamId = null;
+
+ // See: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-7.1.4.1
+ // try to extract subjectId from securityEvent
+ SubjectId subjectId = ssfEvent.getSubjectId();
+ if (subjectId instanceof OpaqueSubjectId opaqueSubjectId) {
+ streamId = opaqueSubjectId.getId();
+ }
+
+ if (streamId == null) {
+ // as a fallback, try to extract subjectId from securityEventToken
+ subjectId = processingContext.getSecurityEventToken().getSubjectId();
+ if (subjectId instanceof OpaqueSubjectId opaqueSubjectId) {
+ streamId = opaqueSubjectId.getId();
+ }
+ }
+
+ // TODO find a reliable way to extract the streamId from the verification event
+ if (streamId == null) {
+ throw new SsfStreamVerificationException("Could not find stream id for verification request");
+ }
+ return streamId;
+ }
+
+ protected void handleEvent(SsfEventContext eventContext, String eventId, SsfEvent event) {
+ ssfEventListener.onEvent(eventContext, eventId, event);
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventContext.java b/services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventContext.java
new file mode 100644
index 000000000000..07c27c0972c0
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventContext.java
@@ -0,0 +1,48 @@
+package org.keycloak.protocol.ssf.event.processor;
+
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.ssf.event.SecurityEventToken;
+import org.keycloak.protocol.ssf.receiver.SsfReceiver;
+
+public class SsfEventContext {
+
+ protected KeycloakSession session;
+
+ protected SsfReceiver receiver;
+
+ protected SecurityEventToken securityEventToken;
+
+ protected boolean processedSuccessfully;
+
+ public SecurityEventToken getSecurityEventToken() {
+ return securityEventToken;
+ }
+
+ public void setSecurityEventToken(SecurityEventToken securityEventToken) {
+ this.securityEventToken = securityEventToken;
+ }
+
+ protected void setProcessedSuccessfully(boolean processedSuccessfully) {
+ this.processedSuccessfully = processedSuccessfully;
+ }
+
+ public boolean isProcessedSuccessfully() {
+ return processedSuccessfully;
+ }
+
+ public KeycloakSession getSession() {
+ return session;
+ }
+
+ public void setSession(KeycloakSession session) {
+ this.session = session;
+ }
+
+ public SsfReceiver getReceiver() {
+ return receiver;
+ }
+
+ public void setReceiver(SsfReceiver receiver) {
+ this.receiver = receiver;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventProcessor.java b/services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventProcessor.java
new file mode 100644
index 000000000000..ced35fdca980
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/processor/SsfEventProcessor.java
@@ -0,0 +1,6 @@
+package org.keycloak.protocol.ssf.event.processor;
+
+public interface SsfEventProcessor {
+
+ void processSecurityEvents(SsfEventContext context);
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AccountSubjectId.java b/services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AccountSubjectId.java
new file mode 100644
index 000000000000..3c31a35c41ca
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AccountSubjectId.java
@@ -0,0 +1,30 @@
+package org.keycloak.protocol.ssf.event.subjects;
+
+/**
+ * See: https://datatracker.ietf.org/doc/html/rfc9493#name-email-identifier-format
+ */
+public class AccountSubjectId extends SubjectId {
+
+ public static final String TYPE = "account";
+
+ protected String uri;
+
+ public AccountSubjectId() {
+ super(TYPE);
+ }
+
+ public String getUri() {
+ return uri;
+ }
+
+ public void setUri(String uri) {
+ this.uri = uri;
+ }
+
+ @Override
+ public String toString() {
+ return "AccountSubjectId{" +
+ "uri='" + uri + '\'' +
+ '}';
+ }
+}
diff --git a/services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AliasesSubjectId.java b/services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AliasesSubjectId.java
new file mode 100644
index 000000000000..dfd529b75e64
--- /dev/null
+++ b/services/src/main/java/org/keycloak/protocol/ssf/event/subjects/AliasesSubjectId.java
@@ -0,0 +1,36 @@
+package org.keycloak.protocol.ssf.event.subjects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * See: https://datatracker.ietf.org/doc/html/rfc9493#name-aliases-identifier-format
+ */
+public class AliasesSubjectId extends SubjectId {
+
+ public static final String TYPE = "aliases";
+
+ @JsonProperty("identifiers")
+ protected List