The easiest way to make a .NET application an SSF receiver. easyssf-dotnet receives the security events of the OpenID Shared Signals Framework (SSF) with CAEP, RISC and SCIM Events, and turns them into what an application needs: rejected access tokens, ended sessions, mirrored users, or a handler of your own. SET verification, metadata discovery, PUSH and POLL delivery, stream management, replay protection and the state that several instances of an application share are the library's job; reacting to an event is a handler:
builder.AddSsfReceiver().AddEventHandler("CaepSessionRevoked", (context, cancellationToken) =>
{
SsfSubject subject = context.SubjectFor("CaepSessionRevoked");
// end what you hold for subject.SessionId or subject.Subject
return Task.CompletedTask;
});
app.MapSsfPush();The common reactions come ready-made: AddSsfTokenRevocation() rejects the access tokens of a
revoked session or user in a JwtBearer scheme, AddSsfSessionRevocation() does the same for cookie
sessions, see Reacting to revocations. With the hosting package the
transmitter is configuration, see Getting started; without a framework a few
lines wire the parts together, see Receiving pushed SETs.
Tested against the OpenID Foundation's conformance suite: the receiver passes the SSF 1.0 and CAEP Interoperability Profile receiver test plans with push and poll delivery, and the test harness that runs the suite ships with the repository, see Conformance. It is a port of easyssf for Java, which is the reference for protocol behaviour; the two share their test suites, their database tables and their Keycloak setup.
| Status | Experimental, feature complete relative to the Java library; on NuGet.org since 0.6.0, and below 1.0 a minor version may break the API |
| .NET | 10, the current LTS; older runtimes leave support in November 2026 and are not targeted |
| Scope | Receiver only; a transmitter is not planned, EasySsf.Core is written so that one could share it |
| Package prefix | EasySsf |
| License | Apache-2.0 |
| Project | Depends on | |
|---|---|---|
EasySsf.Core |
The data structures of SSF: SETs, subjects, event types, the typed CAEP, RISC and SCIM events, stream configuration, transmitter metadata, as immutable records over System.Text.Json elements. Trim- and AOT-safe, no reflection-based serialization. |
nothing |
EasySsf.Receiver |
The receiver, independent of any framework: SET verification, de-duplication, event handlers, push handling, polling, stream management, CAEP, RISC and SCIM handlers, token revocation and session termination, metrics. | EasySsf.Core, Microsoft.IdentityModel.JsonWebTokens, Microsoft.Extensions.Logging.Abstractions |
EasySsf.Receiver.Hosting |
The receiver for any Microsoft.Extensions.Hosting application: SsfReceiverOptions bound from EasySsf:Receiver, AddSsfReceiver(), the SsfTransmitterFactory that builds the parts from the options, validation on start, the hosted service that registers streams and polls, and a health check. |
EasySsf.Receiver, Microsoft.Extensions.Hosting.Abstractions, .Http, .Options.ConfigurationExtensions, .Diagnostics.HealthChecks |
EasySsf.Receiver.AspNetCore |
MapSsfPush(), the endpoint the transmitter pushes SETs to, and the revocation hooks for JwtBearer (AddSsfTokenRevocation()) and cookie authentication (AddSsfSessionRevocation()). |
EasySsf.Receiver.Hosting, ASP.NET Core, Microsoft.AspNetCore.Authentication.JwtBearer |
EasySsf.Receiver.Data |
The database stores of the receiver on ADO.NET, in plain SQL on the tables of the Java library: processed SETs, revocations and the acknowledgements a poller owes, shared by all instances of an application; AddDatabaseStores() for the host, DbSsfSchema with the SQL for a migration tool, a cleanup of expired rows. |
EasySsf.Receiver.Hosting, System.Data.Common |
EasySsf.Testing.Conformance |
Runs the OpenID conformance suite's SSF receiver test plans against a receiver under test, in any framework: the suite started with Testcontainers, the scenarios the receiver plays, the plans and the expected outcome of every module, see Conformance. Built with the solution, not published. | EasySsf.Receiver, Testcontainers |
examples/ |
A resource server and an OIDC client that receive events from a Keycloak transmitter, with a Docker Compose setup for Keycloak, and a SCIM provisioning example with a demo transmitter of its own, see Examples. Not published. | |
EasySsf.Testing |
Test support: a transmitter that signs SETs, serves its JWK Set and metadata, hands out access tokens and emulates the stream management and poll endpoints, all through an HttpMessageHandler, see Testing your receiver. |
EasySsf.Core |
- Verifies a SET (RFC 8417): signature against the transmitter's JWK Set,
typ,iss,aud,jti,iat,eventsand the top-levelsub_idSSF 1.0 requires. Rejections carry the error codes of RFC 8935 (invalid_request,invalid_key,invalid_issuer,invalid_audience), so a push endpoint can answer with them. A transmitter whose keys cannot be fetched is reported separately, as a temporary condition. - Caches the JWK Set and fetches it again when a SET names a key it does not know, rate limited so that bogus SETs cannot make the receiver flood the transmitter.
- Accepts only asymmetric signature algorithms (
RS256by default) and RSA keys of at least 2048 bits, both configurable. - Resolves subjects (RFC 9493): simple identifiers of every format, complex subjects with
user,session,device,tenantand other members, aliases, thescimformat of SCIM Events, and the conventions of early SSF drafts and of Keycloak behind explicit modes. - Knows the event types of SSF, CAEP, RISC and SCIM Events (RFC 9967) by URI and by alias
(
CaepSessionRevoked), and lets you register aliases for vendor specific types. - Receives pushed SETs (RFC 8935): checks the transmitter's
Authorizationheader in constant time, verifies, skips SETs it has already processed (jtide-duplication with a lease, so that a SET another instance is handling is left alone and one abandoned by a crash is taken over), hands verified events to yourISsfEventHandlers, and answers202,400with an RFC 8935 error document,401,413,500or503as the case requires. - Rejects a SET whose subject has a member the transmitter declared critical and the application does not interpret, as SSF requires.
- Records metrics with
System.Diagnostics.Metricsunder the Java library's names (easyssf.receiver.sets,.events,.poll). - Discovers the transmitter from its
.well-known/ssf-configuration(SSF 1.0, section 7), at the location the specification defines and at the one OpenID providers tend to use, and holds the transmitter's issuer and endpoints tohttpsunless a loopback address or an explicit insecure policy says otherwise. - Manages its stream at the transmitter: looks a stream up by identifier, or reuses, updates
or creates the stream the receiver wants, in the background with retries, and offers an
SsfStreamClientfor the stream management API (streams, status, subjects, verification). - Polls the transmitter (RFC 8936) with short or long polling, acknowledges handled SETs and
reports invalid ones on the next request, keeps acknowledgements in an
ISsfPollAckStoreuntil a request carried them, backs off onRetry-After, and authenticates with the client credentials grant, renewing the token ahead of its expiry and on a 401. - Revokes access tokens and cookie sessions: a
session-revokedevent records the session, or the user and the time, in anISsfTokenRevocationStore; a JwtBearer scheme then rejects tokens with thatsid, or thatsubissued up to the event, and a cookie scheme signs such a principal out on its next request. AnISsfSessionTerminatorof the application ends server-side sessions when the event arrives, see Reacting to revocations. - Typed events:
SsfCaepEvent(CAEP 1.0, the common claims of section 2 and the event-specific ones of section 3),SsfRiscEvent(RISC 1.0) andSsfScimEventwithSsfScimSubject(SCIM Events, RFC 9967), each read from a SET withIn(eventToken), and the handler base classes that dispatch them, see Typed handlers.
Reference EasySsf.Receiver.Hosting and, for push delivery, EasySsf.Receiver.AspNetCore:
<PackageReference Include="EasySsf.Receiver.Hosting" Version="0.6.0" />
<PackageReference Include="EasySsf.Receiver.AspNetCore" Version="0.6.0" />Configure the transmitter, register the receiver and your handlers, and map the push endpoint:
{
"EasySsf": {
"Receiver": {
"TransmitterIssuer": "https://idp.example/realms/demo",
"ExpectedAudience": "https://my-app.example",
"Push": { "ExpectedAuthHeader": "Bearer s3cr3t" }
}
}
}using EasySsf.Receiver.AspNetCore;
using EasySsf.Receiver.Hosting;
var builder = WebApplication.CreateBuilder(args);
builder.AddSsfReceiver() // binds EasySsf:Receiver, validates on start
.AddEventHandler<SessionRevokedHandler>() // any ISsfEventHandler, SsfCaepEventHandler, ...
.AddEventHandler("CaepCredentialChange", (context, ct) => credentials.ChangedAsync(context.Subject, ct));
builder.Services.AddHealthChecks().AddSsfReceiver();
var app = builder.Build();
app.MapSsfPush(); // POST /ssf/push, anonymous: the SET authenticates itself
app.MapHealthChecks("/healthz");
app.Run();The transmitter's metadata is discovered from its issuer on first use, so the application starts even while the transmitter is down. To poll instead of being pushed to, and to let the receiver register its own stream:
{
"EasySsf": {
"Receiver": {
"TransmitterIssuer": "https://idp.example/realms/demo",
"DeliveryMethod": "Poll",
"Stream": { "Management": "Receiver", "EventsRequested": ["CaepSessionRevoked", "CaepCredentialChange"] },
"Poll": { "LongPolling": true },
"OAuth2": {
"TokenUri": "https://idp.example/realms/demo/protocol/openid-connect/token",
"ClientId": "my-app", "ClientSecret": "..."
}
}
}
}The hosted service registers the stream and starts polling when the host starts, and acknowledges
what it owes the transmitter when the host stops. The audience of the stream is learned from the
registration, so ExpectedAudience is not needed.
- Several instances register under the same credentials and reuse the one stream, as the
registrar finds it on startup. Several instances that start at the same time against a transmitter
with no stream yet may each create one, unless the transmitter allows a single stream per receiver
(Keycloak does); every event is then delivered once per stream, with a
jtiof its own, which the dedup store cannot tell apart. Start one instance first, or let the transmitter manage the stream. Stream:DeleteOnShutdownis for a single instance: the stream is shared, so the first instance to stop deletes it for the others, and the next one to start creates a new stream with a new audience.
Several transmitters go under EasySsf:Receiver:Transmitters:<name>, each with the same settings;
the one at EasySsf:Receiver is named default. SETs are verified by the transmitter that issued
them, and each transmitter authenticates at the push endpoint with its own header. A customizer
replaces or adds parts of any transmitter before it is built:
builder.AddSsfReceiver().AddTransmitterCustomizer(transmitter =>
{
if (transmitter.Name == "okta")
{
transmitter.TokenProvider = new MyTokenProvider();
}
});Your own ISsfJtiDedupStore, ISsfPollAckStore or ISsfReceiverMetrics registered before
AddSsfReceiver() replace the in-memory store, the in-memory acknowledgements and the
System.Diagnostics.Metrics meter.
The receiver records what session-revoked events (and the Revocation:UserEventTypes you add,
typically CaepCredentialChange) revoke, scoped to the issuer of the tokens. Two lines hook that
into the authentication of the application:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => ...);
builder.Services.AddSsfTokenRevocation(); // rejects access tokens of revoked sessions and users
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddSsfSessionRevocation(); // signs revoked cookie sessions out on their next request| Event | Subject of the event | Effect |
|---|---|---|
session-revoked |
names a session (complex subject with a session member) |
access tokens with that sid, and cookies of that session, are rejected |
session-revoked |
names only a user (iss_sub) |
access tokens and cookies of that sub issued up to the time of the event are rejected |
a Revocation:UserEventTypes event, e.g. credential-change |
names a user, with or without a session | as above, for the user |
The check reads iss, sub, sid and iat from the principal, by their JWT names or the claim
types ASP.NET Core maps them to; a cookie is compared by the time it was issued. The revocations
live in the ISsfTokenRevocationStore, in memory for Revocation:Ttl (24 hours by default), which
must cover the longest token or cookie lifetime: a revocation that is forgotten before the token
expires makes the token usable again. AddSsfSessionRevocation() therefore fails the start when the
cookie's ExpireTimeSpan exceeds the TTL, and warns about sliding expiration, which has no bound.
The database stores share the revocations between instances. An
application that keeps sessions server-side registers an ISsfSessionTerminator instead or in
addition; the receiver then ends the sessions of the event's subject when the event arrives, with
SsfSubjectClaimsMatcher deciding which sessions belong to the subject.
A SET is pushed to one instance of an application, and a revocation it records in memory is
unknown to the others. EasySsf.Receiver.Data keeps that state in the database of the
application instead, in three tables that are the same as those of the Java library:
<PackageReference Include="EasySsf.Receiver.Data" Version="0.6.0" />| Table | Store | Content |
|---|---|---|
EASYSSF_PROCESSED_SET |
DbSsfJtiDedupStore |
the SETs that were processed or are being processed (STATE), forgotten after Dedup:Retention (7 days) |
EASYSSF_REVOCATION |
DbSsfTokenRevocationStore |
revoked sessions and subjects, per issuer, removed after Revocation:Ttl |
EASYSSF_POLL_ACK |
DbSsfPollAckStore |
acknowledgements and error reports a polling receiver owes its transmitter until a poll request carried them, forgotten after Data:AckRetention (7 days) |
builder.Services.AddNpgsqlDataSource(connectionString); // registers a DbDataSource
builder.AddSsfReceiver().AddDatabaseStores();
// or, for a provider without a DbDataSource:
builder.AddSsfReceiver().AddDatabaseStores(() => new SqliteConnection(connectionString));- Tables: in SQLite the tables are created on startup, and columns a newer release added to an
existing table as well. For any other database create them with your migration tool:
migration/(one script per release that changed the schema, named for Flyway, plain SQL for anything else; alsoDbSsfSchema.Migrations) keeps an installation current from release to release,schema.sql(DbSsfSchema.SchemaSql) is the current schema for a fresh one. Or setData:InitializeSchema=Alwaysto have the receiver create and upgrade the tables itself. If a table or a column is missing the host fails to start with the statements to run, rather than on the first event. The stores use plain SQL (VARCHAR,BIGINT, timestamps as milliseconds since the epoch, no vendor syntax; a result limit is applied by reading no further) and are tested on SQLite and PostgreSQL. - Several instances: a SET is claimed before its handlers run and marked processed afterwards.
An instance that receives a SET another one is handling leaves it for the transmitter's redelivery,
neither handling nor acknowledging it, so a handler failure on the first instance is not masked by
an acknowledgement of the second. The claim is renewed every third of
Dedup:Lease(60 seconds) while the handlers run; a claim that was not renewed for the lease counts as abandoned by a crashed instance and the SET is handled again. The guarantee is therefore at-least-once processing with concurrent deliveries suppressed, not exactly once: handlers stay idempotent and key their side effects byeventContext.IdempotencyKey. - Opting out:
Data:Enabled=falsekeeps the state in memory although the stores were added. AnISsfJtiDedupStore,ISsfTokenRevocationStoreorISsfPollAckStorethe application registered itself is kept in any case. - Cleanup: expired rows are deleted when a store is written to (at most once a minute) and every
Data:CleanupInterval(15 minutes) byDbSsfStoreCleanupin the background;00:00:00turns the periodic cleanup off. The stores offerPurgeExpiredAsync()to do it from your own scheduler. - Cost: checking an access token or cookie is one query on the primary key of
EASYSSF_REVOCATION. - Without a host, build the stores yourself:
ISsfDbOperationsruns their SQL, over aDbDataSourceor a connection factory (DataSourceSsfDbOperations, with hooks for the parameter prefix and the error detection of an unusual provider) or over your own data access, andPrepareTableAsync(create)checks or creates a store's table.
Every setting of EasySsf:Receiver, with its default. The settings of a transmitter, from
TransmitterIssuer to SetValidation, repeat under Transmitters:<name>. Lists
(EventsRequested, AcceptedAlgorithms, UnderstoodSubjectMembers, Scopes) start empty and
mean their default while empty, because configuration binding adds to a list rather than replacing
it.
| Setting | Default | |
|---|---|---|
Enabled |
true |
Switches the receiver off entirely when false. |
TransmitterIssuer |
Issuer of the transmitter, must match iss of every SET. An https URL without query or fragment; http only on loopback addresses or with AllowInsecureHttp. Required unless transmitters are configured by name. |
|
TransmitterMetadataUrl |
derived | By default <host>/.well-known/ssf-configuration<issuer-path> (SSF 1.0, section 7.2), falling back to <issuer>/.well-known/ssf-configuration. |
TransmitterJwksUrl |
from metadata | Skips metadata discovery for the keys when set. |
AllowInsecureHttp |
false |
Accepts http for the transmitter issuer, the endpoints it publishes and the poll endpoint on any host, with a warning on startup. For development only. |
ExpectedAudience |
Every SET must contain it in aud. Without it, the audience of the stream the receiver registers or looks up, and SETs that arrive before the stream is known are left for redelivery (503). Without either the receiver refuses to start, unless SetValidation:AllowAnyAudience is set. |
|
DeliveryMethod |
Push |
Push or Poll. |
UnderstoodSubjectMembers |
user, session, device, application, tenant, org_unit, group |
The members of a complex subject the application interprets; a SET with a member the transmitter declared critical that is not listed is rejected. |
EventAliases:<alias> |
Aliases for event type URIs, e.g. "AcmeLogin": "https://events.acme.example/login", usable wherever an event type is named. |
|
Http:Timeout |
00:00:05 |
Timeout for calls to the transmitter; a long poll waits its hold time plus a margin regardless. |
Http:UserAgent |
User-Agent header for calls to the transmitter. |
|
Http:MaxResponseSize |
4194304 (4 MiB) |
Largest response body accepted from the transmitter, in bytes (metadata, JWK Set, token, stream configuration, poll response); a larger one fails the call as if the transmitter were unavailable. |
TransmitterAccessToken |
Static access token for stream management and polling. | |
OAuth2:TokenUri |
Token endpoint for the client credentials grant. | |
OAuth2:ClientId / ClientSecret |
||
OAuth2:Scopes |
||
OAuth2:ClientAuthenticationMethod |
Basic |
Basic or Post. |
OAuth2:ExpirySafetyWindow |
00:00:30 |
How long before its expiry an access token is renewed, at most a quarter of its lifetime. |
OAuth2:AdditionalParameters:<name> |
Form parameters sent with the token request in addition to the grant. | |
Stream:Management |
Transmitter |
Receiver makes the application create or update its stream on startup. |
Stream:Id |
Stream created at the transmitter, looked up on startup. | |
Stream:EventsRequested |
CaepSessionRevoked, CaepCredentialChange |
For a stream managed by the receiver. |
Stream:Description |
For a stream managed by the receiver. | |
Stream:DeleteOnShutdown |
false |
For a stream managed by the receiver. Single instance only: the stream is shared, see Getting started. |
Poll:EndpointUrl |
from the stream | Held to the same https rule as the transmitter issuer, as the access token is sent to it. |
Poll:AutoStartup |
true |
Otherwise the application polls by calling SsfPoller.PollNowAsync(). |
Poll:Interval |
00:00:30 |
|
Poll:InitialDelay |
00:00:01 |
|
Poll:MaxEvents |
100 |
SETs per request. |
Poll:LongPolling |
false |
Keep one request outstanding that the transmitter holds until SETs are available, instead of polling every Interval. |
Poll:LongPollingHold |
00:00:30 |
How long the transmitter holds a long poll request, agreed with it; the request waits that long plus a margin. |
Poll:RateLimit:FallbackBackoff |
Pause after a 429 without Retry-After; unset, the next poll comes at the regular interval. |
|
Poll:RateLimit:MaxBackoff |
00:05:00 |
Longest pause a Retry-After header or the fallback can cause. |
Metrics:Enabled |
true |
Record easyssf.receiver.* meters with System.Diagnostics.Metrics. |
SetValidation:AcceptedAlgorithms |
RS256 |
JWS algorithms accepted for SETs. |
SetValidation:MinRsaKeySize |
2048 |
0 disables the check. |
SetValidation:RequireTypeHeader |
true |
Requires typ: secevent+jwt. |
SetValidation:SubjectCompatibility |
Strict |
Requires the top-level sub_id claim of SSF 1.0. Legacy accepts SETs of transmitters following earlier drafts, which put the subject into the event. |
SetValidation:ClockSkew |
00:01:00 |
Tolerance for iat in the future. |
SetValidation:AllowAnyAudience |
false |
Accepts SETs with any aud when no audience is configured or known from a stream. For a transmitter that sends no aud only; with it the receiver also accepts SETs the transmitter issued for other receivers. |
Push:EndpointPath |
/ssf/push |
Where MapSsfPush() maps the endpoint; a pattern given to MapSsfPush wins. |
Push:ExpectedAuthHeader |
Exact Authorization header value the transmitter must send. |
|
Push:DeliveryEndpointUrl |
How the transmitter reaches the push endpoint, for a stream managed by the receiver. | |
Dedup:Enabled |
true |
|
Dedup:Capacity |
10000 |
Size of the in-memory jti store. |
Dedup:Lease |
00:01:00 |
How long a SET counts as being handled by the instance that claimed it without renewing the claim, which it does every third of this time while its handlers run. |
Dedup:Retention |
7.00:00:00 |
How long the database store remembers a processed SET. |
Data:Enabled |
true |
Keep the state in the database once AddDatabaseStores() was called. |
Data:InitializeSchema |
Embedded |
When to create missing tables: Embedded (SQLite), Always or Never. |
Data:TablePrefix |
EASYSSF_ |
Prefix of the table names. |
Data:CleanupInterval |
00:15:00 |
How often expired rows are purged; 00:00:00 turns it off. |
Data:AckRetention |
7.00:00:00 |
How long the database store keeps a poll acknowledgement no request managed to deliver. |
Data:AckDeleteBatchSize |
100 |
How many delivered acknowledgements one DELETE of the database store removes at once. |
Revocation:Enabled |
true |
Record revocations and reject tokens of revoked sessions and users. |
Revocation:SessionEventTypes |
CaepSessionRevoked |
Events that revoke the session they name, or the user if they name none. |
Revocation:UserEventTypes |
Events that revoke the user they name, session or not; CaepCredentialChange for cookie sessions. |
|
Revocation:Ttl |
1.00:00:00 |
How long a revocation is remembered; at least the longest access token or cookie lifetime, or a revoked token is accepted again once the revocation is forgotten. AddSsfSessionRevocation() refuses to start when the cookie's ExpireTimeSpan is longer. |
SessionTermination:Enabled |
true |
End server-side sessions through the registered ISsfSessionTerminator. |
SessionTermination:SessionEventTypes |
CaepSessionRevoked |
Events that terminate the session (or user sessions) of their subject. |
SessionTermination:UserEventTypes |
CaepCredentialChange |
Events that terminate all sessions of their subject's user. |
SessionTermination:ScimAttributeClaims:<attribute> |
externalId: sub, id: sub, userName: preferred_username |
Attributes of the scim subject of a SCIM Event and the claim of the logged-in user each is compared with. |
services.AddHealthChecks().AddSsfReceiver() adds the check easyssf. It reports from what the
receiver already knows and never calls the transmitter: Healthy once the transmitter was reached
(its metadata retrieved for push, the last poll succeeded for poll) and its stream, if looked up or
managed, is registered; Unhealthy when the last poll failed or the stream cannot be used;
Degraded while there was no contact yet, which is how the application starts. The data of the
result lists transmitter, delivery, metadata, stream, registration state, last polls and pending
acknowledgements, under the transmitter's name when there are several.
The meter EasySsf.Receiver records, under the names of the Java library:
| Instrument | Type | Tags |
|---|---|---|
easyssf.receiver.sets |
counter | transmitter (the name of the transmitter, default for the one at EasySsf:Receiver), delivery (push, poll), outcome (handled, duplicate, invalid, unauthenticated, unavailable, failed, in_progress) |
easyssf.receiver.events |
counter | transmitter, delivery, event (for example CaepSessionRevoked) |
easyssf.receiver.poll |
histogram, seconds | transmitter, outcome (success, failure) |
easyssf.receiver.poll.pending-acks |
gauge | transmitter; acknowledgements and error reports waiting for the next poll request |
Without a framework, EasySsf.Receiver is the package to reference (it brings EasySsf.Core):
<PackageReference Include="EasySsf.Receiver" Version="0.6.0" />using EasySsf.Core.Event;
using EasySsf.Receiver.Set;
SsfSetVerifierOptions options = new();
options.ExpectedAudiences.Add("https://my-app.example");
ISsfSetVerifier verifier = new JwtSsfSetVerifier(
issuer: "https://idp.example/realms/demo",
jwkSetUri: new Uri("https://idp.example/realms/demo/protocol/openid-connect/certs"),
httpClient: new HttpClient { MaxResponseContentBufferSize = 4 * 1024 * 1024 },
options);
try
{
SsfEventToken set = await verifier.VerifyAsync(encodedSet, cancellationToken);
SsfSubject subject = SsfSubject.From(set.SubjectId);
if (set.Events.ContainsKey(SsfEventTypes.CaepSessionRevoked))
{
// end what you hold for subject.SessionId or subject.Subject
}
}
catch (SsfSetVerificationException ex)
{
// ex.ErrorCode is invalid_request, invalid_key, invalid_issuer or invalid_audience (RFC 8935, section 2.4)
}
catch (SsfTransmitterUnavailableException)
{
// the JWK Set could not be fetched: answer 503, the transmitter will deliver the SET again
}The JWK Set location can also be supplied lazily, as a Func<CancellationToken, ValueTask<Uri>>,
so that it is discovered from the transmitter's metadata on first use. SsfSetVerifierOptions
holds the accepted algorithms, the minimum RSA key size, the clock skew, the subject compatibility
mode (Strict for SSF 1.0, Legacy for transmitters that put the subject into the event payload)
and the TimeProvider. For several transmitters, IssuerRoutingSsfSetVerifier picks the verifier
by the iss claim.
SsfPushHandler does everything but the HTTP: give it the Authorization header and the body of
the request the transmitter posted, and send back the status and body it returns.
using EasySsf.Receiver.Event;
using EasySsf.Receiver.Push;
using EasySsf.Receiver.Set;
ISsfEventHandler handler = SsfEventHandler.For("CaepSessionRevoked", (context, cancellationToken) =>
{
SsfSubject subject = context.SubjectFor("CaepSessionRevoked");
return sessions.EndAsync(subject.SessionId, subject.Subject, context.IdempotencyKey, cancellationToken);
});
SsfSetProcessor processor = new(verifier, new InMemorySsfJtiDedupStore(capacity: 10_000), [handler]);
SsfPushHandler pushHandler = new(processor, expectedAuthorizationHeader: "Bearer " + pushSecret);
// in the endpoint the transmitter posts to, for example a minimal API:
app.MapPost("/ssf/push", async (HttpRequest request, CancellationToken cancellationToken) =>
{
using MemoryStream body = new();
await request.Body.CopyToAsync(body, cancellationToken);
SsfPushResponse response = await pushHandler.HandleAsync(request.Headers.Authorization, body.ToArray(), cancellationToken);
return response.Body is null ? Results.StatusCode(response.Status)
: Results.Text(response.Body, SsfPushResponse.ContentType, statusCode: response.Status);
});Handlers are called for every SET and must be idempotent: a SET is handled at least once, not
exactly once. Throwing from a handler makes the push handler answer 500, so the transmitter
delivers the SET again; the other handlers still run. SsfEventHandler.Of handles every SET,
SsfEventHandler.For only those with a given event; context.EventFor gives the payload as a
JsonElement, context.EventTimestamp the event_timestamp or the issue time. For a
processor.ProcessAsync call of your own, the outcome tells whether the SET was handled or a
duplicate, and SsfSetProcessorOptions takes the metrics, the critical subject members a
transmitter declares and the stream verification hook.
For a receiver that polls, SsfTransmitterBuilder assembles the parts for one transmitter and
SsfTransmitter.Start() runs them in the background: the registrar registers the stream, the
poller fetches as soon as the stream is known. An HttpClient with a long enough timeout serves
them all; bound its MaxResponseContentBufferSize as well (the hosting integration sets 4 MiB),
as it buffers up to 2 GiB of whatever the transmitter sends by default.
using EasySsf.Core.Stream;
using EasySsf.Receiver.Poll;
using EasySsf.Receiver.Set;
using EasySsf.Receiver.Stream;
using EasySsf.Receiver.Transmitter;
HttpClient http = new() { Timeout = TimeSpan.FromMinutes(2), MaxResponseContentBufferSize = 4 * 1024 * 1024 };
string issuer = "https://idp.example/realms/demo";
SsfTransmitterMetadataResolver metadata = new(issuer, null, http);
ClientCredentialsSsfTransmitterTokenProvider tokens = new(http,
new Uri("https://idp.example/realms/demo/protocol/openid-connect/token"), clientId, clientSecret);
SsfReceiverStream receiverStream = new();
SsfStreamClient streamClient = new(http, tokens, metadata);
SsfStreamRegistrar registrar = SsfStreamRegistrar.ForManagedStream(streamClient, receiverStream,
SsfStreamConfiguration.Poll(["CaepSessionRevoked", "CaepCredentialChange"], "my-app"));
SsfSetVerifierOptions verifierOptions = new() { ExpectedAudiencesProvider = () => receiverStream.Audience };
JwtSsfSetVerifier verifier = new(issuer, async ct => (await metadata.ResolveAsync(ct)).JwksUri!, http, verifierOptions);
SsfSetProcessor processor = new(verifier, new InMemorySsfJtiDedupStore(10_000), [handler],
new SsfSetProcessorOptions { CriticalSubjectMembers = _ => metadata.ResolvedMetadata?.CriticalSubjectMembers });
SsfPoller poller = new(http, tokens, () => receiverStream.Configuration?.DeliveryEndpointUrl, processor,
new SsfPollerOptions { Transmitter = issuer, LongPollingHold = TimeSpan.FromSeconds(30) });
SsfTransmitter transmitter = new SsfTransmitterBuilder(SsfTransmitter.DefaultName, issuer)
{
MetadataResolver = metadata, Verifier = verifier, TokenProvider = tokens, StreamClient = streamClient,
ReceiverStream = receiverStream, StreamRegistrar = registrar, Poller = poller,
}.Build();
transmitter.Start();
// ... on shutdown:
await transmitter.StopAsync();The poller sends the access token only to a poll endpoint that passes SsfPollerOptions.UriPolicy,
https or http on loopback addresses, whether the endpoint was configured or taken from the
stream; set it to SsfTransmitterUriPolicy.Insecure for a transmitter without TLS in a test setup.
For push delivery, register with SsfStreamConfiguration.Push(endpointUrl, authorizationHeader, events, description) and leave the poller out; transmitter.PushAuthorizationHeader is what
the SsfPushHandler then expects. Several transmitters go into an SsfTransmitters, whose
Verifier routes SETs by issuer and whose PushAuthorizationHeader looks the header up per
transmitter. SsfTransmitterFactory in the hosting package builds all of this from an
SsfTransmitterOptions, which is what AddSsfReceiver() does for every configured transmitter.
For CAEP, RISC and SCIM Events, derive from the handler of the family and override the methods for
the events the application acts on. Each method gets the typed event and the context of the SET;
the subject is eventContext.Subject for CAEP and RISC, scimEvent.Subject for SCIM.
using EasySsf.Core.Caep;
using EasySsf.Core.Scim;
using EasySsf.Receiver.Caep;
using EasySsf.Receiver.Scim;
class StepUpHandler(IStepUp stepUp) : SsfCaepEventHandler
{
protected override Task OnAssuranceLevelChangeAsync(SsfCaepEvent caepEvent, SsfEventContext eventContext,
CancellationToken cancellationToken)
{
return (caepEvent.ChangeDirection == "decrease")
? stepUp.RequireAsync(eventContext.Subject.Subject, caepEvent.CurrentLevel, cancellationToken)
: Task.CompletedTask;
}
}
class ProvisioningHandler(IUsers users) : SsfScimEventHandler
{
protected override Task OnCreateAsync(SsfScimEvent scimEvent, SsfEventContext eventContext,
CancellationToken cancellationToken)
{
return (scimEvent.IsFull && scimEvent.Subject?.ResourceType == "Users")
? users.CreateAsync(scimEvent.Subject.Id, scimEvent.Data, cancellationToken)
: Task.CompletedTask;
}
protected override Task OnDeactivateAsync(SsfScimEvent scimEvent, SsfEventContext eventContext,
CancellationToken cancellationToken)
{
return users.DisableAsync(scimEvent.Subject?.Id, cancellationToken);
}
}An event type in the CAEP or RISC namespace that this version does not know arrives as
SsfCaepEventKind.Other or SsfRiscEventKind.Other in OnOtherCaepEventAsync and
OnOtherRiscEventAsync, with the common claims typed and the rest in Payload. The enums grow as
the specifications do, so do not switch over them exhaustively. SsfScimOperation folds the
notice and full variants of a provisioning event into one operation; IsFull and IsNotice
tell which arrived.
Event types and aliases. Event types are identified by their URIs; the aliases are a convenience
of easyssf, the family and the event name in PascalCase: CaepSessionRevoked is
https://schemas.openid.net/secevent/caep/event-type/session-revoked, RiscAccountDisabled and
ScimProvCreateFull likewise. Both forms are accepted wherever an event type is named; use the URIs
where you persist event types, and SsfEventTypes.Resolve and SsfEventTypes.AliasOf to go from one
to the other. Vendor specific event types get aliases of your own, which then work in
EventsRequested, eventContext.HasEvent and your handlers like the built-in ones; an alias cannot
redefine a built-in or an earlier one:
SsfEventTypes.RegisterAlias("AcmeLogin", "https://events.acme.example/login");{ "EasySsf": { "Receiver": { "EventAliases": { "AcmeLogin": "https://events.acme.example/login" } } } }The hosting package registers the configured aliases on startup.
EasySsf.Testing contains TestTransmitter, a transmitter that signs SETs and serves its JWK
Set and metadata to an HttpClient in-process, without a socket. The tests of easyssf-dotnet use
it.
<PackageReference Include="EasySsf.Testing" Version="0.6.0" />using EasySsf.Testing;
using static EasySsf.Core.Event.SsfSubjectIdentifiers;
using TestTransmitter transmitter = new();
using HttpClient http = transmitter.CreateHttpClient();
JwtSsfSetVerifier verifier = new(transmitter.Issuer, new Uri(transmitter.JwksUri), http);
JsonObject claims = transmitter.SetClaims("CaepSessionRevoked",
Complex(IssSub(transmitter.Issuer, "alice"), Opaque("session-1")));
claims["aud"] = "https://my-app.example";
string encodedSet = transmitter.SignSet(claims);
// hand encodedSet to the receiver under testThe claims are a JsonObject, so a test removes or changes what it wants rejected
(claims.Remove("jti")). TestTransmitter.Sign signs with any key, algorithm and typ,
TestTransmitter.Unsigned produces an unsecured JWT, TestSigningKey.Generate(1024, "weak")
a key the receiver has to refuse. The subject helpers are the static methods of
SsfSubjectIdentifiers. For a polling receiver, transmitter.QueueSet(encodedSet) makes the SET
available at the poll endpoint, transmitter.Available = false simulates an outage,
transmitter.AcknowledgedSets and transmitter.ReportedErrors show what the receiver
acknowledged, transmitter.Streams the streams it registered. A test with several transmitters
gives each its own host with TestTransmitter.WithMetadata(location, "okta.example").
examples/ holds three applications and a Keycloak setup: a resource server
(JwtBearer) that rejects the access tokens of a session Keycloak revoked, an OIDC client whose
cookie is rejected once the Keycloak session ended or the password changed, and a SCIM provisioning
example that mirrors Users from SCIM Events (RFC 9967) delivered by an in-process transmitter.
The README of the examples walks through running them; the two Keycloak
applications contain no SSF code apart from configuration.
The receiver is tested against the OpenID conformance suite's SSF and CAEP receiver test plans,
with push and poll delivery. tests/EasySsf.Conformance.Receiver
is the receiver under test, a small ASP.NET Core application that plays the stream operations a
test module expects; tests/EasySsf.Conformance.Tests runs the four plans against it, with the
suite started in Docker by Testcontainers. The plan tests are skipped unless asked for, as a plan
takes minutes:
EASYSSF_CONFORMANCE_TESTS=true dotnet test tests/EasySsf.Conformance.TestsSee the README of the receiver under test for the
plans, the suite image (release-v5.3.2, pinned by digest) and manual runs against a suite of
your own.
dotnet build
dotnet test
dotnet format # formatting per .editorconfig; CI fails on unformatted sources
EASYSSF_DATABASE_TESTS=true dotnet test tests/EasySsf.Receiver.Data.Tests # also on PostgreSQL, needs Docker
EASYSSF_CONFORMANCE_TESTS=true dotnet test tests/EasySsf.Conformance.Tests # the conformance plans, needs Docker.NET SDK 10.0.401 or a later feature band (global.json); the libraries target net10.0 only, the
current LTS, and build with warnings as errors and the AOT analysers on. Conventions for contributors and coding agents are in
AGENTS.md, the security policy in SECURITY.md.
The Java code is the reference: protocol behaviour, names and tests are carried over, so that a
reader can hold the two side by side. The .NET conventions differ where the platform does: types
keep the Ssf prefix and interfaces get an I, value objects are sealed records over
JsonElement, everything that waits is async with a CancellationToken, the Java setters are
options types, and TimeProvider replaces Clock. Vendor names are replaced by what a type does
(NimbusSsfSetVerifier is JwtSsfSetVerifier).