SSFgo is a lightweight Go implementation of the OpenID Shared Signals Framework, CAEP and RISC, providing embeddable Transmitter and Receiver capabilities with a focus on standards compliance and interoperability.
Status: API frozen for v1.0, not yet released. Both roles are implemented; once
v1.0.0is tagged the API is covered by COMPATIBILITY.md. The Transmitter passes every module of the OIDF CAEP Interoperability Profile Transmitter plan, and the Receiver every module of the Receiver plan — see conformance/README.md. The full matrix runs daily in CI. OIDF has not yet opened SSF certification. See ROADMAP.md.
| Specification | Status in SSFgo |
|---|---|
| OpenID Shared Signals Framework 1.0 | Transmitter and Receiver |
| OpenID CAEP 1.0 | all 8 event types |
| OpenID RISC 1.0 | all 14 event types |
| RFC 9967 SCIM events | all 12 event types and the scim subject; one event per SET |
| CAEP Interoperability Profile 1.0 | both roles pass the OIDF plans; caep/interop enforces the profile for each |
| RFC 8417 Security Event Token | done |
| RFC 9493 Subject Identifiers | done |
| RFC 8935 Push delivery / RFC 8936 Poll delivery | both sides |
The module has no third-party dependencies. Durable storage for
PostgreSQL and SQLite is a separate module,
storage/sqlstore, which imports no database driver
itself.
GETTING_STARTED.md walks through embedding each role step by step, and docs/guides covers one feature each — session revocation, push or poll, the CAEP Interoperability Profile, SCIM events, storage and scaling, testing, observability and keys in a KMS or HSM. In brief:
A Transmitter — for example inside an identity provider — serves the SSF endpoints and emits events:
tx, err := transmitter.New(transmitter.Config{
Issuer: "https://idp.example.com/ssf",
SigningKeys: []transmitter.SigningKey{{Signer: key, Algorithm: ssf.RS256, KeyID: "2026-09"}},
EventsSupported: interop.EventTypes(),
DeliveryMethods: []ssf.DeliveryMethod{ssf.DeliveryPush, ssf.DeliveryPoll},
DefaultSubjects: ssf.DefaultSubjectsAll,
Store: memstore.NewStreamStore(),
Assurance: ssf.AssuranceDevelopment, // AssuranceProduction refuses in-memory stores: use storage/sqlstore
Authorize: authorizeAccessToken, // your OAuth resource-server check
PermitEvent: permitEvent, // which Receiver may see which subject's events
Limits: transmitter.RecommendedLimits(),
PushRetry: transmitter.RecommendedPushRetry(),
})
go tx.Run(ctx) // push delivery
http.ListenAndServeTLS(":443", cert, key, tx.Handler())
tx.Emit(ctx, ssf.IssSubSubject{Issuer: iss, Subject: "alice"}, caep.SessionRevoked{
Common: caep.Common{ReasonAdmin: ssf.LocalizedText{"en": "Suspicious activity"}},
})A Receiver — for example inside a relying party — creates a stream and handles typed events:
rx, err := receiver.New(ctx, receiver.Config{
Issuer: "https://idp.example.com/ssf",
Audience: "https://rp.example.com",
Registry: registry, // ssf.NewRegistry() + caep.Register
Algorithms: []ssf.SignatureAlgorithm{ssf.RS256},
TokenSource: &receiver.ClientCredentials{TokenURL: tokenURL, ClientID: id, ClientSecret: ssf.NewSecret(secret), AuthMethod: receiver.ClientSecretBasic},
ReplayStore: memstore.NewReplayStore(),
Assurance: ssf.AssuranceDevelopment,
Limits: receiver.RecommendedLimits(), // replay window, key age, clock skew
})
// Optional: interop.ApplyReceiver(&cfg) before receiver.New holds the
// Transmitter to the CAEP Interoperability Profile.
receiver.On(rx, func(ctx context.Context, set ssf.SET, e caep.SessionRevoked) error {
return sessions.RevokeAll(ctx, set.Subject)
})
pushSecret := ssf.NewSecret("Bearer " + randomToken) // withheld from logs and %v; Reveal() reads it
http.Handle("/ssf/events", rx.PushHandler(receiver.PushOptions{AuthorizationHeader: pushSecret}))
// Creates the stream on the first start; later starts reuse it, updating
// whatever changed, and wait out a Transmitter that is briefly unavailable.
stream, err := rx.EnsureStream(ctx, receiver.StreamRequest{Delivery: &ssf.Delivery{
Method: ssf.DeliveryPush, EndpointURL: "https://rp.example.com/ssf/events", AuthorizationHeader: pushSecret,
}})memstore keeps everything in memory, so ssf.AssuranceProduction
refuses it. To survive restarts, or to run several instances on one
database (HorizontallyScaled, which needs PostgreSQL), use
storage/sqlstore:
// go get github.com/idfoundry/ssfgo/storage/sqlstore
db, err := sql.Open("pgx", dsn) // any database/sql driver for PostgreSQL or SQLite
err = sqlstore.CreateSchema(ctx, db, sqlstore.Postgres)
store, err := sqlstore.NewStreamStore(ctx, db, sqlstore.Postgres) // transmitter.Config.Store
replay, err := sqlstore.NewReplayStore(ctx, db, sqlstore.Postgres) // receiver.Config.ReplayStore
revocations, err := sqlstore.NewRevocationStore(ctx, db, sqlstore.Postgres) // revocation.Newexamples/session-revocation runs both
sides in one process: cd examples/session-revocation && go run .. Like
every example, it is its own module and uses only the public API.
To revoke tokens as events arrive, revocation records
what session-revoked, account-disabled and similar events mean and checks
the application's validated tokens against it:
rev, err := revocation.New(memstore.NewRevocationStore(), revocation.Options{
Issuers: revocation.SameIssuer, // or StaticTokenIssuers{transmitter: tokenIssuer}
Events: revocation.RecommendedEvents(), // session-revoked, account-disabled, ...
Retention: 24 * time.Hour, // at least the longest token lifetime
Assurance: ssf.AssuranceDevelopment, // production needs a durable store, e.g. sqlstore
})
rev.Register(rx)
api = rev.Middleware(tokenOf, api) // 401 for a token issued before its revocationBoth roles report what they do through optional Hooks in their config,
for metrics or traces without a dependency on any metrics library, and
answer readiness probes with Ready:
cfg.Hooks = receiver.Hooks{SET: func(ctx context.Context, i receiver.SETInfo) {
setsTotal.WithLabelValues(string(i.EventType), i.Outcome.String()).Inc() // e.g. Prometheus
}}
http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if err := rx.Ready(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
}
})To test an application that plays one role, ssftest runs the
other in-process: ssftest.NewTransmitter for testing a Receiver,
ssftest.NewReceiver for testing a Transmitter.
tx := ssftest.NewTransmitter(t)
rx, err := receiver.New(ctx, tx.ReceiverConfig(registry))
stream, err := rx.EnsureStream(ctx, receiver.StreamRequest{})
err = tx.Emit(ctx, subject, caep.SessionRevoked{...})
_, err = rx.Poll(ctx, stream, receiver.PollOptions{}) // your handlers runSee ARCHITECTURE.md, and SECURITY.md for the security model and how to report a vulnerability.
See CONTRIBUTING.md. Changes are listed in CHANGELOG.md, and UPGRADING.md says what to change for each breaking one.
MIT — see LICENSE.