Skip to content

About

Building blocks for the OpenID Shared Signals Framework (SSF) for .NET: a receiver library with CAEP, RISC and SCIM Events

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

easyssf-dotnet

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

Projects

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

What it does today

  • Verifies a SET (RFC 8417): signature against the transmitter's JWK Set, typ, iss, aud, jti, iat, events and the top-level sub_id SSF 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 (RS256 by 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, tenant and other members, aliases, the scim format 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 Authorization header in constant time, verifies, skips SETs it has already processed (jti de-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 your ISsfEventHandlers, and answers 202, 400 with an RFC 8935 error document, 401, 413, 500 or 503 as 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.Metrics under 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 to https unless 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 SsfStreamClient for 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 ISsfPollAckStore until a request carried them, backs off on Retry-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-revoked event records the session, or the user and the time, in an ISsfTokenRevocationStore; a JwtBearer scheme then rejects tokens with that sid, or that sub issued up to the event, and a cookie scheme signs such a principal out on its next request. An ISsfSessionTerminator of 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) and SsfScimEvent with SsfScimSubject (SCIM Events, RFC 9967), each read from a SET with In(eventToken), and the handler base classes that dispatch them, see Typed handlers.

Getting started

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 jti of its own, which the dedup store cannot tell apart. Start one instance first, or let the transmitter manage the stream.
  • Stream:DeleteOnShutdown is 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.

Reacting to revocations

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.

Sharing state between instances

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; also DbSsfSchema.Migrations) keeps an installation current from release to release, schema.sql (DbSsfSchema.SchemaSql) is the current schema for a fresh one. Or set Data:InitializeSchema=Always to 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 by eventContext.IdempotencyKey.
  • Opting out: Data:Enabled=false keeps the state in memory although the stores were added. An ISsfJtiDedupStore, ISsfTokenRevocationStore or ISsfPollAckStore the 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) by DbSsfStoreCleanup in the background; 00:00:00 turns the periodic cleanup off. The stores offer PurgeExpiredAsync() 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: ISsfDbOperations runs their SQL, over a DbDataSource or a connection factory (DataSourceSsfDbOperations, with hooks for the parameter prefix and the error detection of an unusual provider) or over your own data access, and PrepareTableAsync(create) checks or creates a store's table.

Configuration

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.

Health

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.

Metrics

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

Verifying a SET

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.

Receiving pushed SETs

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.

Polling and stream management

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.

Typed handlers

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.

Testing your receiver

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 test

The 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

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.

Conformance

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.Tests

See 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.

Build

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.

Relation to the Java library

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).

About

Building blocks for the OpenID Shared Signals Framework (SSF) for .NET: a receiver library with CAEP, RISC and SCIM Events

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages