From 0e313d5fb5458780a4740eb9d73bf82b324dd71f Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Tue, 6 Oct 2026 01:22:43 +0200 Subject: [PATCH] Add typed CAEP and RISC event access SsfCaepEventHandler and SsfRiscEventHandler dispatch the events of a SET to a method per event kind, with SsfCaepEvent and SsfRiscEvent giving typed access to the claims CAEP 1.0 and RISC 1.0 define. An event type in either namespace that this version does not know is kind OTHER and reaches onOtherCaepEvent or onOtherRiscEvent with the common claims typed; SsfEventContext.eventFor stays for raw access. Adds the deprecated RISC sessions-revoked type and SsfEventTimestamps for the shared timestamp rule. Closes #30 Signed-off-by: Thomas Darimont --- CHANGELOG.md | 9 + README.md | 26 ++ easyssf-core/AGENTS.md | 5 + easyssf-core/src/main/java/module-info.java | 2 + .../org/easyssf/core/caep/SsfCaepEvent.java | 303 ++++++++++++++++++ .../easyssf/core/caep/SsfCaepEventKind.java | 93 ++++++ .../core/event/SsfEventTimestamps.java | 28 ++ .../org/easyssf/core/event/SsfEventTypes.java | 26 ++ .../org/easyssf/core/risc/SsfRiscEvent.java | 133 ++++++++ .../easyssf/core/risc/SsfRiscEventKind.java | 134 ++++++++ .../easyssf/core/caep/SsfCaepEventTests.java | 200 ++++++++++++ .../easyssf/core/risc/SsfRiscEventTests.java | 124 +++++++ easyssf-receiver/AGENTS.md | 3 +- .../src/main/java/module-info.java | 2 + .../receiver/caep/SsfCaepEventHandler.java | 131 ++++++++ .../receiver/event/SsfEventContext.java | 9 +- .../receiver/risc/SsfRiscEventHandler.java | 174 ++++++++++ .../caep/SsfCaepEventHandlerTests.java | 166 ++++++++++ .../risc/SsfRiscEventHandlerTests.java | 188 +++++++++++ 19 files changed, 1749 insertions(+), 7 deletions(-) create mode 100644 easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEvent.java create mode 100644 easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEventKind.java create mode 100644 easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTimestamps.java create mode 100644 easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEvent.java create mode 100644 easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEventKind.java create mode 100644 easyssf-core/src/test/java/org/easyssf/core/caep/SsfCaepEventTests.java create mode 100644 easyssf-core/src/test/java/org/easyssf/core/risc/SsfRiscEventTests.java create mode 100644 easyssf-receiver/src/main/java/org/easyssf/receiver/caep/SsfCaepEventHandler.java create mode 100644 easyssf-receiver/src/main/java/org/easyssf/receiver/risc/SsfRiscEventHandler.java create mode 100644 easyssf-receiver/src/test/java/org/easyssf/receiver/caep/SsfCaepEventHandlerTests.java create mode 100644 easyssf-receiver/src/test/java/org/easyssf/receiver/risc/SsfRiscEventHandlerTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a61cb..488ea80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ The notable changes of every release. The section of a version is the text of it - `SsfEventToken.idempotencyKey()` and `SsfEventContext.idempotencyKey()`, the issuer and `jti` of a SET, as the key for the side effects of a handler: processing is at least once, which the documentation now says in those words. +- Typed CAEP and RISC events: `SsfCaepEventHandler` and `SsfRiscEventHandler` dispatch the events of a + SET to a method per event (`onSessionRevoked`, `onAssuranceLevelChange`, `onAccountDisabled`, ...), + with `SsfCaepEvent` and `SsfRiscEvent` from `easyssf-core` for the claims the specifications define + (`eventTimestamp()`, `initiatingEntity()`, the localized `reasonAdmin()` and `reasonUser()`, + `credentialType()`, `currentLevel()`, `changeDirection()`, `reason()`, `newValue()`, ...). An event + type in the CAEP or RISC namespace that easyssf does not know yet is `SsfCaepEventKind.OTHER` or + `SsfRiscEventKind.OTHER` and reaches `onOtherCaepEvent` or `onOtherRiscEvent`. `SsfEventContext.eventFor` + stays for raw access. `SsfEventTypes` gained `RiscSessionsRevoked` (deprecated by RISC, still sent) and + `isCaepEvent`/`isRiscEvent`; `SsfEventTimestamps` reads an `event_timestamp` in seconds or milliseconds. - Schema migration: `easyssf-receiver-jdbc` ships versioned migration scripts in `org/easyssf/receiver/jdbc/migration/` (Flyway naming, plain SQL), and `JdbcSsfSchema.prepareTable` adds the columns a release introduced to an existing table where it may create tables diff --git a/README.md b/README.md index 87bbe16..08c3f96 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,30 @@ class StepUpHandler implements SsfEventHandler { } ``` +For CAEP and RISC events there are typed handlers that spare you the claim names: `SsfCaepEventHandler` +dispatches every CAEP event of a SET to a method per event (`onSessionRevoked`, `onCredentialChange`, +`onAssuranceLevelChange`, `onDeviceComplianceChange`, `onRiskLevelChange`, ...) with an `SsfCaepEvent` +that reads the common claims of CAEP section 2 (`eventTimestamp()`, `initiatingEntity()`, `reasonAdmin()` +and `reasonUser()` by language tag) and the event-specific ones (`credentialType()`, `changeType()`, +`currentLevel()`, `changeDirection()`, `currentStatus()`, `amr()`, `principal()`, ...). `SsfRiscEventHandler` +does the same for RISC (`onAccountDisabled`, `onIdentifierChanged`, `onCredentialCompromise`, ...) with +`SsfRiscEvent` (`reason()`, `newValue()`, `credentialType()`). An event type in the CAEP or RISC namespace +that this version does not know reaches `onOtherCaepEvent` or `onOtherRiscEvent`, with the common claims +typed and the rest in `payload()`; the raw map stays available through `eventContext.eventFor(...)`. + +```java +class StepUpHandler extends SsfCaepEventHandler { + + @Override + protected void onAssuranceLevelChange(SsfCaepEvent event, SsfEventContext eventContext) { + if ("decrease".equals(event.changeDirection())) { + stepUp.require(eventContext.subject().subject(), event.currentLevel()); + } + } + +} +``` + **A SET is handled at least once, not exactly once**, so handlers must be idempotent. If a handler throws, the SET is not acknowledged and the transmitter is expected to deliver it again, in which case all handlers run again, possibly on another instance of the application. With a shared dedup store a @@ -308,6 +332,8 @@ Ready-made pieces for the common reactions: access tokens of revoked sessions and users. - `SsfSessionTerminationEventHandler` + your `SsfSessionTerminator` to end local sessions; `SsfSubjectClaimsMatcher` tells whether the subject of an event matches the claims of a user. +- `SsfCaepEventHandler` and `SsfRiscEventHandler` to react to CAEP and RISC events by kind, with + `SsfCaepEvent` and `SsfRiscEvent` from `easyssf-core` for the typed payload. - `SsfScimEventHandler` to react to SCIM Events (RFC 9967) by operation, with `SsfScimEvent` and `SsfScimSubject` from `easyssf-core` for the payload and the resource. - `SsfStreamClient` and `SsfStreamRegistrar` for stream management, diff --git a/easyssf-core/AGENTS.md b/easyssf-core/AGENTS.md index 2454d5c..2272f83 100644 --- a/easyssf-core/AGENTS.md +++ b/easyssf-core/AGENTS.md @@ -12,5 +12,10 @@ keep it that way. ones unchanged. `SsfSubjectIdentifiers` builds identifiers for tests and stream requests. - `org.easyssf.core.scim` is what is *in* a SCIM Event SET (subject, operation, typed payload). Behaviour against a SCIM service provider does not belong here; it would be a module of its own. + `org.easyssf.core.caep` and `org.easyssf.core.risc` do the same for CAEP and RISC: a kind enum + with `OTHER` for an unknown event type of the namespace, and a record with an accessor per + claim the specification defines, each returning `null` when absent. A new event of a + specification gets a constant and alias in `SsfEventTypes`, a kind, accessors for its claims and + an `onXxx` method in the receiver's handler. - `module-info.java` exports every package; add the line for a new one. - Tests: `SsfValueObjectsTests` checks immutability of the records, each type has `Tests`. diff --git a/easyssf-core/src/main/java/module-info.java b/easyssf-core/src/main/java/module-info.java index acb5172..81e5a0e 100644 --- a/easyssf-core/src/main/java/module-info.java +++ b/easyssf-core/src/main/java/module-info.java @@ -4,8 +4,10 @@ */ module org.easyssf.core { exports org.easyssf.core; + exports org.easyssf.core.caep; exports org.easyssf.core.event; exports org.easyssf.core.metadata; + exports org.easyssf.core.risc; exports org.easyssf.core.scim; exports org.easyssf.core.stream; exports org.easyssf.core.support; diff --git a/easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEvent.java b/easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEvent.java new file mode 100644 index 0000000..57ffce1 --- /dev/null +++ b/easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEvent.java @@ -0,0 +1,303 @@ +package org.easyssf.core.caep; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.easyssf.core.event.SsfEventTimestamps; +import org.easyssf.core.event.SsfEventToken; +import org.easyssf.core.event.SsfEventTypes; +import org.easyssf.core.support.SsfAssert; +import org.easyssf.core.support.SsfCollections; + +/** + * A CAEP 1.0 event of a SET, with typed access to the claims every CAEP event may carry + * (section 2: {@link #eventTimestamp()}, {@link #initiatingEntity()}, + * {@link #reasonAdmin()}, {@link #reasonUser()}) and to the event-specific claims of + * section 3. An accessor returns {@code null} (or an empty collection) when the claim is + * absent, whether because the transmitter left it out or because the {@link #kind()} does + * not define it. The {@link #payload()} is the event as received, for anything else. + * + *

+ * The subject of the event is the {@code sub_id} of the SET, + * {@code SsfEventContext.subject()} in a handler: CAEP does not prescribe a subject + * format. + * + * @param eventType the event type URI + * @param kind the kind of event, {@link SsfCaepEventKind#OTHER} for a CAEP event type + * this version does not know + * @param payload the event payload as received + */ +public record SsfCaepEvent(String eventType, SsfCaepEventKind kind, Map payload) { + + public SsfCaepEvent { + SsfAssert.notNull(eventType, "eventType must not be null"); + SsfAssert.notNull(kind, "kind must not be null"); + payload = SsfCollections.copyOf((payload != null) ? payload : Map.of()); + } + + /** + * @param eventType the event type URI or alias + * @param payload the event payload, may be {@code null} + * @return the event, {@code null} if the event type is not a CAEP event + */ + public static SsfCaepEvent of(String eventType, Map payload) { + SsfCaepEventKind kind = SsfCaepEventKind.of(eventType); + if (kind == null) { + return null; + } + return new SsfCaepEvent(SsfEventTypes.resolve(eventType), kind, payload); + } + + /** + * The CAEP events of a SET, in the order of its {@code events} claim. CAEP allows one + * event per SET; a SET with several is read like one of each. + * @param eventToken a verified SET + * @return the CAEP events, empty if the SET carries none + */ + @SuppressWarnings("unchecked") + public static List in(SsfEventToken eventToken) { + SsfAssert.notNull(eventToken, "eventToken must not be null"); + List events = new ArrayList<>(); + for (Map.Entry entry : eventToken.events().entrySet()) { + SsfCaepEventKind kind = SsfCaepEventKind.of(entry.getKey()); + if (kind != null) { + Map payload = (entry.getValue() instanceof Map map) ? (Map) map + : Map.of(); + events.add(new SsfCaepEvent(entry.getKey(), kind, payload)); + } + } + return List.copyOf(events); + } + + // CAEP section 2: claims every event may carry + + /** + * @return the {@code event_timestamp}, the time the event occurred, {@code null} if + * absent. CAEP defines seconds since the epoch; a value that is clearly milliseconds + * is accepted too. + */ + public Instant eventTimestamp() { + return SsfEventTimestamps.from(this.payload.get("event_timestamp")); + } + + /** + * @return the {@code initiating_entity}, who invoked the event: {@code admin}, + * {@code user}, {@code policy} or {@code system}; {@code null} if absent + */ + public String initiatingEntity() { + return string("initiating_entity"); + } + + /** + * @return the {@code reason_admin}, the reason for administrators, by BCP 47 language + * tag ({@code en}, {@code de-CH}, ...); empty if absent. A transmitter that sends a + * plain string instead of the localizable object of CAEP gets the key {@code ""}. + */ + public Map reasonAdmin() { + return localized("reason_admin"); + } + + /** + * @return the {@code reason_user}, the reason for display to the end user, by BCP 47 + * language tag; empty if absent, a plain string under the key {@code ""} + */ + public Map reasonUser() { + return localized("reason_user"); + } + + // CAEP section 3: event-specific claims + + /** + * @return the {@code claims} of a {@link SsfCaepEventKind#TOKEN_CLAIMS_CHANGE}, each + * with its new value; {@code null} if absent + */ + @SuppressWarnings("unchecked") + public Map claims() { + return (this.payload.get("claims") instanceof Map claims) ? (Map) claims : null; + } + + /** + * @return the {@code credential_type} of a + * {@link SsfCaepEventKind#CREDENTIAL_CHANGE}: {@code password}, {@code pin}, + * {@code x509}, {@code fido2-platform}, {@code fido2-roaming}, {@code fido-u2f}, + * {@code verifiable-credential}, {@code phone-voice}, {@code phone-sms}, {@code app} + * or a value agreed with the transmitter; {@code null} if absent + */ + public String credentialType() { + return string("credential_type"); + } + + /** + * @return the {@code change_type} of a {@link SsfCaepEventKind#CREDENTIAL_CHANGE}: + * {@code create}, {@code revoke}, {@code update} or {@code delete}; {@code null} if + * absent + */ + public String changeType() { + return string("change_type"); + } + + /** + * @return the {@code friendly_name} of the credential of a + * {@link SsfCaepEventKind#CREDENTIAL_CHANGE}, {@code null} if absent + */ + public String friendlyName() { + return string("friendly_name"); + } + + /** + * @return the {@code x509_issuer} of an X.509 credential, {@code null} if absent + */ + public String x509Issuer() { + return string("x509_issuer"); + } + + /** + * @return the {@code x509_serial} of an X.509 credential, {@code null} if absent + */ + public String x509Serial() { + return string("x509_serial"); + } + + /** + * @return the {@code fido2_aaguid}, the FIDO2 Authenticator Attestation GUID of the + * credential, {@code null} if absent + */ + public String fido2Aaguid() { + return string("fido2_aaguid"); + } + + /** + * @return the {@code namespace} of the levels of an + * {@link SsfCaepEventKind#ASSURANCE_LEVEL_CHANGE}: {@code RFC8176}, {@code RFC6711}, + * {@code ISO-IEC-29115}, {@code NIST-IAL}, {@code NIST-AAL}, {@code NIST-FAL} or a + * value agreed with the transmitter; {@code null} if absent + */ + public String namespace() { + return string("namespace"); + } + + /** + * @return the {@code current_level}: the assurance level of an + * {@link SsfCaepEventKind#ASSURANCE_LEVEL_CHANGE} in its {@link #namespace()}, or the + * risk level of a {@link SsfCaepEventKind#RISK_LEVEL_CHANGE} ({@code LOW}, + * {@code MEDIUM}, {@code HIGH}); {@code null} if absent + */ + public String currentLevel() { + return string("current_level"); + } + + /** + * @return the {@code previous_level} of an assurance or risk level change, + * {@code null} if absent: the transmitter does not know the previous level + */ + public String previousLevel() { + return string("previous_level"); + } + + /** + * @return the {@code change_direction} of an + * {@link SsfCaepEventKind#ASSURANCE_LEVEL_CHANGE}: {@code increase} or + * {@code decrease}; {@code null} if absent + */ + public String changeDirection() { + return string("change_direction"); + } + + /** + * @return the {@code current_status} of a + * {@link SsfCaepEventKind#DEVICE_COMPLIANCE_CHANGE}: {@code compliant} or + * {@code not-compliant}; {@code null} if absent + */ + public String currentStatus() { + return string("current_status"); + } + + /** + * @return the {@code previous_status} of a + * {@link SsfCaepEventKind#DEVICE_COMPLIANCE_CHANGE}: {@code compliant} or + * {@code not-compliant}; {@code null} if absent + */ + public String previousStatus() { + return string("previous_status"); + } + + /** + * @return the {@code fp_ua}, the fingerprint of the user agent of a + * {@link SsfCaepEventKind#SESSION_ESTABLISHED} or + * {@link SsfCaepEventKind#SESSION_PRESENTED}; {@code null} if absent + */ + public String fpUa() { + return string("fp_ua"); + } + + /** + * @return the {@code acr}, the authentication context class reference of a + * {@link SsfCaepEventKind#SESSION_ESTABLISHED}, as in an ID Token; {@code null} if + * absent + */ + public String acr() { + return string("acr"); + } + + /** + * @return the {@code amr}, the authentication methods references of a + * {@link SsfCaepEventKind#SESSION_ESTABLISHED}, as in an ID Token; empty if absent + */ + public List amr() { + if (!(this.payload.get("amr") instanceof List amr)) { + return List.of(); + } + return amr.stream().filter(String.class::isInstance).map(String.class::cast).toList(); + } + + /** + * @return the {@code ext_id}, the external session identifier of a + * {@link SsfCaepEventKind#SESSION_ESTABLISHED} or + * {@link SsfCaepEventKind#SESSION_PRESENTED} that correlates it with a broader + * session, for example a SAML one; {@code null} if absent + */ + public String extId() { + return string("ext_id"); + } + + /** + * @return the {@code principal} of a {@link SsfCaepEventKind#RISK_LEVEL_CHANGE}, the + * entity whose risk changed: {@code USER}, {@code DEVICE}, {@code SESSION}, + * {@code TENANT}, {@code ORG_UNIT}, {@code GROUP} or another entity of SSF; + * {@code null} if absent + */ + public String principal() { + return string("principal"); + } + + /** + * @return the {@code risk_reason} of a {@link SsfCaepEventKind#RISK_LEVEL_CHANGE}, + * {@code null} if absent + */ + public String riskReason() { + return string("risk_reason"); + } + + private String string(String name) { + Object value = this.payload.get(name); + return (value != null) ? value.toString() : null; + } + + private Map localized(String name) { + Object value = this.payload.get(name); + if (value instanceof Map byLanguage) { + Map reasons = new LinkedHashMap<>(); + byLanguage.forEach((language, text) -> { + if (text != null) { + reasons.put(String.valueOf(language), text.toString()); + } + }); + return Map.copyOf(reasons); + } + return (value != null) ? Map.of("", value.toString()) : Map.of(); + } + +} diff --git a/easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEventKind.java b/easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEventKind.java new file mode 100644 index 0000000..120aae4 --- /dev/null +++ b/easyssf-core/src/main/java/org/easyssf/core/caep/SsfCaepEventKind.java @@ -0,0 +1,93 @@ +package org.easyssf.core.caep; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.easyssf.core.event.SsfEventTypes; + +/** + * The events of CAEP 1.0, section 3, and {@link #OTHER} for an event type in the CAEP + * namespace this version does not know. New constants are added as CAEP defines events, + * so do not switch over this enum exhaustively; subclass {@code SsfCaepEventHandler} + * instead. + */ +public enum SsfCaepEventKind { + + /** + * The session was revoked (section 3.1). + */ + SESSION_REVOKED(SsfEventTypes.CAEP_SESSION_REVOKED), + + /** + * Claims of the token the subject identifies changed (section 3.2). + */ + TOKEN_CLAIMS_CHANGE(SsfEventTypes.CAEP_TOKEN_CLAIMS_CHANGE), + + /** + * A credential was created, revoked, updated or deleted (section 3.3). + */ + CREDENTIAL_CHANGE(SsfEventTypes.CAEP_CREDENTIAL_CHANGE), + + /** + * The assurance level of the subject changed (section 3.4). + */ + ASSURANCE_LEVEL_CHANGE(SsfEventTypes.CAEP_ASSURANCE_LEVEL_CHANGE), + + /** + * The compliance status of the device changed (section 3.5). + */ + DEVICE_COMPLIANCE_CHANGE(SsfEventTypes.CAEP_DEVICE_COMPLIANCE_CHANGE), + + /** + * A session was established (section 3.6). + */ + SESSION_ESTABLISHED(SsfEventTypes.CAEP_SESSION_ESTABLISHED), + + /** + * A session was presented to the transmitter (section 3.7). + */ + SESSION_PRESENTED(SsfEventTypes.CAEP_SESSION_PRESENTED), + + /** + * The risk level of the subject changed (section 3.8). + */ + RISK_LEVEL_CHANGE(SsfEventTypes.CAEP_RISK_LEVEL_CHANGE), + + /** + * An event type in the CAEP namespace this version does not know, for example one of + * a later CAEP version. The common claims of CAEP section 2 are still available. + */ + OTHER(null); + + private static final Map BY_EVENT_TYPE = Arrays.stream(values()) + .filter((kind) -> kind.eventType != null) + .collect(Collectors.toUnmodifiableMap((kind) -> kind.eventType, Function.identity())); + + private final String eventType; + + SsfCaepEventKind(String eventType) { + this.eventType = eventType; + } + + /** + * @return the event type URI, {@code null} for {@link #OTHER} + */ + public String eventType() { + return this.eventType; + } + + /** + * @param eventType an event type URI or alias + * @return the kind of CAEP event, {@link #OTHER} for an unknown event type in the + * CAEP namespace, {@code null} if the event type is not a CAEP event + */ + public static SsfCaepEventKind of(String eventType) { + if (!SsfEventTypes.isCaepEvent(eventType)) { + return null; + } + return BY_EVENT_TYPE.getOrDefault(SsfEventTypes.resolve(eventType), OTHER); + } + +} diff --git a/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTimestamps.java b/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTimestamps.java new file mode 100644 index 0000000..ba35077 --- /dev/null +++ b/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTimestamps.java @@ -0,0 +1,28 @@ +package org.easyssf.core.event; + +import java.time.Instant; + +/** + * Reads the {@code event_timestamp} claim of an event payload. CAEP 1.0 (section 2) and + * RISC 1.0 (section 2.7) define it as a JSON number of seconds since the epoch. + */ +public final class SsfEventTimestamps { + + private SsfEventTimestamps() { + } + + /** + * @param value the value of an {@code event_timestamp} claim + * @return the instant, {@code null} if the value is not a number. A value that is + * clearly milliseconds is read as such: some transmitters send milliseconds. + */ + public static Instant from(Object value) { + if (!(value instanceof Number timestamp)) { + return null; + } + long number = timestamp.longValue(); + // CAEP defines seconds since epoch, some transmitters send milliseconds + return (number > 100_000_000_000L) ? Instant.ofEpochMilli(number) : Instant.ofEpochSecond(number); + } + +} diff --git a/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTypes.java b/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTypes.java index f6a425f..ad58dde 100644 --- a/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTypes.java +++ b/easyssf-core/src/main/java/org/easyssf/core/event/SsfEventTypes.java @@ -66,6 +66,13 @@ public final class SsfEventTypes { public static final String RISC_RECOVERY_INFORMATION_CHANGED = RISC + "recovery-information-changed"; + /** + * RISC 1.0, section 2.11: all sessions of the account were revoked. Deprecated by + * RISC in favour of {@link #CAEP_SESSION_REVOKED}, but still emitted by some + * transmitters. + */ + public static final String RISC_SESSIONS_REVOKED = RISC + "sessions-revoked"; + /** * SCIM Events (RFC 9967, section 2.3.1): the resource was added to the event feed. */ @@ -162,6 +169,7 @@ public final class SsfEventTypes { register("RiscOptOutEffective", RISC_OPT_OUT_EFFECTIVE); register("RiscRecoveryActivated", RISC_RECOVERY_ACTIVATED); register("RiscRecoveryInformationChanged", RISC_RECOVERY_INFORMATION_CHANGED); + register("RiscSessionsRevoked", RISC_SESSIONS_REVOKED); register("ScimFeedAdd", SCIM_FEED_ADD); register("ScimFeedRemove", SCIM_FEED_REMOVE); register("ScimProvCreateNotice", SCIM_PROV_CREATE_NOTICE); @@ -234,6 +242,24 @@ public static boolean isAlias(String name) { return name != null && URI_BY_ALIAS.containsKey(name); } + /** + * @return whether the event type is in the CAEP namespace + * ({@code https://schemas.openid.net/secevent/caep/event-type/}), one of the events + * of CAEP 1.0 or a later one + */ + public static boolean isCaepEvent(String aliasOrUri) { + return aliasOrUri != null && resolve(aliasOrUri).startsWith(CAEP); + } + + /** + * @return whether the event type is in the RISC namespace + * ({@code https://schemas.openid.net/secevent/risc/event-type/}), one of the events + * of RISC 1.0 or a later one + */ + public static boolean isRiscEvent(String aliasOrUri) { + return aliasOrUri != null && resolve(aliasOrUri).startsWith(RISC); + } + /** * Resolves an alias to its event type URI. Anything that is not a known alias is * returned unchanged, so full URIs (including vendor specific ones) pass through. diff --git a/easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEvent.java b/easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEvent.java new file mode 100644 index 0000000..56e445b --- /dev/null +++ b/easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEvent.java @@ -0,0 +1,133 @@ +package org.easyssf.core.risc; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.easyssf.core.event.SsfEventTimestamps; +import org.easyssf.core.event.SsfEventToken; +import org.easyssf.core.event.SsfEventTypes; +import org.easyssf.core.support.SsfAssert; +import org.easyssf.core.support.SsfCollections; + +/** + * A RISC 1.0 event of a SET, with typed access to the few attributes RISC defines: the + * {@link #reason()} of an account-disabled, the {@link #newValue()} of an + * identifier-changed, and the {@link #credentialType()}, {@link #eventTimestamp()} and + * reasons of a credential-compromise. Most RISC events have no attributes; the + * {@link #payload()} is the event as received for anything else. An accessor returns + * {@code null} when the attribute is absent, whether because the transmitter left it out + * or because the {@link #kind()} does not define it. + * + *

+ * The subject of the event is the {@code sub_id} of the SET, + * {@code SsfEventContext.subject()} in a handler. + * + * @param eventType the event type URI + * @param kind the kind of event, {@link SsfRiscEventKind#OTHER} for a RISC event type + * this version does not know + * @param payload the event payload as received + */ +public record SsfRiscEvent(String eventType, SsfRiscEventKind kind, Map payload) { + + public SsfRiscEvent { + SsfAssert.notNull(eventType, "eventType must not be null"); + SsfAssert.notNull(kind, "kind must not be null"); + payload = SsfCollections.copyOf((payload != null) ? payload : Map.of()); + } + + /** + * @param eventType the event type URI or alias + * @param payload the event payload, may be {@code null} + * @return the event, {@code null} if the event type is not a RISC event + */ + public static SsfRiscEvent of(String eventType, Map payload) { + SsfRiscEventKind kind = SsfRiscEventKind.of(eventType); + if (kind == null) { + return null; + } + return new SsfRiscEvent(SsfEventTypes.resolve(eventType), kind, payload); + } + + /** + * The RISC events of a SET, in the order of its {@code events} claim. + * @param eventToken a verified SET + * @return the RISC events, empty if the SET carries none + */ + @SuppressWarnings("unchecked") + public static List in(SsfEventToken eventToken) { + SsfAssert.notNull(eventToken, "eventToken must not be null"); + List events = new ArrayList<>(); + for (Map.Entry entry : eventToken.events().entrySet()) { + SsfRiscEventKind kind = SsfRiscEventKind.of(entry.getKey()); + if (kind != null) { + Map payload = (entry.getValue() instanceof Map map) ? (Map) map + : Map.of(); + events.add(new SsfRiscEvent(entry.getKey(), kind, payload)); + } + } + return List.copyOf(events); + } + + /** + * @return the {@code reason} of an {@link SsfRiscEventKind#ACCOUNT_DISABLED}: + * {@code hijacking} or {@code bulk-account}; {@code null} if absent + */ + public String reason() { + return string("reason"); + } + + /** + * @return the {@code new-value} of an {@link SsfRiscEventKind#IDENTIFIER_CHANGED}, + * the new identifier; {@code null} if absent + */ + public String newValue() { + return string("new-value"); + } + + /** + * @return the {@code credential_type} of a + * {@link SsfRiscEventKind#CREDENTIAL_COMPROMISE}, one of the values of CAEP's + * credential-change ({@code password}, {@code pin}, {@code x509}, ...); {@code null} + * if absent + */ + public String credentialType() { + return string("credential_type"); + } + + /** + * @return the {@code event_timestamp} of a + * {@link SsfRiscEventKind#CREDENTIAL_COMPROMISE}, when the transmitter discovered the + * compromise, {@code null} if absent. RISC defines seconds since the epoch; a value + * that is clearly milliseconds is accepted too. + */ + public Instant eventTimestamp() { + return SsfEventTimestamps.from(this.payload.get("event_timestamp")); + } + + /** + * @return the {@code reason_admin} of a + * {@link SsfRiscEventKind#CREDENTIAL_COMPROMISE}, the reason for administrators, + * {@code null} if absent. RISC leaves the type open; a localizable object as CAEP + * defines it is returned as its JSON text. + */ + public String reasonAdmin() { + return string("reason_admin"); + } + + /** + * @return the {@code reason_user} of a + * {@link SsfRiscEventKind#CREDENTIAL_COMPROMISE}, the reason for the end user, + * {@code null} if absent + */ + public String reasonUser() { + return string("reason_user"); + } + + private String string(String name) { + Object value = this.payload.get(name); + return (value != null) ? value.toString() : null; + } + +} diff --git a/easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEventKind.java b/easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEventKind.java new file mode 100644 index 0000000..8c80d62 --- /dev/null +++ b/easyssf-core/src/main/java/org/easyssf/core/risc/SsfRiscEventKind.java @@ -0,0 +1,134 @@ +package org.easyssf.core.risc; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.easyssf.core.event.SsfEventTypes; + +/** + * The events of RISC 1.0, section 2, and {@link #OTHER} for an event type in the RISC + * namespace this version does not know. New constants are added as RISC defines events, + * so do not switch over this enum exhaustively; subclass {@code SsfRiscEventHandler} + * instead. + */ +public enum SsfRiscEventKind { + + /** + * The account has to change its credential, for example after a leak elsewhere + * (section 2.1). + */ + ACCOUNT_CREDENTIAL_CHANGE_REQUIRED(SsfEventTypes.RISC_ACCOUNT_CREDENTIAL_CHANGE_REQUIRED), + + /** + * The account was permanently deleted (section 2.2). + */ + ACCOUNT_PURGED(SsfEventTypes.RISC_ACCOUNT_PURGED), + + /** + * The account was disabled, see {@link SsfRiscEvent#reason()} (section 2.3). + */ + ACCOUNT_DISABLED(SsfEventTypes.RISC_ACCOUNT_DISABLED), + + /** + * The account was enabled again (section 2.4). + */ + ACCOUNT_ENABLED(SsfEventTypes.RISC_ACCOUNT_ENABLED), + + /** + * The identifier of the subject, an email address or phone number, changed to + * {@link SsfRiscEvent#newValue()} (section 2.5). + */ + IDENTIFIER_CHANGED(SsfEventTypes.RISC_IDENTIFIER_CHANGED), + + /** + * The identifier was given to someone else (section 2.6). + */ + IDENTIFIER_RECYCLED(SsfEventTypes.RISC_IDENTIFIER_RECYCLED), + + /** + * A credential of the subject was found compromised, see + * {@link SsfRiscEvent#credentialType()} (section 2.7). + */ + CREDENTIAL_COMPROMISE(SsfEventTypes.RISC_CREDENTIAL_COMPROMISE), + + /** + * The account takes part in the RISC exchange (section 2.8.1). + */ + OPT_IN(SsfEventTypes.RISC_OPT_IN), + + /** + * The user asked to leave the RISC exchange (section 2.8.2). + */ + OPT_OUT_INITIATED(SsfEventTypes.RISC_OPT_OUT_INITIATED), + + /** + * The user withdrew the request to leave (section 2.8.3). + */ + OPT_OUT_CANCELLED(SsfEventTypes.RISC_OPT_OUT_CANCELLED), + + /** + * The account left the RISC exchange (section 2.8.4). + */ + OPT_OUT_EFFECTIVE(SsfEventTypes.RISC_OPT_OUT_EFFECTIVE), + + /** + * The account went through a recovery flow (section 2.9). + */ + RECOVERY_ACTIVATED(SsfEventTypes.RISC_RECOVERY_ACTIVATED), + + /** + * The recovery information of the account changed (section 2.10). + */ + RECOVERY_INFORMATION_CHANGED(SsfEventTypes.RISC_RECOVERY_INFORMATION_CHANGED), + + /** + * All sessions of the account were revoked (section 2.11). Deprecated by RISC in + * favour of CAEP's {@code session-revoked}, still sent by some transmitters. + */ + SESSIONS_REVOKED(SsfEventTypes.RISC_SESSIONS_REVOKED), + + /** + * An event type in the RISC namespace this version does not know. + */ + OTHER(null); + + private static final Map BY_EVENT_TYPE = Arrays.stream(values()) + .filter((kind) -> kind.eventType != null) + .collect(Collectors.toUnmodifiableMap((kind) -> kind.eventType, Function.identity())); + + private final String eventType; + + SsfRiscEventKind(String eventType) { + this.eventType = eventType; + } + + /** + * @return the event type URI, {@code null} for {@link #OTHER} + */ + public String eventType() { + return this.eventType; + } + + /** + * Whether this is one of the opt-in and opt-out events of section 2.8, which report + * the participation of the account in the exchange rather than a risk. + */ + public boolean isOptOutState() { + return this == OPT_IN || this == OPT_OUT_INITIATED || this == OPT_OUT_CANCELLED || this == OPT_OUT_EFFECTIVE; + } + + /** + * @param eventType an event type URI or alias + * @return the kind of RISC event, {@link #OTHER} for an unknown event type in the + * RISC namespace, {@code null} if the event type is not a RISC event + */ + public static SsfRiscEventKind of(String eventType) { + if (!SsfEventTypes.isRiscEvent(eventType)) { + return null; + } + return BY_EVENT_TYPE.getOrDefault(SsfEventTypes.resolve(eventType), OTHER); + } + +} diff --git a/easyssf-core/src/test/java/org/easyssf/core/caep/SsfCaepEventTests.java b/easyssf-core/src/test/java/org/easyssf/core/caep/SsfCaepEventTests.java new file mode 100644 index 0000000..44c5a5a --- /dev/null +++ b/easyssf-core/src/test/java/org/easyssf/core/caep/SsfCaepEventTests.java @@ -0,0 +1,200 @@ +package org.easyssf.core.caep; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.easyssf.core.event.SsfEventToken; +import org.easyssf.core.event.SsfEventTypes; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easyssf.core.event.SsfSubjectIdentifiers.email; + +class SsfCaepEventTests { + + private static final String UNKNOWN_CAEP_EVENT = "https://schemas.openid.net/secevent/caep/event-type/geo-fence-exit"; + + @Test + void everyCaepEventTypeHasAKindAndAnAlias() { + for (SsfCaepEventKind kind : SsfCaepEventKind.values()) { + if (kind == SsfCaepEventKind.OTHER) { + assertThat(kind.eventType()).isNull(); + continue; + } + assertThat(kind.eventType()).startsWith("https://schemas.openid.net/secevent/caep/event-type/"); + assertThat(SsfCaepEventKind.of(kind.eventType())).isEqualTo(kind); + assertThat(SsfEventTypes.aliasOf(kind.eventType())).startsWith("Caep"); + assertThat(SsfCaepEventKind.of(SsfEventTypes.aliasOf(kind.eventType()))).isEqualTo(kind); + } + assertThat(SsfCaepEventKind.values()).hasSize(9); + } + + @Test + void unknownEventTypeInTheCaepNamespaceIsOtherAndAnythingElseIsNotCaep() { + assertThat(SsfCaepEventKind.of(UNKNOWN_CAEP_EVENT)).isEqualTo(SsfCaepEventKind.OTHER); + assertThat(SsfCaepEventKind.of(SsfEventTypes.RISC_ACCOUNT_DISABLED)).isNull(); + assertThat(SsfCaepEventKind.of("ScimProvDelete")).isNull(); + assertThat(SsfCaepEventKind.of("urn:example:acme:login")).isNull(); + assertThat(SsfCaepEventKind.of(null)).isNull(); + } + + @Test + void commonClaimsOfSection2AreTypedForEveryKind() { + Map payload = Map.of("event_timestamp", 1615304991, "initiating_entity", "policy", + "reason_admin", Map.of("en", "Landspeed Policy Violation: C076E82F"), "reason_user", Map.of("en", + "Access attempt from multiple regions.", "es-410", "Intento de acceso desde varias regiones.")); + SsfCaepEvent event = single(SsfEventTypes.CAEP_SESSION_REVOKED, payload); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.SESSION_REVOKED); + assertThat(event.eventTimestamp()).isEqualTo(Instant.ofEpochSecond(1615304991)); + assertThat(event.initiatingEntity()).isEqualTo("policy"); + assertThat(event.reasonAdmin()).containsExactly(Map.entry("en", "Landspeed Policy Violation: C076E82F")); + assertThat(event.reasonUser()).containsOnlyKeys("en", "es-410"); + // the same claims on an event this version does not know + SsfCaepEvent other = single(UNKNOWN_CAEP_EVENT, payload); + assertThat(other.kind()).isEqualTo(SsfCaepEventKind.OTHER); + assertThat(other.eventTimestamp()).isEqualTo(Instant.ofEpochSecond(1615304991)); + assertThat(other.initiatingEntity()).isEqualTo("policy"); + assertThat(other.reasonUser()).containsKey("es-410"); + assertThat(other.payload()).isEqualTo(payload); + } + + @Test + void absentCommonClaimsAreNullOrEmptyAndMillisecondsAreTolerated() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of()); + assertThat(event.eventTimestamp()).isNull(); + assertThat(event.initiatingEntity()).isNull(); + assertThat(event.reasonAdmin()).isEmpty(); + assertThat(event.reasonUser()).isEmpty(); + assertThat( + single(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of("event_timestamp", 1615304991000L)).eventTimestamp()) + .isEqualTo(Instant.ofEpochSecond(1615304991)); + assertThat(single(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of("event_timestamp", "yesterday")).eventTimestamp()) + .isNull(); + } + + @Test + void plainStringReasonIsKeptUnderTheEmptyLanguageTag() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of("reason_admin", "Policy violation")); + assertThat(event.reasonAdmin()).containsExactly(Map.entry("", "Policy violation")); + } + + @Test + void tokenClaimsChangeCarriesTheNewClaims() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_TOKEN_CLAIMS_CHANGE, + Map.of("event_timestamp", 1615304991, "claims", Map.of("role", "ro-admin"))); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.TOKEN_CLAIMS_CHANGE); + assertThat(event.claims()).containsExactly(Map.entry("role", "ro-admin")); + assertThat(single(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of()).claims()).isNull(); + } + + @Test + void credentialChangeNamesTheCredential() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_CREDENTIAL_CHANGE, + Map.of("credential_type", "fido2-roaming", "change_type", "create", "fido2_aaguid", + "accced6a-63f5-490a-9eea-e59bc1896cfc", "friendly_name", "Jane's USB authenticator", + "initiating_entity", "user", "reason_admin", Map.of("en", "User self-enrollment"))); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.CREDENTIAL_CHANGE); + assertThat(event.credentialType()).isEqualTo("fido2-roaming"); + assertThat(event.changeType()).isEqualTo("create"); + assertThat(event.fido2Aaguid()).isEqualTo("accced6a-63f5-490a-9eea-e59bc1896cfc"); + assertThat(event.friendlyName()).isEqualTo("Jane's USB authenticator"); + assertThat(event.x509Issuer()).isNull(); + assertThat(event.x509Serial()).isNull(); + SsfCaepEvent x509 = single(SsfEventTypes.CAEP_CREDENTIAL_CHANGE, Map.of("credential_type", "x509", + "change_type", "revoke", "x509_issuer", "CN=Example CA", "x509_serial", "0A1B2C")); + assertThat(x509.x509Issuer()).isEqualTo("CN=Example CA"); + assertThat(x509.x509Serial()).isEqualTo("0A1B2C"); + } + + @Test + void assuranceLevelChangeHasNamespaceLevelsAndDirection() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_ASSURANCE_LEVEL_CHANGE, Map.of("namespace", "NIST-AAL", + "current_level", "nist-aal2", "previous_level", "nist-aal1", "change_direction", "increase")); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.ASSURANCE_LEVEL_CHANGE); + assertThat(event.namespace()).isEqualTo("NIST-AAL"); + assertThat(event.currentLevel()).isEqualTo("nist-aal2"); + assertThat(event.previousLevel()).isEqualTo("nist-aal1"); + assertThat(event.changeDirection()).isEqualTo("increase"); + SsfCaepEvent unknownPrevious = single(SsfEventTypes.CAEP_ASSURANCE_LEVEL_CHANGE, + Map.of("namespace", "RFC8176", "current_level", "1")); + assertThat(unknownPrevious.previousLevel()).isNull(); + assertThat(unknownPrevious.changeDirection()).isNull(); + } + + @Test + void deviceComplianceChangeHasBothStatuses() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_DEVICE_COMPLIANCE_CHANGE, Map.of("previous_status", "compliant", + "current_status", "not-compliant", "initiating_entity", "policy")); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.DEVICE_COMPLIANCE_CHANGE); + assertThat(event.previousStatus()).isEqualTo("compliant"); + assertThat(event.currentStatus()).isEqualTo("not-compliant"); + } + + @Test + void sessionEventsDescribeTheSession() { + SsfCaepEvent established = single(SsfEventTypes.CAEP_SESSION_ESTABLISHED, + Map.of("fp_ua", "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", "acr", "AAL2", "amr", List.of("otp", 42), + "ext_id", "12345", "event_timestamp", 1615304991)); + assertThat(established.kind()).isEqualTo(SsfCaepEventKind.SESSION_ESTABLISHED); + assertThat(established.fpUa()).isEqualTo("abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611"); + assertThat(established.acr()).isEqualTo("AAL2"); + assertThat(established.amr()).containsExactly("otp"); + assertThat(established.extId()).isEqualTo("12345"); + SsfCaepEvent presented = single(SsfEventTypes.CAEP_SESSION_PRESENTED, + Map.of("fp_ua", "abb0b6e7da81a42233f8f2b1a8ddb1b9a4c81611", "ext_id", "12345")); + assertThat(presented.kind()).isEqualTo(SsfCaepEventKind.SESSION_PRESENTED); + assertThat(presented.acr()).isNull(); + assertThat(presented.amr()).isEmpty(); + } + + @Test + void riskLevelChangeNamesThePrincipalAndTheLevels() { + SsfCaepEvent event = single(SsfEventTypes.CAEP_RISK_LEVEL_CHANGE, Map.of("principal", "USER", "current_level", + "LOW", "previous_level", "HIGH", "risk_reason", "PASSWORD_FOUND_IN_DATA_BREACH")); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.RISK_LEVEL_CHANGE); + assertThat(event.principal()).isEqualTo("USER"); + assertThat(event.currentLevel()).isEqualTo("LOW"); + assertThat(event.previousLevel()).isEqualTo("HIGH"); + assertThat(event.riskReason()).isEqualTo("PASSWORD_FOUND_IN_DATA_BREACH"); + } + + @Test + void findsAllCaepEventsOfASetInOrderAndIgnoresOthers() { + Map events = new LinkedHashMap<>(); + events.put(SsfEventTypes.RISC_ACCOUNT_DISABLED, Map.of("reason", "hijacking")); + events.put(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of("event_timestamp", 1615304991)); + events.put(UNKNOWN_CAEP_EVENT, "not an object"); + events.put(SsfEventTypes.SCIM_PROV_DELETE, Map.of()); + List caepEvents = SsfCaepEvent.in(token(events)); + assertThat(caepEvents).extracting(SsfCaepEvent::kind) + .containsExactly(SsfCaepEventKind.SESSION_REVOKED, SsfCaepEventKind.OTHER); + assertThat(caepEvents.get(1).eventType()).isEqualTo(UNKNOWN_CAEP_EVENT); + assertThat(caepEvents.get(1).payload()).isEmpty(); + assertThat(SsfCaepEvent.in(token(Map.of(SsfEventTypes.RISC_ACCOUNT_PURGED, Map.of())))).isEmpty(); + } + + @Test + void ofResolvesAliasesAndRejectsOtherEventTypes() { + SsfCaepEvent event = SsfCaepEvent.of("CaepSessionRevoked", null); + assertThat(event.eventType()).isEqualTo(SsfEventTypes.CAEP_SESSION_REVOKED); + assertThat(event.kind()).isEqualTo(SsfCaepEventKind.SESSION_REVOKED); + assertThat(event.payload()).isEmpty(); + assertThat(SsfCaepEvent.of("RiscAccountDisabled", Map.of())).isNull(); + } + + private static SsfCaepEvent single(String eventType, Map payload) { + Map events = new LinkedHashMap<>(); + events.put(eventType, payload); + List caepEvents = SsfCaepEvent.in(token(events)); + assertThat(caepEvents).hasSize(1); + return caepEvents.get(0); + } + + private static SsfEventToken token(Map events) { + return new SsfEventToken("jti-1", "https://idp.example.com", Instant.parse("2026-10-06T10:00:00Z"), List.of(), + events, email("someuser@somedomain.com"), "8675309", Map.of()); + } + +} diff --git a/easyssf-core/src/test/java/org/easyssf/core/risc/SsfRiscEventTests.java b/easyssf-core/src/test/java/org/easyssf/core/risc/SsfRiscEventTests.java new file mode 100644 index 0000000..611bb53 --- /dev/null +++ b/easyssf-core/src/test/java/org/easyssf/core/risc/SsfRiscEventTests.java @@ -0,0 +1,124 @@ +package org.easyssf.core.risc; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.easyssf.core.event.SsfEventToken; +import org.easyssf.core.event.SsfEventTypes; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easyssf.core.event.SsfSubjectIdentifiers.email; + +class SsfRiscEventTests { + + private static final String UNKNOWN_RISC_EVENT = "https://schemas.openid.net/secevent/risc/event-type/account-merged"; + + @Test + void everyRiscEventTypeHasAKindAndAnAlias() { + for (SsfRiscEventKind kind : SsfRiscEventKind.values()) { + if (kind == SsfRiscEventKind.OTHER) { + assertThat(kind.eventType()).isNull(); + continue; + } + assertThat(kind.eventType()).startsWith("https://schemas.openid.net/secevent/risc/event-type/"); + assertThat(SsfRiscEventKind.of(kind.eventType())).isEqualTo(kind); + assertThat(SsfEventTypes.aliasOf(kind.eventType())).startsWith("Risc"); + assertThat(SsfRiscEventKind.of(SsfEventTypes.aliasOf(kind.eventType()))).isEqualTo(kind); + } + assertThat(SsfRiscEventKind.values()).hasSize(15); + assertThat(SsfRiscEventKind.of("RiscSessionsRevoked")).isEqualTo(SsfRiscEventKind.SESSIONS_REVOKED); + } + + @Test + void unknownEventTypeInTheRiscNamespaceIsOtherAndAnythingElseIsNotRisc() { + assertThat(SsfRiscEventKind.of(UNKNOWN_RISC_EVENT)).isEqualTo(SsfRiscEventKind.OTHER); + assertThat(SsfRiscEventKind.of(SsfEventTypes.CAEP_SESSION_REVOKED)).isNull(); + assertThat(SsfRiscEventKind.of("ScimProvDelete")).isNull(); + assertThat(SsfRiscEventKind.of(null)).isNull(); + } + + @Test + void optInAndOptOutEventsReportTheParticipation() { + assertThat(SsfRiscEventKind.values()).filteredOn(SsfRiscEventKind::isOptOutState) + .containsExactly(SsfRiscEventKind.OPT_IN, SsfRiscEventKind.OPT_OUT_INITIATED, + SsfRiscEventKind.OPT_OUT_CANCELLED, SsfRiscEventKind.OPT_OUT_EFFECTIVE); + } + + @Test + void accountDisabledMayCarryAReason() { + SsfRiscEvent event = single(SsfEventTypes.RISC_ACCOUNT_DISABLED, Map.of("reason", "hijacking")); + assertThat(event.kind()).isEqualTo(SsfRiscEventKind.ACCOUNT_DISABLED); + assertThat(event.reason()).isEqualTo("hijacking"); + assertThat(single(SsfEventTypes.RISC_ACCOUNT_DISABLED, Map.of()).reason()).isNull(); + } + + @Test + void identifierChangedMayCarryTheNewValue() { + SsfRiscEvent event = single(SsfEventTypes.RISC_IDENTIFIER_CHANGED, Map.of("new-value", "john.roe@example.com")); + assertThat(event.kind()).isEqualTo(SsfRiscEventKind.IDENTIFIER_CHANGED); + assertThat(event.newValue()).isEqualTo("john.roe@example.com"); + assertThat(single(SsfEventTypes.RISC_IDENTIFIER_RECYCLED, Map.of()).newValue()).isNull(); + } + + @Test + void credentialCompromiseNamesTheCredentialAndWhenItWasFound() { + SsfRiscEvent event = single(SsfEventTypes.RISC_CREDENTIAL_COMPROMISE, + Map.of("credential_type", "password", "event_timestamp", 1508184845, "reason_admin", + "Found in a breach dump", "reason_user", + Map.of("en", "Your password was found in a data breach"))); + assertThat(event.kind()).isEqualTo(SsfRiscEventKind.CREDENTIAL_COMPROMISE); + assertThat(event.credentialType()).isEqualTo("password"); + assertThat(event.eventTimestamp()).isEqualTo(Instant.ofEpochSecond(1508184845)); + assertThat(event.reasonAdmin()).isEqualTo("Found in a breach dump"); + assertThat(event.reasonUser()).contains("Your password was found in a data breach"); + SsfRiscEvent minimal = single(SsfEventTypes.RISC_CREDENTIAL_COMPROMISE, Map.of("credential_type", "password")); + assertThat(minimal.eventTimestamp()).isNull(); + assertThat(minimal.reasonAdmin()).isNull(); + assertThat(minimal.reasonUser()).isNull(); + } + + @Test + void eventsWithoutAttributesHaveAnEmptyPayload() { + assertThat(single(SsfEventTypes.RISC_ACCOUNT_PURGED, Map.of()).payload()).isEmpty(); + assertThat(single(SsfEventTypes.RISC_OPT_OUT_EFFECTIVE, null).payload()).isEmpty(); + } + + @Test + void findsAllRiscEventsOfASetInOrderAndIgnoresOthers() { + Map events = new LinkedHashMap<>(); + events.put(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of()); + events.put(SsfEventTypes.RISC_ACCOUNT_DISABLED, Map.of("reason", "bulk-account")); + events.put(UNKNOWN_RISC_EVENT, Map.of("into", "other-account")); + List riscEvents = SsfRiscEvent.in(token(events)); + assertThat(riscEvents).extracting(SsfRiscEvent::kind) + .containsExactly(SsfRiscEventKind.ACCOUNT_DISABLED, SsfRiscEventKind.OTHER); + assertThat(riscEvents.get(1).eventType()).isEqualTo(UNKNOWN_RISC_EVENT); + assertThat(riscEvents.get(1).payload()).containsEntry("into", "other-account"); + assertThat(SsfRiscEvent.in(token(Map.of(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of())))).isEmpty(); + } + + @Test + void ofResolvesAliasesAndRejectsOtherEventTypes() { + SsfRiscEvent event = SsfRiscEvent.of("RiscAccountPurged", null); + assertThat(event.eventType()).isEqualTo(SsfEventTypes.RISC_ACCOUNT_PURGED); + assertThat(event.kind()).isEqualTo(SsfRiscEventKind.ACCOUNT_PURGED); + assertThat(SsfRiscEvent.of("CaepSessionRevoked", Map.of())).isNull(); + } + + private static SsfRiscEvent single(String eventType, Map payload) { + Map events = new LinkedHashMap<>(); + events.put(eventType, payload); + List riscEvents = SsfRiscEvent.in(token(events)); + assertThat(riscEvents).hasSize(1); + return riscEvents.get(0); + } + + private static SsfEventToken token(Map events) { + return new SsfEventToken("jti-1", "https://idp.example.com", Instant.parse("2026-10-06T10:00:00Z"), List.of(), + events, email("john.doe@example.com"), null, Map.of()); + } + +} diff --git a/easyssf-receiver/AGENTS.md b/easyssf-receiver/AGENTS.md index e847c67..fdeffd3 100644 --- a/easyssf-receiver/AGENTS.md +++ b/easyssf-receiver/AGENTS.md @@ -6,7 +6,8 @@ classes; they must not need anything but SLF4J, Nimbus and the JDK HTTP client. - Packages by concern: `set` (verification, de-duplication, `SsfSetProcessor` runs the handlers), `push` and `poll` (delivery), `stream` and `transmitter` (stream management, metadata, tokens), `event` (`SsfEventHandler`, `SsfEventContext`), `revocation`, `session`, - `scim` (handlers for the common reactions), `metrics`, `http`. + `caep`, `risc`, `scim` (handlers for the common reactions and typed dispatch by event), `metrics`, + `http`. - `SsfSetProcessor` hands every verified SET to every handler, catches their exceptions, and reports a failure so the transmitter delivers again. Handlers must therefore be idempotent, and the processor must never mark a SET as seen before all handlers succeeded. diff --git a/easyssf-receiver/src/main/java/module-info.java b/easyssf-receiver/src/main/java/module-info.java index c89e6f8..b05f741 100644 --- a/easyssf-receiver/src/main/java/module-info.java +++ b/easyssf-receiver/src/main/java/module-info.java @@ -10,12 +10,14 @@ requires java.net.http; requires static micrometer.core; + exports org.easyssf.receiver.caep; exports org.easyssf.receiver.event; exports org.easyssf.receiver.http; exports org.easyssf.receiver.metrics; exports org.easyssf.receiver.poll; exports org.easyssf.receiver.push; exports org.easyssf.receiver.revocation; + exports org.easyssf.receiver.risc; exports org.easyssf.receiver.scim; exports org.easyssf.receiver.session; exports org.easyssf.receiver.set; diff --git a/easyssf-receiver/src/main/java/org/easyssf/receiver/caep/SsfCaepEventHandler.java b/easyssf-receiver/src/main/java/org/easyssf/receiver/caep/SsfCaepEventHandler.java new file mode 100644 index 0000000..edf0570 --- /dev/null +++ b/easyssf-receiver/src/main/java/org/easyssf/receiver/caep/SsfCaepEventHandler.java @@ -0,0 +1,131 @@ +package org.easyssf.receiver.caep; + +import org.easyssf.core.caep.SsfCaepEvent; +import org.easyssf.core.caep.SsfCaepEventKind; +import org.easyssf.receiver.event.SsfEventContext; +import org.easyssf.receiver.event.SsfEventHandler; + +/** + * An {@link SsfEventHandler} for CAEP 1.0 events that dispatches every CAEP event of a + * SET to the method of its kind: {@link #onSessionRevoked}, {@link #onTokenClaimsChange}, + * {@link #onCredentialChange}, {@link #onAssuranceLevelChange}, + * {@link #onDeviceComplianceChange}, {@link #onSessionEstablished}, + * {@link #onSessionPresented}, {@link #onRiskLevelChange}, and {@link #onOtherCaepEvent} + * for an event type in the CAEP namespace this version does not know. The methods do + * nothing by default, override the ones the receiver acts on. SETs without CAEP events + * are ignored. + * + *

+ * Each event comes with typed access to its payload and with the {@code SsfEventContext} + * of the SET, whose {@link SsfEventContext#subject() subject} the event is about. The + * built-in reactions to {@code session-revoked} and {@code credential-change}, ending + * sessions and rejecting tokens, are {@code SsfSessionTerminationEventHandler} and + * {@code SsfTokenRevocationEventHandler}; this handler is for what the application does + * on top. Like every handler, an implementation has to be idempotent: a SET is delivered + * again if any handler failed. + * + *

+ * class StepUpHandler extends SsfCaepEventHandler {
+ *
+ *     @Override
+ *     protected void onAssuranceLevelChange(SsfCaepEvent event, SsfEventContext eventContext) {
+ *         if ("decrease".equals(event.changeDirection())) {
+ *             stepUp.require(eventContext.subject().subject(), event.currentLevel());
+ *         }
+ *     }
+ *
+ * }
+ * 
+ */ +public abstract class SsfCaepEventHandler implements SsfEventHandler { + + @Override + public void handle(SsfEventContext eventContext) { + for (SsfCaepEvent event : SsfCaepEvent.in(eventContext.eventToken())) { + onCaepEvent(event, eventContext); + } + } + + /** + * Called for every CAEP event of a SET, dispatches to the method of its kind. + * Override to treat all CAEP events alike. + * @param event the event + * @param eventContext the SET the event arrived in + */ + protected void onCaepEvent(SsfCaepEvent event, SsfEventContext eventContext) { + switch (event.kind()) { + case SESSION_REVOKED -> onSessionRevoked(event, eventContext); + case TOKEN_CLAIMS_CHANGE -> onTokenClaimsChange(event, eventContext); + case CREDENTIAL_CHANGE -> onCredentialChange(event, eventContext); + case ASSURANCE_LEVEL_CHANGE -> onAssuranceLevelChange(event, eventContext); + case DEVICE_COMPLIANCE_CHANGE -> onDeviceComplianceChange(event, eventContext); + case SESSION_ESTABLISHED -> onSessionEstablished(event, eventContext); + case SESSION_PRESENTED -> onSessionPresented(event, eventContext); + case RISK_LEVEL_CHANGE -> onRiskLevelChange(event, eventContext); + case OTHER -> onOtherCaepEvent(event, eventContext); + } + } + + /** + * The session was revoked ({@code CaepSessionRevoked}). + */ + protected void onSessionRevoked(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * Claims of the token changed ({@code CaepTokenClaimsChange}), see + * {@link SsfCaepEvent#claims()}. + */ + protected void onTokenClaimsChange(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * A credential was created, revoked, updated or deleted + * ({@code CaepCredentialChange}), see {@link SsfCaepEvent#credentialType()} and + * {@link SsfCaepEvent#changeType()}. + */ + protected void onCredentialChange(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * The assurance level changed ({@code CaepAssuranceLevelChange}), see + * {@link SsfCaepEvent#currentLevel()} and {@link SsfCaepEvent#changeDirection()}. + */ + protected void onAssuranceLevelChange(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * The compliance status of the device changed ({@code CaepDeviceComplianceChange}), + * see {@link SsfCaepEvent#currentStatus()}. + */ + protected void onDeviceComplianceChange(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * A session was established ({@code CaepSessionEstablished}). + */ + protected void onSessionEstablished(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * A session was presented ({@code CaepSessionPresented}). + */ + protected void onSessionPresented(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * The risk level changed ({@code CaepRiskLevelChange}), see + * {@link SsfCaepEvent#currentLevel()} and {@link SsfCaepEvent#principal()}. + */ + protected void onRiskLevelChange(SsfCaepEvent event, SsfEventContext eventContext) { + } + + /** + * An event in the CAEP namespace this version of easyssf does not know + * ({@link SsfCaepEventKind#OTHER}): the common claims of CAEP section 2 are typed, + * the rest is in {@link SsfCaepEvent#payload()}. + */ + protected void onOtherCaepEvent(SsfCaepEvent event, SsfEventContext eventContext) { + } + +} diff --git a/easyssf-receiver/src/main/java/org/easyssf/receiver/event/SsfEventContext.java b/easyssf-receiver/src/main/java/org/easyssf/receiver/event/SsfEventContext.java index e202a2c..b2cb587 100644 --- a/easyssf-receiver/src/main/java/org/easyssf/receiver/event/SsfEventContext.java +++ b/easyssf-receiver/src/main/java/org/easyssf/receiver/event/SsfEventContext.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Set; +import org.easyssf.core.event.SsfEventTimestamps; import org.easyssf.core.event.SsfEventToken; import org.easyssf.core.event.SsfEventTypes; import org.easyssf.core.event.SsfSubject; @@ -98,12 +99,8 @@ public SsfSubject subjectFor(String aliasOrUri) { */ public Instant eventTimestamp(String aliasOrUri) { Map event = eventFor(aliasOrUri); - if (event != null && event.get("event_timestamp") instanceof Number timestamp) { - long value = timestamp.longValue(); - // CAEP defines seconds since epoch, some transmitters send milliseconds - return (value > 100_000_000_000L) ? Instant.ofEpochMilli(value) : Instant.ofEpochSecond(value); - } - return this.eventToken.iat(); + Instant timestamp = (event != null) ? SsfEventTimestamps.from(event.get("event_timestamp")) : null; + return (timestamp != null) ? timestamp : this.eventToken.iat(); } } diff --git a/easyssf-receiver/src/main/java/org/easyssf/receiver/risc/SsfRiscEventHandler.java b/easyssf-receiver/src/main/java/org/easyssf/receiver/risc/SsfRiscEventHandler.java new file mode 100644 index 0000000..b7bdf11 --- /dev/null +++ b/easyssf-receiver/src/main/java/org/easyssf/receiver/risc/SsfRiscEventHandler.java @@ -0,0 +1,174 @@ +package org.easyssf.receiver.risc; + +import org.easyssf.core.risc.SsfRiscEvent; +import org.easyssf.core.risc.SsfRiscEventKind; +import org.easyssf.receiver.event.SsfEventContext; +import org.easyssf.receiver.event.SsfEventHandler; + +/** + * An {@link SsfEventHandler} for RISC 1.0 events that dispatches every RISC event of a + * SET to the method of its kind: {@link #onAccountCredentialChangeRequired}, + * {@link #onAccountPurged}, {@link #onAccountDisabled}, {@link #onAccountEnabled}, + * {@link #onIdentifierChanged}, {@link #onIdentifierRecycled}, + * {@link #onCredentialCompromise}, {@link #onOptIn}, {@link #onOptOutInitiated}, + * {@link #onOptOutCancelled}, {@link #onOptOutEffective}, {@link #onRecoveryActivated}, + * {@link #onRecoveryInformationChanged}, {@link #onSessionsRevoked}, and + * {@link #onOtherRiscEvent} for an event type in the RISC namespace this version does not + * know. The methods do nothing by default, override the ones the receiver acts on. SETs + * without RISC events are ignored. + * + *

+ * Each event comes with the {@code SsfEventContext} of the SET, whose + * {@link SsfEventContext#subject() subject} is the account the event is about. Like every + * handler, an implementation has to be idempotent: a SET is delivered again if any + * handler failed. + * + *

+ * class AccountHandler extends SsfRiscEventHandler {
+ *
+ *     @Override
+ *     protected void onAccountDisabled(SsfRiscEvent event, SsfEventContext eventContext) {
+ *         accounts.suspend(eventContext.subject().email(), event.reason());
+ *     }
+ *
+ *     @Override
+ *     protected void onAccountPurged(SsfRiscEvent event, SsfEventContext eventContext) {
+ *         accounts.delete(eventContext.subject().email());
+ *     }
+ *
+ * }
+ * 
+ */ +public abstract class SsfRiscEventHandler implements SsfEventHandler { + + @Override + public void handle(SsfEventContext eventContext) { + for (SsfRiscEvent event : SsfRiscEvent.in(eventContext.eventToken())) { + onRiscEvent(event, eventContext); + } + } + + /** + * Called for every RISC event of a SET, dispatches to the method of its kind. + * Override to treat all RISC events alike. + * @param event the event + * @param eventContext the SET the event arrived in + */ + protected void onRiscEvent(SsfRiscEvent event, SsfEventContext eventContext) { + switch (event.kind()) { + case ACCOUNT_CREDENTIAL_CHANGE_REQUIRED -> onAccountCredentialChangeRequired(event, eventContext); + case ACCOUNT_PURGED -> onAccountPurged(event, eventContext); + case ACCOUNT_DISABLED -> onAccountDisabled(event, eventContext); + case ACCOUNT_ENABLED -> onAccountEnabled(event, eventContext); + case IDENTIFIER_CHANGED -> onIdentifierChanged(event, eventContext); + case IDENTIFIER_RECYCLED -> onIdentifierRecycled(event, eventContext); + case CREDENTIAL_COMPROMISE -> onCredentialCompromise(event, eventContext); + case OPT_IN -> onOptIn(event, eventContext); + case OPT_OUT_INITIATED -> onOptOutInitiated(event, eventContext); + case OPT_OUT_CANCELLED -> onOptOutCancelled(event, eventContext); + case OPT_OUT_EFFECTIVE -> onOptOutEffective(event, eventContext); + case RECOVERY_ACTIVATED -> onRecoveryActivated(event, eventContext); + case RECOVERY_INFORMATION_CHANGED -> onRecoveryInformationChanged(event, eventContext); + case SESSIONS_REVOKED -> onSessionsRevoked(event, eventContext); + case OTHER -> onOtherRiscEvent(event, eventContext); + } + } + + /** + * The account has to change its credential + * ({@code RiscAccountCredentialChangeRequired}). + */ + protected void onAccountCredentialChangeRequired(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The account was permanently deleted ({@code RiscAccountPurged}). + */ + protected void onAccountPurged(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The account was disabled ({@code RiscAccountDisabled}), see + * {@link SsfRiscEvent#reason()}. + */ + protected void onAccountDisabled(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The account was enabled again ({@code RiscAccountEnabled}). + */ + protected void onAccountEnabled(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The identifier of the account changed ({@code RiscIdentifierChanged}), see + * {@link SsfRiscEvent#newValue()}. + */ + protected void onIdentifierChanged(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The identifier was given to someone else ({@code RiscIdentifierRecycled}). + */ + protected void onIdentifierRecycled(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * A credential was found compromised ({@code RiscCredentialCompromise}), see + * {@link SsfRiscEvent#credentialType()}. + */ + protected void onCredentialCompromise(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The account takes part in the RISC exchange ({@code RiscOptIn}). + */ + protected void onOptIn(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The user asked to leave the RISC exchange ({@code RiscOptOutInitiated}). + */ + protected void onOptOutInitiated(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The user withdrew the request to leave ({@code RiscOptOutCancelled}). + */ + protected void onOptOutCancelled(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The account left the RISC exchange ({@code RiscOptOutEffective}). + */ + protected void onOptOutEffective(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The account went through a recovery flow ({@code RiscRecoveryActivated}). + */ + protected void onRecoveryActivated(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * The recovery information changed ({@code RiscRecoveryInformationChanged}). + */ + protected void onRecoveryInformationChanged(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * All sessions of the account were revoked ({@code RiscSessionsRevoked}, deprecated + * by RISC in favour of {@code CaepSessionRevoked}). + */ + protected void onSessionsRevoked(SsfRiscEvent event, SsfEventContext eventContext) { + } + + /** + * An event in the RISC namespace this version of easyssf does not know + * ({@link SsfRiscEventKind#OTHER}); its attributes are in + * {@link SsfRiscEvent#payload()}. + */ + protected void onOtherRiscEvent(SsfRiscEvent event, SsfEventContext eventContext) { + } + +} diff --git a/easyssf-receiver/src/test/java/org/easyssf/receiver/caep/SsfCaepEventHandlerTests.java b/easyssf-receiver/src/test/java/org/easyssf/receiver/caep/SsfCaepEventHandlerTests.java new file mode 100644 index 0000000..ccf3805 --- /dev/null +++ b/easyssf-receiver/src/test/java/org/easyssf/receiver/caep/SsfCaepEventHandlerTests.java @@ -0,0 +1,166 @@ +package org.easyssf.receiver.caep; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.easyssf.core.caep.SsfCaepEvent; +import org.easyssf.core.caep.SsfCaepEventKind; +import org.easyssf.core.event.SsfEventToken; +import org.easyssf.core.event.SsfEventTypes; +import org.easyssf.receiver.event.SsfEventContext; +import org.easyssf.receiver.http.JdkSsfHttpClient; +import org.easyssf.receiver.set.InMemorySsfJtiDedupStore; +import org.easyssf.receiver.set.NimbusSsfSetVerifier; +import org.easyssf.receiver.set.SsfSetProcessor; +import org.easyssf.test.TestTransmitter; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import com.nimbusds.jwt.JWTClaimsSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easyssf.core.event.SsfSubjectIdentifiers.email; +import static org.easyssf.core.event.SsfSubjectIdentifiers.issSub; + +class SsfCaepEventHandlerTests { + + private static final String UNKNOWN_CAEP_EVENT = "https://schemas.openid.net/secevent/caep/event-type/geo-fence-exit"; + + private static final TestTransmitter transmitter = new TestTransmitter(); + + private final RecordingHandler handler = new RecordingHandler(); + + @AfterAll + static void stopTransmitter() { + transmitter.close(); + } + + @Test + void dispatchesEveryKindToItsMethod() { + for (SsfCaepEventKind kind : SsfCaepEventKind.values()) { + String eventType = (kind != SsfCaepEventKind.OTHER) ? kind.eventType() : UNKNOWN_CAEP_EVENT; + this.handler.handle(context(Map.of(eventType, Map.of()))); + } + assertThat(this.handler.calls).containsExactly("onSessionRevoked", "onTokenClaimsChange", "onCredentialChange", + "onAssuranceLevelChange", "onDeviceComplianceChange", "onSessionEstablished", "onSessionPresented", + "onRiskLevelChange", "onOtherCaepEvent"); + } + + @Test + void handlesAllCaepEventsOfASetAndIgnoresOtherSets() { + Map events = new LinkedHashMap<>(); + events.put(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of("initiating_entity", "admin")); + events.put(SsfEventTypes.RISC_ACCOUNT_DISABLED, Map.of("reason", "hijacking")); + events.put(UNKNOWN_CAEP_EVENT, Map.of("event_timestamp", 1615304991, "zone", "HQ")); + this.handler.handle(context(events)); + this.handler.handle(context(Map.of(SsfEventTypes.RISC_ACCOUNT_PURGED, Map.of()))); + this.handler.handle(context(Map.of("urn:example:acme:login", Map.of()))); + assertThat(this.handler.calls).containsExactly("onSessionRevoked", "onOtherCaepEvent"); + assertThat(this.handler.events.get(0).initiatingEntity()).isEqualTo("admin"); + SsfCaepEvent other = this.handler.events.get(1); + assertThat(other.eventType()).isEqualTo(UNKNOWN_CAEP_EVENT); + assertThat(other.eventTimestamp()).isEqualTo(Instant.ofEpochSecond(1615304991)); + assertThat(other.payload()).containsEntry("zone", "HQ"); + assertThat(this.handler.contexts.get(0).subject().email()).isEqualTo("someuser@somedomain.com"); + } + + @Test + void caepSetPassesStrictVerificationAndReachesTheHandler() { + transmitter.publishJwks(transmitter.key()); + NimbusSsfSetVerifier verifier = new NimbusSsfSetVerifier(transmitter.issuer(), transmitter::jwksUri, + new JdkSsfHttpClient()); + verifier.setExpectedAudience(TestTransmitter.AUDIENCE); + SsfSetProcessor processor = new SsfSetProcessor(verifier, new InMemorySsfJtiDedupStore(10), + List.of(this.handler)); + JWTClaimsSet claims = transmitter + .setClaims("CaepAssuranceLevelChange", issSub("https://idp.example.com/3456789/", "jane.smith@example.com"), + Map.of("namespace", "NIST-AAL", "current_level", "nist-aal1", "previous_level", "nist-aal2", + "change_direction", "decrease", "initiating_entity", "user", "event_timestamp", 1615304991)) + .claim("txn", "8675309") + .build(); + + assertThat(processor.process(transmitter.signSet(claims))).isEqualTo(SsfSetProcessor.Outcome.HANDLED); + + assertThat(this.handler.calls).containsExactly("onAssuranceLevelChange"); + SsfCaepEvent event = this.handler.events.get(0); + assertThat(event.namespace()).isEqualTo("NIST-AAL"); + assertThat(event.currentLevel()).isEqualTo("nist-aal1"); + assertThat(event.previousLevel()).isEqualTo("nist-aal2"); + assertThat(event.changeDirection()).isEqualTo("decrease"); + assertThat(event.eventTimestamp()).isEqualTo(Instant.ofEpochSecond(1615304991)); + SsfEventContext eventContext = this.handler.contexts.get(0); + assertThat(eventContext.subject().subject()).isEqualTo("jane.smith@example.com"); + assertThat(eventContext.eventToken().txn()).isEqualTo("8675309"); + } + + private static SsfEventContext context(Map events) { + return new SsfEventContext( + new SsfEventToken("jti-1", "https://idp.example.com", Instant.parse("2026-10-06T10:00:00Z"), List.of(), + events, email("someuser@somedomain.com"), null, Map.of())); + } + + private static final class RecordingHandler extends SsfCaepEventHandler { + + private final List calls = new ArrayList<>(); + + private final List events = new ArrayList<>(); + + private final List contexts = new ArrayList<>(); + + @Override + protected void onSessionRevoked(SsfCaepEvent event, SsfEventContext eventContext) { + record("onSessionRevoked", event, eventContext); + } + + @Override + protected void onTokenClaimsChange(SsfCaepEvent event, SsfEventContext eventContext) { + record("onTokenClaimsChange", event, eventContext); + } + + @Override + protected void onCredentialChange(SsfCaepEvent event, SsfEventContext eventContext) { + record("onCredentialChange", event, eventContext); + } + + @Override + protected void onAssuranceLevelChange(SsfCaepEvent event, SsfEventContext eventContext) { + record("onAssuranceLevelChange", event, eventContext); + } + + @Override + protected void onDeviceComplianceChange(SsfCaepEvent event, SsfEventContext eventContext) { + record("onDeviceComplianceChange", event, eventContext); + } + + @Override + protected void onSessionEstablished(SsfCaepEvent event, SsfEventContext eventContext) { + record("onSessionEstablished", event, eventContext); + } + + @Override + protected void onSessionPresented(SsfCaepEvent event, SsfEventContext eventContext) { + record("onSessionPresented", event, eventContext); + } + + @Override + protected void onRiskLevelChange(SsfCaepEvent event, SsfEventContext eventContext) { + record("onRiskLevelChange", event, eventContext); + } + + @Override + protected void onOtherCaepEvent(SsfCaepEvent event, SsfEventContext eventContext) { + record("onOtherCaepEvent", event, eventContext); + } + + private void record(String method, SsfCaepEvent event, SsfEventContext eventContext) { + this.calls.add(method); + this.events.add(event); + this.contexts.add(eventContext); + } + + } + +} diff --git a/easyssf-receiver/src/test/java/org/easyssf/receiver/risc/SsfRiscEventHandlerTests.java b/easyssf-receiver/src/test/java/org/easyssf/receiver/risc/SsfRiscEventHandlerTests.java new file mode 100644 index 0000000..42a3bad --- /dev/null +++ b/easyssf-receiver/src/test/java/org/easyssf/receiver/risc/SsfRiscEventHandlerTests.java @@ -0,0 +1,188 @@ +package org.easyssf.receiver.risc; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.easyssf.core.event.SsfEventToken; +import org.easyssf.core.event.SsfEventTypes; +import org.easyssf.core.risc.SsfRiscEvent; +import org.easyssf.core.risc.SsfRiscEventKind; +import org.easyssf.receiver.event.SsfEventContext; +import org.easyssf.receiver.http.JdkSsfHttpClient; +import org.easyssf.receiver.set.InMemorySsfJtiDedupStore; +import org.easyssf.receiver.set.NimbusSsfSetVerifier; +import org.easyssf.receiver.set.SsfSetProcessor; +import org.easyssf.test.TestTransmitter; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import com.nimbusds.jwt.JWTClaimsSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.easyssf.core.event.SsfSubjectIdentifiers.email; +import static org.easyssf.core.event.SsfSubjectIdentifiers.issSub; + +class SsfRiscEventHandlerTests { + + private static final String UNKNOWN_RISC_EVENT = "https://schemas.openid.net/secevent/risc/event-type/account-merged"; + + private static final TestTransmitter transmitter = new TestTransmitter(); + + private final RecordingHandler handler = new RecordingHandler(); + + @AfterAll + static void stopTransmitter() { + transmitter.close(); + } + + @Test + void dispatchesEveryKindToItsMethod() { + for (SsfRiscEventKind kind : SsfRiscEventKind.values()) { + String eventType = (kind != SsfRiscEventKind.OTHER) ? kind.eventType() : UNKNOWN_RISC_EVENT; + this.handler.handle(context(Map.of(eventType, Map.of()))); + } + assertThat(this.handler.calls).containsExactly("onAccountCredentialChangeRequired", "onAccountPurged", + "onAccountDisabled", "onAccountEnabled", "onIdentifierChanged", "onIdentifierRecycled", + "onCredentialCompromise", "onOptIn", "onOptOutInitiated", "onOptOutCancelled", "onOptOutEffective", + "onRecoveryActivated", "onRecoveryInformationChanged", "onSessionsRevoked", "onOtherRiscEvent"); + } + + @Test + void handlesAllRiscEventsOfASetAndIgnoresOtherSets() { + Map events = new LinkedHashMap<>(); + events.put(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of()); + events.put(SsfEventTypes.RISC_ACCOUNT_DISABLED, Map.of("reason", "hijacking")); + events.put(UNKNOWN_RISC_EVENT, Map.of("into", "other-account")); + this.handler.handle(context(events)); + this.handler.handle(context(Map.of(SsfEventTypes.CAEP_SESSION_REVOKED, Map.of()))); + this.handler.handle(context(Map.of("urn:example:acme:login", Map.of()))); + assertThat(this.handler.calls).containsExactly("onAccountDisabled", "onOtherRiscEvent"); + assertThat(this.handler.events.get(0).reason()).isEqualTo("hijacking"); + assertThat(this.handler.events.get(1).eventType()).isEqualTo(UNKNOWN_RISC_EVENT); + assertThat(this.handler.events.get(1).payload()).containsEntry("into", "other-account"); + assertThat(this.handler.contexts.get(0).subject().email()).isEqualTo("john.doe@example.com"); + } + + @Test + void riscSetPassesStrictVerificationAndReachesTheHandler() { + transmitter.publishJwks(transmitter.key()); + NimbusSsfSetVerifier verifier = new NimbusSsfSetVerifier(transmitter.issuer(), transmitter::jwksUri, + new JdkSsfHttpClient()); + verifier.setExpectedAudience(TestTransmitter.AUDIENCE); + SsfSetProcessor processor = new SsfSetProcessor(verifier, new InMemorySsfJtiDedupStore(10), + List.of(this.handler)); + JWTClaimsSet claims = transmitter + .setClaims("RiscCredentialCompromise", issSub("https://idp.example.com/3456790/", "joe.smith@example.com"), + Map.of("credential_type", "password", "event_timestamp", 1508184845)) + .build(); + + assertThat(processor.process(transmitter.signSet(claims))).isEqualTo(SsfSetProcessor.Outcome.HANDLED); + + assertThat(this.handler.calls).containsExactly("onCredentialCompromise"); + SsfRiscEvent event = this.handler.events.get(0); + assertThat(event.credentialType()).isEqualTo("password"); + assertThat(event.eventTimestamp()).isEqualTo(Instant.ofEpochSecond(1508184845)); + assertThat(this.handler.contexts.get(0).subject().subject()).isEqualTo("joe.smith@example.com"); + } + + private static SsfEventContext context(Map events) { + return new SsfEventContext( + new SsfEventToken("jti-1", "https://idp.example.com", Instant.parse("2026-10-06T10:00:00Z"), List.of(), + events, email("john.doe@example.com"), null, Map.of())); + } + + private static final class RecordingHandler extends SsfRiscEventHandler { + + private final List calls = new ArrayList<>(); + + private final List events = new ArrayList<>(); + + private final List contexts = new ArrayList<>(); + + @Override + protected void onAccountCredentialChangeRequired(SsfRiscEvent event, SsfEventContext eventContext) { + record("onAccountCredentialChangeRequired", event, eventContext); + } + + @Override + protected void onAccountPurged(SsfRiscEvent event, SsfEventContext eventContext) { + record("onAccountPurged", event, eventContext); + } + + @Override + protected void onAccountDisabled(SsfRiscEvent event, SsfEventContext eventContext) { + record("onAccountDisabled", event, eventContext); + } + + @Override + protected void onAccountEnabled(SsfRiscEvent event, SsfEventContext eventContext) { + record("onAccountEnabled", event, eventContext); + } + + @Override + protected void onIdentifierChanged(SsfRiscEvent event, SsfEventContext eventContext) { + record("onIdentifierChanged", event, eventContext); + } + + @Override + protected void onIdentifierRecycled(SsfRiscEvent event, SsfEventContext eventContext) { + record("onIdentifierRecycled", event, eventContext); + } + + @Override + protected void onCredentialCompromise(SsfRiscEvent event, SsfEventContext eventContext) { + record("onCredentialCompromise", event, eventContext); + } + + @Override + protected void onOptIn(SsfRiscEvent event, SsfEventContext eventContext) { + record("onOptIn", event, eventContext); + } + + @Override + protected void onOptOutInitiated(SsfRiscEvent event, SsfEventContext eventContext) { + record("onOptOutInitiated", event, eventContext); + } + + @Override + protected void onOptOutCancelled(SsfRiscEvent event, SsfEventContext eventContext) { + record("onOptOutCancelled", event, eventContext); + } + + @Override + protected void onOptOutEffective(SsfRiscEvent event, SsfEventContext eventContext) { + record("onOptOutEffective", event, eventContext); + } + + @Override + protected void onRecoveryActivated(SsfRiscEvent event, SsfEventContext eventContext) { + record("onRecoveryActivated", event, eventContext); + } + + @Override + protected void onRecoveryInformationChanged(SsfRiscEvent event, SsfEventContext eventContext) { + record("onRecoveryInformationChanged", event, eventContext); + } + + @Override + protected void onSessionsRevoked(SsfRiscEvent event, SsfEventContext eventContext) { + record("onSessionsRevoked", event, eventContext); + } + + @Override + protected void onOtherRiscEvent(SsfRiscEvent event, SsfEventContext eventContext) { + record("onOtherRiscEvent", event, eventContext); + } + + private void record(String method, SsfRiscEvent event, SsfEventContext eventContext) { + this.calls.add(method); + this.events.add(event); + this.contexts.add(eventContext); + } + + } + +}