Building blocks for the OpenID Shared Signals Framework (SSF) in Rust, for applications that receive security events: a framework independent receiver library, a crate for axum (and through tower for tonic, Loco and Leptos), a crate for actix-web, and database stores on sqlx. A port of easyssf for Java, following the layout of the Java modules and of easyssf-dotnet; the three share their test suites, their database tables and their configuration keys.
Turn a Rust application into an SSF receiver without implementing SET validation, metadata discovery, PUSH and POLL delivery, stream management and replay protection yourself. Reacting to a security event is a closure:
let receiver = SsfReceiver::builder(config)
.handler(for_event("CaepSessionRevoked", |context: SsfEventContext| async move {
let subject = context.subject();
// end what you hold for subject.session_id() or subject.subject()
Ok(())
}))
.build()?;
receiver.start(); // registers the stream, starts polling
let app = Router::new().merge(push_router_for(&receiver)); // POST /ssf/push| Crate | Holds |
|---|---|
easyssf-core |
The vocabulary: SETs, subjects, event types with aliases, the typed CAEP, RISC and SCIM events, stream configuration, transmitter metadata |
easyssf-receiver |
The receiver without a web framework: verification, de-duplication with a lease, push and poll, stream management, typed handlers, token revocation, session termination, metrics; the configuration types and SsfReceiver, which assembles a receiver from them |
easyssf-receiver-axum |
push_router_for(), SsfRevocationLayer for resource servers, SsfSessionRevocationLayer for tower-sessions |
easyssf-receiver-actix-web |
configure_push_for(), SsfRevocation middleware, SsfSessionRevocation for actix-session |
easyssf-receiver-sqlx |
The three tables shared with Java and .NET, on SQLite, PostgreSQL and MySQL |
easyssf-test |
TestTransmitter: signs SETs, serves metadata and the JWK Set, issues access tokens, emulates stream management and polling, in-process through the receiver's HTTP client trait |
Rocket, Poem, Salvo and warp need no crate: read the Authorization header and the body, call
SsfPushHandler::handle, answer with its status and body (see Plain Rust).
- 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).jsonwebtokenchecks the signature only; every claim rule is the library's, because a SET has noexpand jsonwebtoken's defaults would reject every SET. - 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. - Resolves subjects (RFC 9493): every simple format, complex subjects, aliases, the
scimformat, 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 and claim renewal, so a SET another instance is handling is left alone and one abandoned by a crash is taken over), hands verified events to the handlers, answers202,400with an error document,401,413,500or503. The web framework crates run the processing in a spawned task, so a transmitter that disconnects does not cancel the handlers. - Discovers the transmitter from its
.well-known/ssf-configuration, at the SSF location and at the one OpenID providers tend to use, and holds its issuer and endpoints tohttpsunless a loopback address orallow-insecure-httpsays otherwise. - Manages its stream: looks a stream up by id, or reuses, updates or creates the stream the
receiver wants, in the background with retries;
SsfStreamClientfor the management API. - Polls (RFC 8936) with short or long polling, acknowledges handled SETs and reports invalid
ones with the next request, keeps acknowledgements in an
SsfPollAckStore, backs off onRetry-After, authenticates with the client credentials grant. - Revokes access tokens and sessions:
session-revokedevents fill anSsfTokenRevocationStore; the axum layer and the actix middleware reject tokens with thatsidor thatsubissued up to the event, and the session layers end sessions on their next request. AnSsfSessionTerminatorends server-side sessions as the event arrives. - Typed events with handler traits:
SsfCaepEventHandler,SsfRiscEventHandler,SsfScimEventHandler, each adapted withcaep(...),risc(...)orscim(...). - Records metrics with the
metricsfacade under the Java names (featuremetrics) and reports its health (receiver.health()).
[dependencies]
easyssf-receiver = "0.6.0"
easyssf-receiver-axum = "0.6.0"The configuration keys are those of the Spring Boot starter and the .NET options, in kebab-case,
deserialized with serde from TOML, YAML, JSON or the environment through the configuration crate of
your choice (figment, config-rs, ...), under easyssf.receiver:
[easyssf.receiver]
transmitter-issuer = "https://idp.example/realms/demo" # required
expected-audience = "https://my-app.example" # or let the receiver register the stream
[easyssf.receiver.push]
expected-auth-header = "Bearer ${SSF_PUSH_SECRET}" # recommendeduse easyssf_receiver::{SsfReceiver, config::SsfReceiverConfig, event::for_event};
use easyssf_receiver_axum::{SsfRevocationLayer, push_router_for};
let config: SsfReceiverConfig = figment.extract_inner("easyssf.receiver")?;
let receiver = SsfReceiver::builder(config)
.handler(for_event("CaepCredentialChange", credentials_changed))
.build()?; // validates the configuration
receiver.start(); // registers the stream, starts polling
let app = Router::new()
.route("/api/orders", get(orders))
// after the layer that validates the access token and leaves its claims on the request
.layer(SsfRevocationLayer::new(receiver.token_revocation_store(), |parts| {
parts.extensions.get::<SsfTokenClaims>().cloned()
}))
.merge(push_router_for(&receiver)); // POST /ssf/push
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await?;
receiver.shutdown().await; // sends the acknowledgements it owesTo poll instead of being pushed to, and to let the receiver register its own stream:
[easyssf.receiver]
transmitter-issuer = "https://idp.example/realms/demo"
delivery-method = "poll"
[easyssf.receiver.stream]
management = "receiver"
events-requested = ["CaepSessionRevoked", "CaepCredentialChange"]
[easyssf.receiver.poll]
long-polling = true
[easyssf.receiver.oauth2]
token-uri = "https://idp.example/realms/demo/protocol/openid-connect/token"
client-id = "my-app"
client-secret = "..."The audience of the stream is learned from the registration, so expected-audience is not needed.
Several transmitters go under easyssf.receiver.transmitters.<name>, each with the same settings;
the one at easyssf.receiver is named default. A customizer replaces parts of any transmitter
before it is built: .customize_transmitter(|builder| if builder.name == "okta" { ... }).
Every setting, with its default, is documented on SsfReceiverConfig and its sub-structs
(cargo doc); the keys are those of the
.NET configuration table in
kebab-case, with durations as strings such as "30s" or "24h".
actix-web builds the App once per worker thread. Build the receiver once, outside the
HttpServer::new closure, and share it; built inside, every worker would register the stream and
poll on its own.
let receiver = Arc::new(SsfReceiver::builder(config).build()?);
receiver.start();
HttpServer::new(move || {
App::new()
.configure(configure_push_for(&receiver))
.service(web::scope("/api")
.wrap(SsfRevocation::new(receiver.token_revocation_store(), |request| {
request.extensions().get::<SsfTokenClaims>().cloned()
}))
.route("/orders", web::get().to(orders)))
})Every framework integrates the library the same way: SsfPushHandler::handle(authorization_header, body) returns a status and an optional JSON body. Run it in a spawned task so that a transmitter
that disconnects does not cancel the handlers:
let response = tokio::spawn(async move { push_handler.handle(authorization.as_deref(), &body).await }).await?;
// send response.status and, if Some, response.body as application/json with Content-Language: enAssembling the parts by hand reads like the plain Java section of the Java README, see
examples/resource_server.rs of the axum crate and the documentation of SsfSetProcessor,
SsfPoller and SsfStreamRegistrar.
A SET is pushed to one instance of an application. easyssf-receiver-sqlx keeps the processed
SETs, the revocations and the acknowledgements a poller owes in the database of the application,
in the tables of the Java library (EASYSSF_PROCESSED_SET, EASYSSF_REVOCATION,
EASYSSF_POLL_ACK):
let stores = SsfSqlStores::from_config(SqlxSsfSqlOperations::postgres(pool), &config);
stores.prepare_tables().await?; // checks or creates the tables (data.initialize-schema)
stores.cleanup().start(); // purges expired rows every data.cleanup-interval
let receiver = stores.apply(SsfReceiver::builder(config)).build()?;sql/schema.sql and sql/migration/ ship byte for byte with the Java library and are embedded as
SsfSqlSchema::SCHEMA_SQL and SsfSqlSchema::MIGRATIONS for a migration tool. The stores use ?
placeholders; sqlx does not rewrite them for PostgreSQL, so the postgres implementation does.
An application on Diesel, SeaORM or tokio-postgres implements SsfSqlOperations over its own data
access.
easyssf-test contains TestTransmitter, a transmitter that signs SETs, serves its metadata and
JWK Set, hands out access tokens and emulates the stream management and poll endpoints, all
in-process through the receiver's SsfHttpClient trait: give it to the receiver as its HTTP
client, no socket is involved.
let transmitter = TestTransmitter::new();
let receiver = SsfReceiver::builder(config_for(transmitter.issuer()))
.http_client(transmitter.clone())
.build()?;
let set = transmitter.set("CaepSessionRevoked", Some(complex(Some(iss_sub(transmitter.issuer(), "alice")), Some(opaque("session-1")))));
// POST set to /ssf/push, or transmitter.queue_set(&set) for a polling receivertransmitter.set_available(false) simulates an outage, transmitter.acknowledged_sets() and
transmitter.reported_errors() show what a polling receiver acknowledged, transmitter.streams()
the streams a receiver registered. TestSigningKey::generate(1024, "weak") makes keys a receiver
has to reject.
| Crate | Feature | Default | |
|---|---|---|---|
easyssf-receiver |
rust-crypto |
yes | The pure-Rust crypto backend of jsonwebtoken. It pulls in the rsa crate, whose advisory RUSTSEC-2023-0071 concerns private-key operations a receiver never performs; deny.toml ignores it for that reason. |
easyssf-receiver |
aws-lc-rs |
The aws-lc-rs backend instead; needs a C toolchain. Exactly one backend. | |
easyssf-receiver |
reqwest |
yes | The HTTP client that calls the transmitter, rustls with aws-lc-rs. Without it, give the builder an SsfHttpClient. |
easyssf-receiver |
metrics |
easyssf.receiver.sets, .events, .poll and .poll.pending-acks with the metrics facade. |
|
easyssf-receiver-axum |
tower-sessions |
The session revocation layer. | |
easyssf-receiver-actix-web |
actix-session |
The session revocation middleware. | |
easyssf-receiver-sqlx |
sqlite, postgres, mysql |
sqlite |
The databases. |
easyssf-test, easyssf-receiver-sqlx, easyssf-receiver-axum, easyssf-receiver-actix-web |
rust-crypto, aws-lc-rs |
rust-crypto |
The backend of easyssf-receiver, forwarded so that each crate builds on its own. For aws-lc-rs, turn the default features off on every easyssf crate you depend on and name aws-lc-rs on each; one rust-crypto left on anywhere brings both backends in, which is a compile error. |
- The conformance harness (
easyssf-test-conformanceof Java): the suite in Testcontainers, its plans and scenarios. The receiver is tested by the ported unit and integration tests; the four OpenID plans are the next step. - OpenTelemetry metrics: the
metricsfacade is in, an OTel exporter bridges onto it. - A transmitter.
easyssf-coreis written to be shared with one.
examples/ has the port of the Spring Boot examples: a resource server and an OIDC
client on axum that receive events from a Keycloak transmitter (with the Docker Compose setup for
Keycloak), and a SCIM provisioning example that mirrors Users from SCIM Events against the
in-process TestTransmitter. See examples/README.md.
cargo test-all # every feature that can be on at once, see .cargo/config.toml
cargo clippy-all
cargo run -p easyssf-receiver-axum --example resource_server
cargo run -p example-scim-provisioning # the SCIM example, no Keycloak neededThe sqlx tests run on SQLite always and on PostgreSQL and MySQL when EASYSSF_DATABASE_TESTS=true
and EASYSSF_POSTGRES_URL / EASYSSF_MYSQL_URL name the databases (CI does this in the database
job). The schema job compares the SQL files with the Java repository.