Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/guides/openid-federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ beyond the authorization code flow is your grant, never its own metadata's:
`AllowedScopes`, `AuthorizationDetailsTypes`, `AllowsClientCredentialsGrant`,
`AllowsCIBA` and `AllowedClientAuthMethods`. A resolved registration is cached for at most
`MaxCacheAge`, so a superior that stops vouching for a client takes
effect within that time. A failed one is remembered for `FailureCacheAge`
effect within that time. The cache is shared fairly: one superior's
Relying Parties, or one branch of a Trust Anchor's, can hold only part of
it, so an intermediate minting many Relying Parties only ever displaces
its own (see `MaxCacheAge`). A failed one is remembered for `FailureCacheAge`
(10 seconds by default), so requests repeating a client_id that doesn't
resolve don't each repeat the outbound fetches. `OnResolutionFailure` tells
your operator why a client was refused, once per resolution attempted; the
Expand Down
61 changes: 24 additions & 37 deletions federation/automatic_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,16 @@ type AutomaticRegistrationConfig struct {
// for a given client is min(MaxCacheAge, its own Trust Chain's
// ResolvedEntity.ExpiresAt), never longer than the chain itself
// remains valid. Required — must be positive. At most 4096
// registrations are cached at once: once full, expired ones are
// dropped, and if none has expired a new registration is used but
// not cached (so it's resolved again on its next use) rather than
// evicting a registration that's still valid.
// registrations are cached at once, and no one federation member
// can fill that: the Relying Parties under any one immediate
// superior share at most 512 places (that superior's oldest is
// evicted for its newest), and those in any one branch of a Trust
// Anchor (everything under one of its subordinates) at most 2048.
// Relying Parties directly under a Trust Anchor count only against
// the 4096. Past a branch's or the whole cache's limit, expired
// registrations are dropped first; if none has expired a new
// registration is used but not cached (so it's resolved again on its
// next use) rather than evicting another superior's still-valid one.
MaxCacheAge time.Duration

// FailureCacheAge is how long a failed automatic registration is
Expand Down Expand Up @@ -161,6 +167,10 @@ type cachedClient struct {
client storage.RegisteredClient
jwks json.RawMessage
expiresAt time.Time
// superior and branch are where the Relying Party sits in its Trust
// Chain, which bounds how much of the cache it and its siblings may
// hold (see registrationCache and chainPosition).
superior, branch string
}

// DefaultFailureCacheAge is AutomaticRegistrationConfig.FailureCacheAge
Expand All @@ -177,16 +187,6 @@ const DefaultFailureCacheAge = 10 * time.Second
// simply isn't remembered — the same as before failures were cached.
const maxFailedResolutions = 4096

// maxCachedRegistrations bounds how many successful registrations
// AutomaticClientRepository caches at once. An entity that can have
// subordinates registered under a trusted Trust Anchor can mint new
// client_ids, each costing one resolution, so the cache must not grow
// with them either. Once full, expired entries are dropped, and if none
// has expired a new registration simply isn't cached: evicting a
// still-valid one instead would let anyone able to register clients
// push a busy client out of the cache for the price of one resolution.
const maxCachedRegistrations = 4096

// maxRememberedFailureBytes bounds the message a remembered failure
// keeps. A failed Trust Chain resolution's error names every branch tried
// and echoes the RP's and its superiors' own claims, so its length is
Expand Down Expand Up @@ -274,7 +274,7 @@ type AutomaticClientRepository struct {
clock Clock

mu sync.Mutex
cache map[fapi.ClientID]cachedClient
cache *registrationCache
// failures holds recent registration failures (see
// AutomaticRegistrationConfig.FailureCacheAge), at most
// maxFailedResolutions of them.
Expand Down Expand Up @@ -342,7 +342,7 @@ func NewAutomaticClientRepository(underlying storage.ClientRepository, resolver
}
return &AutomaticClientRepository{
underlying: underlying, resolver: resolver, fetcher: fetcher, cfg: cfg, clock: clock,
cache: make(map[fapi.ClientID]cachedClient),
cache: newRegistrationCache(),
failures: make(map[fapi.ClientID]failedResolution),
inflight: make(map[fapi.ClientID]*inflightResolution),
}, nil
Expand Down Expand Up @@ -400,12 +400,9 @@ func (a *AutomaticClientRepository) resolve(ctx context.Context, id fapi.ClientI
now := a.clock.Now()

a.mu.Lock()
if entry, ok := a.cache[id]; ok {
if now.Before(entry.expiresAt) {
a.mu.Unlock()
return entry, nil
}
delete(a.cache, id)
if entry, ok := a.cache.get(id, now); ok {
a.mu.Unlock()
return entry, nil
}
if failed, ok := a.failures[id]; ok && now.Before(failed.expiresAt) {
a.mu.Unlock()
Expand Down Expand Up @@ -464,21 +461,10 @@ func (a *AutomaticClientRepository) resolveUncached(ctx context.Context, id fapi
return entry, nil
}

// cacheRegistration caches entry as id's registration, within
// maxCachedRegistrations (see its doc comment for the policy once full).
// a.mu must be held.
// cacheRegistration caches entry as id's registration, within the
// limits registrationCache's doc comment sets out. a.mu must be held.
func (a *AutomaticClientRepository) cacheRegistration(id fapi.ClientID, entry cachedClient, now time.Time) {
if _, ok := a.cache[id]; !ok && len(a.cache) >= maxCachedRegistrations {
for k, c := range a.cache {
if !now.Before(c.expiresAt) {
delete(a.cache, k)
}
}
if len(a.cache) >= maxCachedRegistrations {
return
}
}
a.cache[id] = entry
a.cache.put(id, entry, now)
}

// rememberFailure records err as id's registration failure until
Expand Down Expand Up @@ -525,7 +511,8 @@ func (a *AutomaticClientRepository) register(ctx context.Context, id fapi.Client
if maxAge := a.clock.Now().Add(a.cfg.MaxCacheAge); maxAge.Before(expiresAt) {
expiresAt = maxAge
}
return cachedClient{client: client, jwks: jwks, expiresAt: expiresAt}, nil
superior, branch := chainPosition(resolved.Chain)
return cachedClient{client: client, jwks: jwks, expiresAt: expiresAt, superior: superior, branch: branch}, nil
}

// fetchJWKS fetches and parses a client's remote jwks_uri, mirroring
Expand Down
26 changes: 14 additions & 12 deletions federation/automatic_registration_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,40 +729,40 @@ func TestRememberFailureKeepsABoundedCopy(t *testing.T) {
// than evicting one that's still valid.
func TestCacheRegistrationStaysWithinItsCap(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
repo := &AutomaticClientRepository{cache: make(map[fapi.ClientID]cachedClient)}
repo := &AutomaticClientRepository{cache: newRegistrationCache()}
entry := cachedClient{expiresAt: now.Add(time.Hour)}
for i := range maxCachedRegistrations {
repo.cacheRegistration(fapi.ClientID(fmt.Sprintf("https://rp%d.example", i)), entry, now)
}
if len(repo.cache) != maxCachedRegistrations {
t.Fatalf("cache = %d, want the cap %d", len(repo.cache), maxCachedRegistrations)
if repo.cache.len() != maxCachedRegistrations {
t.Fatalf("cache = %d, want the cap %d", repo.cache.len(), maxCachedRegistrations)
}

// Full, nothing expired: a new registration isn't cached, and no
// still-valid one is evicted for it.
repo.cacheRegistration("https://new.example", entry, now.Add(time.Second))
if _, ok := repo.cache["https://new.example"]; ok || len(repo.cache) != maxCachedRegistrations {
t.Fatalf("full cache with nothing expired cached a new registration (len %d)", len(repo.cache))
if _, ok := repo.cache.entries["https://new.example"]; ok || repo.cache.len() != maxCachedRegistrations {
t.Fatalf("full cache with nothing expired cached a new registration (len %d)", repo.cache.len())
}
if _, ok := repo.cache["https://rp0.example"]; !ok {
if _, ok := repo.cache.entries["https://rp0.example"]; !ok {
t.Fatal("a still-valid registration was evicted")
}

// An already-cached client_id is still refreshed when full.
refreshed := cachedClient{expiresAt: now.Add(2 * time.Hour)}
repo.cacheRegistration("https://rp0.example", refreshed, now.Add(time.Second))
if got := repo.cache["https://rp0.example"].expiresAt; !got.Equal(refreshed.expiresAt) {
if got := repo.cache.entries["https://rp0.example"].entry.expiresAt; !got.Equal(refreshed.expiresAt) {
t.Fatalf("refreshed registration expires at %v, want %v", got, refreshed.expiresAt)
}

// Once registrations have expired, a new one takes their place.
later := now.Add(90 * time.Minute)
repo.cacheRegistration("https://new.example", cachedClient{expiresAt: later.Add(time.Hour)}, later)
if _, ok := repo.cache["https://new.example"]; !ok {
if _, ok := repo.cache.entries["https://new.example"]; !ok {
t.Fatal("registration not cached after expired entries could be dropped")
}
if len(repo.cache) != 2 {
t.Fatalf("cache after eviction = %d, want 2 (the refreshed rp0 and the new one)", len(repo.cache))
if repo.cache.len() != 2 {
t.Fatalf("cache after eviction = %d, want 2 (the refreshed rp0 and the new one)", repo.cache.len())
}
}

Expand All @@ -775,14 +775,16 @@ func TestResolveDropsAnExpiredRegistration(t *testing.T) {
repo := &AutomaticClientRepository{
clock: fixedTestClock{now: now},
cfg: AutomaticRegistrationConfig{FailureCacheAge: time.Minute},
cache: map[fapi.ClientID]cachedClient{id: {expiresAt: now.Add(-time.Second)}},
cache: newRegistrationCache(),
failures: make(map[fapi.ClientID]failedResolution),
inflight: make(map[fapi.ClientID]*inflightResolution),
}
repo.cache.put(id, cachedClient{expiresAt: now.Add(time.Hour)}, now.Add(-2*time.Hour))
repo.cache.entries[id].entry.expiresAt = now.Add(-time.Second)
if _, err := repo.resolve(context.Background(), id); err == nil {
t.Fatal("resolve of an expired registration with an invalid entity ID = nil error, want error")
}
if _, ok := repo.cache[id]; ok {
if _, ok := repo.cache.entries[id]; ok {
t.Fatal("expired registration still cached after its lookup")
}
}
6 changes: 6 additions & 0 deletions federation/automatic_registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ func TestAutomaticClientRepositoryResolveClientFallsBackToFederation(t *testing.
if err != nil {
t.Fatalf("ResolveClient: %v", err)
}
// The registration is cached against where it sits in its Trust
// Chain: directly under the Trust Anchor, so the Trust Anchor is its
// superior and it has no branch.
if sup, branch, ok := federation.CachedChainPosition(repo, fapi.ClientID(f.rpID)); !ok || sup != f.taID || branch != "" {
t.Errorf("cached chain position = %q, %q (cached %v), want %q, \"\"", sup, branch, ok, f.taID)
}
if got.ID() != fapi.ClientID(f.rpID) {
t.Errorf("ID() = %q, want %q", got.ID(), f.rpID)
}
Expand Down
15 changes: 15 additions & 0 deletions federation/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package federation

import fapi "github.com/idfoundry/fapigo"

// CachedChainPosition reports where id's cached registration sits in its
// Trust Chain (see chainPosition), for tests in package federation_test.
func CachedChainPosition(r *AutomaticClientRepository, id fapi.ClientID) (superior, branch string, ok bool) {
r.mu.Lock()
defer r.mu.Unlock()
slot, ok := r.cache.entries[id]
if !ok {
return "", "", false
}
return slot.entry.superior, slot.entry.branch, true
}
170 changes: 170 additions & 0 deletions federation/registration_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package federation

import (
"container/list"
"time"

fapi "github.com/idfoundry/fapigo"
)

// maxCachedRegistrations bounds how many successful registrations
// AutomaticClientRepository caches at once. An entity that can have
// subordinates registered under a trusted Trust Anchor can mint new
// client_ids, each costing one resolution, so the cache must not grow
// with them either.
const maxCachedRegistrations = 4096

// maxCachedPerSuperior bounds the cached registrations whose Trust Chain
// runs through any one immediate superior — the entity that issued the
// Relying Party's Subordinate Statement, and so the only one able to
// mint more Relying Parties beside it.
const maxCachedPerSuperior = maxCachedRegistrations / 8

// maxCachedPerBranch bounds the cached registrations under any one
// subordinate of a Trust Anchor (the branch the Trust Anchor itself
// vouched for). An intermediate can mint intermediates beneath it, each
// a new immediate superior with its own maxCachedPerSuperior, but all of
// them sit in the one branch it was vouched into.
const maxCachedPerBranch = maxCachedRegistrations / 2

// registrationCache is AutomaticClientRepository's cache of successful
// registrations, bounded so that no federation member can crowd out
// Relying Parties it doesn't control:
//
// - Each entry is counted against its immediate superior and against
// its branch, the Trust Anchor's own subordinate its chain runs
// through. A Relying Party registered directly under a Trust Anchor
// has no branch: a Trust Anchor is trusted, and such a Relying Party
// can't mint others beside it, so those count only against
// maxCachedRegistrations.
// - A new entry for a superior at maxCachedPerSuperior evicts that
// superior's own least recently cached entry, so its newest
// registrations stay cached without touching anyone else's.
// - Otherwise, a new entry that would take its branch past
// maxCachedPerBranch, or the cache past maxCachedRegistrations, first
// drops every expired entry, and if that isn't enough the new entry
// simply isn't cached. A still-valid entry is never evicted for
// another superior's registration, so an attacker able to mint
// Relying Parties can only ever displace its own.
//
// An entry also leaves the cache when found expired on lookup. Every
// method requires the AutomaticClientRepository's mutex.
type registrationCache struct {
entries map[fapi.ClientID]*cacheSlot
bySuperior map[string]*list.List
branchCounts map[string]int
}

// cacheSlot is one cached registration and its place in its superior's
// list, oldest first.
type cacheSlot struct {
entry cachedClient
elem *list.Element
}

func newRegistrationCache() *registrationCache {
return &registrationCache{
entries: make(map[fapi.ClientID]*cacheSlot),
bySuperior: make(map[string]*list.List),
branchCounts: make(map[string]int),
}
}

// get returns id's entry if cached and not expired at now, removing it
// when expired.
func (c *registrationCache) get(id fapi.ClientID, now time.Time) (cachedClient, bool) {
slot, ok := c.entries[id]
if !ok {
return cachedClient{}, false
}
if !now.Before(slot.entry.expiresAt) {
c.remove(id)
return cachedClient{}, false
}
return slot.entry, true
}

// len reports how many registrations are cached.
func (c *registrationCache) len() int { return len(c.entries) }

// put caches entry as id's registration at now, within the limits
// registrationCache's doc comment sets out, and reports whether it did.
// Re-caching an id replaces its entry.
func (c *registrationCache) put(id fapi.ClientID, entry cachedClient, now time.Time) bool {
c.remove(id)
if entry.branch != "" {
if own := c.bySuperior[entry.superior]; own != nil && own.Len() >= maxCachedPerSuperior {
c.remove(own.Front().Value.(fapi.ClientID))
}
if c.branchCounts[entry.branch] >= maxCachedPerBranch {
c.dropExpired(now)
if c.branchCounts[entry.branch] >= maxCachedPerBranch {
return false
}
}
}
if len(c.entries) >= maxCachedRegistrations {
c.dropExpired(now)
if len(c.entries) >= maxCachedRegistrations {
return false
}
}
own := c.bySuperior[entry.superior]
if own == nil {
own = list.New()
c.bySuperior[entry.superior] = own
}
c.entries[id] = &cacheSlot{entry: entry, elem: own.PushBack(id)}
if entry.branch != "" {
c.branchCounts[entry.branch]++
}
return true
}

// remove drops id's entry, if cached.
func (c *registrationCache) remove(id fapi.ClientID) {
slot, ok := c.entries[id]
if !ok {
return
}
delete(c.entries, id)
if own := c.bySuperior[slot.entry.superior]; own != nil {
own.Remove(slot.elem)
if own.Len() == 0 {
delete(c.bySuperior, slot.entry.superior)
}
}
if b := slot.entry.branch; b != "" {
if c.branchCounts[b]--; c.branchCounts[b] <= 0 {
delete(c.branchCounts, b)
}
}
}

// dropExpired removes every entry expired at now. It's called only when
// a limit is reached, not per request.
func (c *registrationCache) dropExpired(now time.Time) {
for id, slot := range c.entries {
if !now.Before(slot.entry.expiresAt) {
c.remove(id)
}
}
}

// chainPosition returns, for a Relying Party resolved through chain
// (the Relying Party first, its Trust Anchor last), the immediate
// superior and the branch registrationCache counts it against. A chain
// of one or two entities — a Relying Party that is its own Trust Anchor,
// or one directly under it — has no branch.
func chainPosition(chain []string) (superior, branch string) {
switch {
case len(chain) == 0:
return "", ""
case len(chain) == 1:
return chain[0], ""
case len(chain) == 2:
return chain[1], ""
default:
return chain[1], chain[len(chain)-2]
}
}
Loading
Loading