Skip to content

Access-change invalidation

Access tokens are self-contained JWTs, so removing a person from an organization or lowering a role does not reach a token already issued. SystemForge bounds that window two ways: short token lifetimes, and a record of every access-reducing change that relying parties consult. The design is in Organizations and Membership; the relying-party API is in Relying Parties.

Token lifetime

Setting Default Limit
tokens.access_token_lifetime 10 minutes 15 minutes; a larger value fails configuration loading with ErrInvalidConfig
Per client access_token_lifetime (and sf_oauth_apps.access_token_ttl) The server value Capped at 15 minutes
tokens.refresh_token_lifetime 7 days (absolute 30 days)

A membership or role change does not revoke refresh tokens: the next refresh re-evaluates the orgs claim and issues reduced claims. Only deactivation and explicit session revocation revoke refresh tokens.

What is recorded

sf_access_changes receives a row, in the transaction that makes the change, for: membership_removed, org_role_changed, app_role_changed, deactivated, sessions_revoked, management_granted, management_ended and grant_reduced. Adding access records nothing. Pass a sink to each service with WithAccessChangeSink; rows are written with or without one. accesschange.PruneExpired deletes rows past accesschange.Retention; call it from your own maintenance loop.

Relying-party revocation cache

A token is accepted only if its iat is at or after the recorded change plus a clock-skew margin (30 seconds by default, never below 15).

cache := relyingparty.NewRevocationCache()
mw := relyingparty.BearerMiddleware(relyingparty.BearerConfig{
    Tokens: client, Principals: principals, Revocation: cache,
})
Deployment Feed the cache with
Modular monolith, one process accesschange.ToSink(cache) passed to every service's WithAccessChangeSink
Replicas sharing the database relyingparty.NewAccessChangePoller(source, cache).Run(ctx), polling every 1 to 2 seconds, with WithSuspectStart() on the cache
Another process relyingparty.ReplayWithRetry against GET /access-changes?since=, plus back-channel delivery
Separate deployments Back-channel events (below)

After a restart a cache created with WithSuspectStart refuses tokens issued before it started until the first replay or poll succeeds, so a restart never widens the window.

Replay endpoint

Enable GET /access-changes?since=<RFC 3339>[&limit=] with systemauth.WithAccessChanges(accesschange.NewSource(db)). The caller needs a client-credentials token with the access_changes:read scope. The response is {"changes": [...], "next_since": ...}.

Status Cause
401 Missing or invalid token
403 The token lacks access_changes:read
400 since is not RFC 3339, or limit is not a positive integer
501 Access changes are not configured

Back-channel delivery

Register the client's endpoint (an https URL, or http for a loopback host) as backchannel_logout_uri (ClientConfig.BackchannelLogoutURI, column sf_oauth_apps.backchannel_logout_uri, migration 0007_oauth_app_backchannel_logout_uri.sql) and run SystemAuth's sender:

bc := auth.NewBackchannel()
orgs := organization.NewService(db, organization.WithAccessChangeSink(bc))
go bc.Run(ctx)

SystemAuth POSTs an OpenID Connect Back-Channel Logout token carrying the urn:systemforge:event:access_change event to every registered client, retrying with backoff for up to one maximum token lifetime and never following redirects; a 4xx other than 408 and 429 is not retried. The receiver is relyingparty.BackchannelHandler(cache, client, ...); the BFF serves it at <BasePath>/auth/backchannel-logout.

What a client sees

Situation Response
Token issued before a recorded change 401 with WWW-Authenticate: Bearer error="invalid_token", error_description="token expired"; refresh and retry
BFF session after a change The session refreshes its tokens and claims and stays signed in with reduced claims
Refresh refused (deactivation, revoked sessions) The session ends
Back-channel token invalid (signature, issuer, audience, typ, jti replay) The receiver refuses it; a failure to end sessions answers 500 so SystemAuth retries

Applications that bypass relyingparty must implement the same check.