Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,19 @@
*/
public class GenericSsfEvent extends SsfEvent {

/**
* Required by Jackson: {@code SsfEventMapJsonDeserializer} materialises unknown
* event payloads via {@code treeToValue(eventData, GenericSsfEvent.class)} and
* sets the actual event type URI afterwards through {@link #setEventType}.
*/
public GenericSsfEvent() {
super(null);
this(null);
}

public GenericSsfEvent(String eventType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here:

  1. This constructor doesn't appear to be called anywhere except from the no-arg one just above — was it intended for something specific, or is it speculative API?
  2. More substantively: because GenericSsfEvent extends SsfEvent directly, unknown event types now lose typed subject parsing. Previously subject went through SubjectIdJsonDeserializer into a typed SubjectId; now it lands in attributes as a raw LinkedHashMap and getSubjectId() is gone entirely. Nothing on main reads it today (SubjectSubscriptionFilter uses the token's subject), but it is a capability regression for extension/unknown events.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ctor is intended to be used by custom implementations that don't want to implement their own event classes.

As I mentioned in #50793 (comment) the subject attribute of an event is not used by modern events and exists only for backwards compatbility with implementations that used the older SSE CAEP structures (such as Apple).

super(eventType);

// Generic events don't have an alias by default
setAlias(null);
}

@Override
public String toString() {
return "GenericSecurityEvent{" +
"subjectId=" + subjectId +
", eventType='" + eventType + '\'' +
", eventTimestamp=" + eventTimestamp +
", initiatingEntity=" + initiatingEntity +
", reasonAdmin=" + reasonAdmin +
", reasonUser=" + reasonUser +
", attributes=" + attributes +
'}';
}
}
166 changes: 83 additions & 83 deletions ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,117 +3,55 @@
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import org.keycloak.ssf.subject.SubjectId;
import org.keycloak.ssf.subject.SubjectIdJsonDeserializer;

import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

/**
* Represents a generic SSF event.
*
* <p>
* See: https://datatracker.ietf.org/doc/html/rfc8417
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public abstract class SsfEvent {

private static final ConcurrentMap<Class<?>, Set<String>> DECLARED_JSON_PROPERTIES = new ConcurrentHashMap<>();

/**
* Internal (shorter) alias for the event type.
*/
@JsonIgnore
protected String alias;

@JsonProperty("subject")
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
protected SubjectId subjectId;

@JsonIgnore
protected String eventType;

/**
* The time of the event (UNIX timestamp). Nullable so events that do
* not carry a timestamp — e.g. {@code ssf/event-type/verification}
* (SSF §8.1.4 carries only {@code state}) and other stream-management
* events — are omitted from the wire JSON instead of being serialized
* as {@code "event_timestamp": 0} (the default value of a primitive
* {@code long}, which Jackson always emits).
*/
@JsonProperty("event_timestamp")
protected Long eventTimestamp;

/**
* The entity that initiated the event
*/
@JsonProperty("initiating_entity")
protected InitiatingEntity initiatingEntity;

/**
* A localized administrative message intended for logging and auditing.
* key is language code, value is message.
* The event type URI
*/
@JsonProperty("reason_admin")
protected Map<String, String> reasonAdmin;
@JsonIgnore
protected String eventType;

/**
* A localized message intended for the end user.
* key is language code, value is message.
* Additional unmapped event-specific fields.
*/
@JsonProperty("reason_user")
protected Map<String, String> reasonUser;

@JsonIgnore
protected Map<String, Object> attributes = new HashMap<>();

public SsfEvent(String eventType) {
this.eventType = eventType;

// use the simple class name as the default alias
this.alias = getClass().getSimpleName();
}

public SubjectId getSubjectId() {
return subjectId;
}

public Long getEventTimestamp() {
return eventTimestamp;
}

public void setEventTimestamp(long eventTimestamp) {
this.eventTimestamp = eventTimestamp;
}

public InitiatingEntity getInitiatingEntity() {
return initiatingEntity;
}

public void setInitiatingEntity(InitiatingEntity initiatingEntity) {
this.initiatingEntity = initiatingEntity;
}

public Map<String, String> getReasonAdmin() {
return reasonAdmin;
this(eventType, null);
}

public void setReasonAdmin(Map<String, String> reasonAdmin) {
this.reasonAdmin = reasonAdmin;
}

public Map<String, String> getReasonUser() {
return reasonUser;
}

public void setReasonUser(Map<String, String> reasonUser) {
this.reasonUser = reasonUser;
public SsfEvent(String eventType, String alias) {
this.eventType = eventType;
// use the simple class name as the default alias
this.alias = alias == null ? getClass().getSimpleName() : alias;
}

public String getEventType() {
Expand Down Expand Up @@ -141,13 +79,11 @@ public void setAttributeValue(String key, Object value) {
if (declaredJsonPropertyNames(getClass()).contains(key)) {
throw new IllegalArgumentException(
"Custom attribute key '" + key + "' collides with a declared @JsonProperty on "
+ getClass().getName());
+ getClass().getName());
}
attributes.put(key, value);
}

private static final ConcurrentMap<Class<?>, Set<String>> DECLARED_JSON_PROPERTIES = new ConcurrentHashMap<>();

private static Set<String> declaredJsonPropertyNames(Class<?> type) {
return DECLARED_JSON_PROPERTIES.computeIfAbsent(type, t -> {
Set<String> names = new HashSet<>();
Expand All @@ -167,10 +103,6 @@ public void setEventType(String eventType) {
this.eventType = eventType;
}

public void setSubjectId(SubjectId subjectId) {
this.subjectId = subjectId;
}

public String getAlias() {
return alias;
}
Expand Down Expand Up @@ -202,4 +134,72 @@ public void setAlias(String alias) {
public void validate() {
// no-op — overridden by event subclasses that have spec-required fields
}

@Override
public String toString() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A subclass that overrides toString() instead of appendFields() would silently drop every inherited field, with no compile-time signal. The javadoc on appendFields says the right thing, but making this final would actually enforce it — and since every subclass in the tree now uses the hook, nothing would break.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep toString() overridable since custom/extension events may legitimately want to fully control their rendering rather than go through appendFields.
The javadoc on appendFields documents the contract for subclasses that only contribute fields; I'd rather keep the escape hatch than enforce it at compile time.

Map<String, Object> fields = new LinkedHashMap<>();
appendFields(fields);
if (attributes != null && !attributes.isEmpty()) {
Map<String, Object> renderedAttributes = new LinkedHashMap<>();
for (var entry : attributes.entrySet()) {
Comment thread
thomasdarimont marked this conversation as resolved.
if (entry.getValue() != null) {
renderedAttributes.put(entry.getKey(), entry.getValue());
}
}
if (!renderedAttributes.isEmpty()) {
fields.putIfAbsent("attributes", renderedAttributes);
}
}
StringJoiner rendered = new StringJoiner(", ");
for (var entry : fields.entrySet()) {
Object value = entry.getValue();
if (value == null) {
continue;
}
rendered.add(entry.getKey() + "=" + render(value));
}
String name = alias != null ? alias : getClass().getSimpleName();
return rendered.length() == 0 ? name : name + "{" + rendered + "}";
}

/**
* Renders a field value for {@link #toString()}, quoting {@link String}
* values at every nesting level so entries inside maps (extension
* {@link #attributes}, {@code reason_admin} / {@code reason_user}) read the
* same as top-level fields. Values originate from JSON payloads, which
* cannot form cycles, so the recursion is bounded.
*/
private static String render(Object value) {
if (value instanceof String) {
return "'" + value + '\'';
}
if (value instanceof Map<?, ?> map) {
StringJoiner joiner = new StringJoiner(", ", "{", "}");
for (var entry : map.entrySet()) {
joiner.add(entry.getKey() + "=" + render(entry.getValue()));
}
return joiner.toString();
}
if (value instanceof Iterable<?> iterable) {
StringJoiner joiner = new StringJoiner(", ", "[", "]");
for (var element : iterable) {
joiner.add(render(element));
}
return joiner.toString();
}
return String.valueOf(value);
}

/**
* Contributes the fields of this level of the event hierarchy to the
* {@link #toString()} output; insertion order is the render order.
* Subclasses override this (calling {@code super.appendFields(fields)} first)
* instead of {@code toString()} itself. Values may be put unconditionally —
* {@code null} entries are filtered and {@link String} values quoted centrally
* at every nesting level when rendering, and the extension {@link #attributes}
* map is appended automatically when non-empty.
*/
protected void appendFields(Map<String, Object> fields) {
fields.put("eventType", eventType);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.keycloak.ssf.event.caep;

import java.util.Map;

import org.keycloak.ssf.event.SsfEventValidationException;

import com.fasterxml.jackson.annotation.JsonCreator;
Expand All @@ -15,8 +17,7 @@ public class CaepCredentialChange extends CaepEvent {
/**
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-credential-change
*/
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/credential-change";

public static final String TYPE = CaepEvent.EVENT_TYPE_BASE_URI + "credential-change";

/**
* This MUST be one of the following strings, or any other credential type supported mutually by the Transmitter and the Receiver.
Expand Down Expand Up @@ -205,14 +206,13 @@ public String getType() {
}

@Override
public String toString() {
return "CredentialChange{" +
"credentialType=" + credentialType +
", changeType=" + changeType +
", friendlyName='" + friendlyName + '\'' +
", x509Issuer='" + x509Issuer + '\'' +
", x509Serial='" + x509Serial + '\'' +
", fido2Aaguid='" + fido2Aaguid + '\'' +
'}';
protected void appendFields(Map<String, Object> fields) {
super.appendFields(fields);
fields.put("credentialType", credentialType);
fields.put("changeType", changeType);
fields.put("friendlyName", friendlyName);
fields.put("x509Issuer", x509Issuer);
fields.put("x509Serial", x509Serial);
fields.put("fido2Aaguid", fido2Aaguid);
}
}
Loading
Loading