Handling CAEP and RISC events

SignalsReceiver hands every verified Shared Signals event to the handlers you register, one event type at a time. This page lists every event type of OpenID CAEP 1.0 and OpenID RISC 1.0: what each means, what it carries, what the receiver does with it by default, and how to handle it yourself. The reference covers starting the receiver.

Registering a handler

import { EVENT, SignalsReceiver, type SignalEvent } from 'wsfed-caep-nodejs';

const signals = new SignalsReceiver({ transmitter, tls, onEndSessions });

signals.on(EVENT.credentialChange, async (event: SignalEvent) => {
  await audit.write('credential-change', event.subject.person, event.payload);
});

// Every event, of any type, after the type's own handlers:
signals.on('*', (event) => metrics.count(event.type));

await signals.start();
  • on(type, handler) adds a handler for one event type URI. Use the EVENT constants rather than typing URIs. Several handlers may be registered for one type; they run in the order they were added, then the '*' handlers.
  • Handlers run after the SET is verified: signature, iss, aud, typ, jti. A handler never sees a forged or misaddressed event.
  • A handler may be async. The receiver awaits it before handling the next event, and acknowledges the SET only after every handler has run.
  • A handler that throws is logged, and the SET is still acknowledged. The SET was valid; the failure is the application’s. So make a handler that must not lose an event durable itself (write it to a queue or table first).
  • Ending sessions is not a handler’s job. For the types in endOn, the receiver calls onEndSessions(localIds, event) first, then the handlers, with event.ended true. A handler reads event.sessions to learn which local sessions the event named.

What a handler receives

interface SignalEvent {
  type: string;                       // the event type URI, e.g. EVENT.sessionRevoked
  payload: Record<string, unknown>;   // the event's own members (below)
  subject: {
    person?: string;                  // an iss_sub subject's sub, e.g. 'urn:uuid:…'
    email?: string;                   // an email subject
    idpSession?: string;              // a complex subject's session: the assertion's SessionIndex
  };
  sessions: string[];                 // the local sessions it names (from track())
  ended: boolean;                     // whether the receiver ended them
  jti: string;                        // the SET's id
  iat?: number;                       // when the SET was issued
  txn?: string;                       // the transaction id, when the transmitter sent one
}

event.sessions holds the local sessions the event names:

  • A session event with an IdP session (CAEP’s complex subject, user plus session) names only the local sessions started from that IdP session.
  • Any other event names every local session of the person: matched by subject.person against the subject attribute track() recorded, then by subject.email.

Choosing which events to receive, and which end sessions

import { CAEP_EVENTS, EVENT, RISC_EVENTS, SignalsReceiver } from 'wsfed-caep-nodejs';

new SignalsReceiver({
  transmitter, tls, onEndSessions,
  // Ask for every CAEP and RISC type (the default asks for the ten in DEFAULT_EVENTS_REQUESTED).
  eventsRequested: [...CAEP_EVENTS, ...RISC_EVENTS],
  // End sessions on these (the default is DEFAULT_ENDING_EVENTS, five types).
  endOn: [EVENT.sessionRevoked, EVENT.accountDisabled, EVENT.accountPurged,
          EVENT.credentialCompromise, EVENT.sessionsRevoked, EVENT.riskLevelChange],
});
  • eventsRequested is what the stream asks for when it is created. The transmitter delivers what it supports of that (receiver.stream.events_delivered). An existing stream keeps what it asked for originally. To change it, delete the stream at the transmitter and let start() create a new one.
  • endOn only decides which events end sessions. Every delivered event reaches its handlers either way.
  • To end sessions only on some events of a type, leave that type out of endOn and decide in a handler. For example, end the person’s sessions on a risk-level-change only when its level is HIGH; see that event’s section.

Members every CAEP event may carry

CAEP 1.0 section 2 gives every CAEP event four optional members:

Member  
event_timestamp When the event happened at the transmitter, in seconds (NumericDate).
initiating_entity Who caused it: admin, user, policy or system.
reason_admin Why, for an administrator: a language map such as { "en": "…" }.
reason_user Why, for the person: a language map. Fit to show to them.

In the event tables below, Default is what the receiver does with that type when you change nothing: whether the type is in DEFAULT_EVENTS_REQUESTED, and whether it is in DEFAULT_ENDING_EVENTS.

CAEP events (about sessions)

session-revoked: the person’s session at the identity provider ended

   
Constant EVENT.sessionRevoked
Subject complex: user (iss_sub) and session (opaque, the assertion’s SessionIndex)
Members the four common ones
Default requested; ends the local sessions started from that IdP session

iya-sts sends it when the person signs out, a sign-on session expires, an administrator ends one (Monitoring → Sessions, a global logout), or a disable ends everything.

signals.on(EVENT.sessionRevoked, (event) => {
  // The sessions are already ended (onEndSessions ran). Record why, for the person's next sign-in page.
  const why = (event.payload.reason_user as Record<string, string> | undefined)?.en;
  for (const id of event.sessions) signedOutReasons.set(id, why ?? 'Signed out at the identity provider.');
});

credential-change: a credential was created, changed, revoked or deleted

   
Constant EVENT.credentialChange
Subject complex: user
Members credential_type (required), e.g. password, pin, x509, fido2-platform, fido2-roaming, fido-u2f, verifiable-credential, phone-voice, phone-sms, app, or a URI of the transmitter’s own (iya-sts: urn:iya:sts:credential-type:kerberos-key); change_type (required): create, revoke, update or delete; friendly_name; x509_issuer, x509_serial; fido2_aaguid; the four common ones
Default requested; ends nothing
signals.on(EVENT.credentialChange, async (event) => {
  const { credential_type, change_type } = event.payload as { credential_type: string; change_type: string };
  // A password changed elsewhere: ask the person to sign in again here too, but not mid-request.
  if (credential_type === 'password' && change_type !== 'create') {
    for (const id of event.sessions) await sessions.markForReauthentication(id);
  }
});

token-claims-change: claims in the person’s tokens or assertions changed

   
Constant EVENT.tokenClaimsChange
Subject complex: user
Members claims (required): an object of the changed claims and their new values; the four common ones
Default requested; ends nothing

A WS-Federation session holds the claims from sign-in. This tells you they are out of date, for example a group membership or a role.

signals.on(EVENT.tokenClaimsChange, async (event) => {
  const claims = event.payload.claims as Record<string, unknown>;
  for (const id of event.sessions) await sessions.update(id, { staleAttributes: Object.keys(claims) });
});

assurance-level-change: the authentication’s assurance level went up or down

   
Constant EVENT.assuranceLevelChange
Subject complex: user and session
Members namespace (required; iya-sts: urn:sts:acr); current_level (required); previous_level; change_direction: increase or decrease; the four common ones
Default requested; ends nothing
signals.on(EVENT.assuranceLevelChange, async (event) => {
  // Remember the level, so a page that needs multi-factor can ask for a step-up when it fell.
  for (const id of event.sessions) await sessions.update(id, { acr: event.payload.current_level });
});

device-compliance-change: the device’s compliance status changed

   
Constant EVENT.deviceComplianceChange
Subject complex: user and device (iya-sts: iss_sub naming the registered device)
Members current_status (required) and previous_status (required): compliant or not-compliant; the four common ones
Default not requested (add it to eventsRequested)
signals.on(EVENT.deviceComplianceChange, async (event) => {
  // Not ended by default: a non-compliant device may still read, but not write.
  if (event.payload.current_status === 'not-compliant') {
    for (const id of event.sessions) await sessions.update(id, { readOnly: true });
  }
});

session-established: the person signed in at the identity provider

   
Constant EVENT.sessionEstablished
Subject complex: user and session
Members fp_ua (a fingerprint of the user agent); acr; amr; ext_id; the four common ones
Default not requested

It names a new IdP session, so no local session is started from it yet and event.sessions is the person’s other local sessions. It suits auditing and anomaly detection.

signals.on(EVENT.sessionEstablished, (event) => {
  audit.write('idp-sign-in', event.subject.person, { acr: event.payload.acr, amr: event.payload.amr });
});

session-presented: an existing IdP session was used again (single sign-on)

   
Constant EVENT.sessionPresented
Subject complex: user and session
Members fp_ua; ext_id; the four common ones
Default not requested
signals.on(EVENT.sessionPresented, (event) => {
  // Keep a local session alive while its IdP session is in use elsewhere.
  for (const id of event.sessions) sessions.touch(id);
});

risk-level-change: the transmitter’s risk assessment changed

   
Constant EVENT.riskLevelChange
Subject complex: user (or the device, session… the principal names)
Members risk_reason (required); principal (required): USER, DEVICE, SESSION, TENANT, ORG_UNIT or GROUP; current_level (required): LOW, MEDIUM or HIGH; previous_level; the four common ones
Default not requested
signals.on(EVENT.riskLevelChange, async (event) => {
  const { current_level, principal } = event.payload as { current_level: string; principal: string };
  // End the person's sessions only when their risk is HIGH, rather than on every change.
  if (principal === 'USER' && current_level === 'HIGH' && event.sessions.length) {
    await endMySessions(event.sessions, 'Your account was flagged as high risk.');
  }
});

RISC events (about accounts)

RISC subjects are plain, not complex. iya-sts names the account as iss_sub with sub set to urn:uuid:… (risc.subjectFormat). The identifier events use email or phone_number. So a RISC event names every local session of the person.

account-disabled: the account was disabled

   
Constant EVENT.accountDisabled
Members reason (optional): hijacking or bulk-account
Default requested; ends every local session of the person
signals.on(EVENT.accountDisabled, async (event) => {
  // Sessions are already ended. Also refuse new local sign-ins until account-enabled arrives.
  await accounts.block(event.subject.person!, event.payload.reason ?? 'disabled at the identity provider');
});

account-enabled: the account was enabled again

   
Constant EVENT.accountEnabled
Members none
Default requested; ends nothing
signals.on(EVENT.accountEnabled, (event) => accounts.unblock(event.subject.person!));

account-purged: the account was deleted

   
Constant EVENT.accountPurged
Members none
Default requested; ends every local session of the person
signals.on(EVENT.accountPurged, async (event) => {
  // Remove or anonymise what the application keeps about the person.
  await profiles.deleteBySubject(event.subject.person!);
});

credential-compromise: a credential is known or suspected to be compromised

   
Constant EVENT.credentialCompromise
Members credential_type (required, CAEP’s list); event_timestamp; reason_admin; reason_user
Default requested; ends every local session of the person
signals.on(EVENT.credentialCompromise, async (event) => {
  await security.alert(`compromised ${event.payload.credential_type} for ${event.subject.person}`);
});

account-credential-change-required: the person must change a credential

   
Constant EVENT.accountCredentialChangeRequired
Members none
Default requested; ends nothing
signals.on(EVENT.accountCredentialChangeRequired, async (event) => {
  // Send the person to the identity provider's password change on their next page.
  for (const id of event.sessions) await sessions.update(id, { mustChangeCredential: true });
});

identifier-changed: an identifier (email address, phone number) changed

   
Constant EVENT.identifierChanged
Subject the OLD identifier: email or phone_number
Members new-value (optional; note the hyphen, the only one in SSF), absent when the identifier was removed
Default not requested
signals.on(EVENT.identifierChanged, async (event) => {
  // The subject is the old address; the receiver matched sessions by email through it.
  if (event.subject.email) await profiles.renameEmail(event.subject.email, event.payload['new-value'] as string | undefined);
});

identifier-recycled: an identifier now belongs to a different account

   
Constant EVENT.identifierRecycled
Subject the identifier: email or phone_number
Members none
Default not requested
signals.on(EVENT.identifierRecycled, async (event) => {
  // Anything keyed by this address belonged to someone else: unlink it.
  if (event.subject.email) await profiles.unlinkEmail(event.subject.email);
});

recovery-activated: account recovery was started

   
Constant EVENT.recoveryActivated
Members none
Default not requested
signals.on(EVENT.recoveryActivated, (event) => security.notice(`recovery started for ${event.subject.person}`));

recovery-information-changed: recovery information (address, codes) changed

   
Constant EVENT.recoveryInformationChanged
Members none
Default not requested
signals.on(EVENT.recoveryInformationChanged, (event) => audit.write('recovery-info-changed', event.subject.person));

opt-in, opt-out-initiated, opt-out-cancelled, opt-out-effective: the person’s participation in event exchange

   
Constants EVENT.optIn, EVENT.optOutInitiated, EVENT.optOutCancelled, EVENT.optOutEffective
Members none
Default not requested

These are RISC 1.0 section 2.8’s states. After opt-out-effective the transmitter sends no more events about that account until opt-in. An application that relies on signals should know when they stop.

signals.on(EVENT.optOutEffective, (event) => accounts.setSignals(event.subject.person!, false));
signals.on(EVENT.optIn, (event) => accounts.setSignals(event.subject.person!, true));

sessions-revoked (deprecated): every session of the account was revoked

   
Constant EVENT.sessionsRevoked
Members none
Default requested; ends every local session of the person

RISC 1.0 deprecates it in favour of CAEP’s session-revoked, but transmitters still send it. iya-sts sends it by hand and on an emergency signing-key rotation.

The stream’s own events

Constant    
EVENT.verification SSF 1.0 verification The answer to receiver.verify(state); payload.state is what you passed. It names no person.
EVENT.streamUpdated SSF 1.0 stream-updated The stream’s status changed; payload.status is enabled, paused or disabled, with reason.

Neither is in eventsRequested: SSF delivers them anyway. Neither ends a session.

signals.on(EVENT.streamUpdated, (event) => {
  if (event.payload.status !== 'enabled') ops.alert(`the signals stream is ${event.payload.status}: ${event.payload.reason ?? ''}`);
});