From e239dd6be53050bfe5700736cebe6c56a7c2dcb4 Mon Sep 17 00:00:00 2001 From: Oscar Sanderson Date: Fri, 9 Oct 2026 18:05:38 +0800 Subject: [PATCH] fix(federation): share the registration cache fairly between superiors The automatic-registration cache held at most 4096 registrations and, once full, cached nothing new rather than evict a valid entry. That kept an attacker from pushing out busy clients, but an intermediate that minted and refreshed 4096 Relying Parties once per cache lifetime kept it full indefinitely, so every other federation client paid a full Trust Chain resolution on every request. Each cached registration is now counted against its immediate superior (the entity that issued its Subordinate Statement, the only one able to mint Relying Parties beside it) and against its branch (the Trust Anchor's own subordinate its chain runs through, which also covers the intermediates an intermediate mints beneath itself): - a superior holds at most 512 entries; its newest registration evicts its own oldest, never anyone else's; - a branch holds at most 2048; past that, and past the overall 4096, expired entries are dropped first and otherwise the new registration isn't cached; - Relying Parties directly under a Trust Anchor can't mint siblings, so they count only against the overall limit. An attacker able to mint Relying Parties can now only ever displace its own. Lookups stay O(1) and an eviction is O(1); the expired-entry sweep runs only when a limit is reached. The cache moves into its own type, and MaxCacheAge's doc and the federation guide describe the policy. Co-Authored-By: Claude Opus 5.5 --- docs/guides/openid-federation.md | 5 +- federation/automatic_registration.go | 61 +++--- .../automatic_registration_internal_test.go | 26 +-- federation/automatic_registration_test.go | 6 + federation/export_test.go | 15 ++ federation/registration_cache.go | 170 +++++++++++++++++ federation/registration_cache_test.go | 178 ++++++++++++++++++ 7 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 federation/export_test.go create mode 100644 federation/registration_cache.go create mode 100644 federation/registration_cache_test.go diff --git a/docs/guides/openid-federation.md b/docs/guides/openid-federation.md index 73570410..17ac3432 100644 --- a/docs/guides/openid-federation.md +++ b/docs/guides/openid-federation.md @@ -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 diff --git a/federation/automatic_registration.go b/federation/automatic_registration.go index f10e4d9a..e0beba70 100644 --- a/federation/automatic_registration.go +++ b/federation/automatic_registration.go @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -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() @@ -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 @@ -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 diff --git a/federation/automatic_registration_internal_test.go b/federation/automatic_registration_internal_test.go index 5713b57e..8167799b 100644 --- a/federation/automatic_registration_internal_test.go +++ b/federation/automatic_registration_internal_test.go @@ -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()) } } @@ -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") } } diff --git a/federation/automatic_registration_test.go b/federation/automatic_registration_test.go index 852edb48..d89ca305 100644 --- a/federation/automatic_registration_test.go +++ b/federation/automatic_registration_test.go @@ -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) } diff --git a/federation/export_test.go b/federation/export_test.go new file mode 100644 index 00000000..113e5575 --- /dev/null +++ b/federation/export_test.go @@ -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 +} diff --git a/federation/registration_cache.go b/federation/registration_cache.go new file mode 100644 index 00000000..783e94ec --- /dev/null +++ b/federation/registration_cache.go @@ -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 ®istrationCache{ + 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] + } +} diff --git a/federation/registration_cache_test.go b/federation/registration_cache_test.go new file mode 100644 index 00000000..c3260eb3 --- /dev/null +++ b/federation/registration_cache_test.go @@ -0,0 +1,178 @@ +package federation + +import ( + "fmt" + "testing" + "time" + + fapi "github.com/idfoundry/fapigo" +) + +var cacheTestNow = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + +func cacheEntry(superior, branch string, expiresAt time.Time) cachedClient { + return cachedClient{superior: superior, branch: branch, expiresAt: expiresAt} +} + +func rpID(prefix string, i int) fapi.ClientID { + return fapi.ClientID(fmt.Sprintf("https://%s%d.example", prefix, i)) +} + +// TestRegistrationCacheContainsOneSuperiorsLeaves: an intermediate that +// mints more Relying Parties than maxCachedPerSuperior only ever +// displaces its own, oldest first, and a Relying Party under another +// superior is still cached and stays cached. +func TestRegistrationCacheContainsOneSuperiorsLeaves(t *testing.T) { + c := newRegistrationCache() + valid := cacheTestNow.Add(time.Hour) + if !c.put("https://honest-rp.example", cacheEntry("https://honest-int.example", "https://honest-int.example", valid), cacheTestNow) { + t.Fatal("honest registration not cached") + } + for i := range maxCachedPerSuperior * 3 { + c.put(rpID("attack", i), cacheEntry("https://evil-int.example", "https://evil-int.example", valid), cacheTestNow) + } + if got := c.bySuperior["https://evil-int.example"].Len(); got != maxCachedPerSuperior { + t.Fatalf("attacker's superior holds %d entries, want its quota %d", got, maxCachedPerSuperior) + } + if _, ok := c.get("https://honest-rp.example", cacheTestNow); !ok { + t.Fatal("the attacker's registrations displaced another superior's") + } + // The attacker's newest registrations are the ones kept. + if _, ok := c.entries[rpID("attack", 0)]; ok { + t.Fatal("the attacker's oldest registration is still cached") + } + if _, ok := c.entries[rpID("attack", maxCachedPerSuperior*3-1)]; !ok { + t.Fatal("the attacker's newest registration isn't cached") + } + if !c.put("https://second-honest.example", cacheEntry("https://other-int.example", "https://other-int.example", valid), cacheTestNow) { + t.Fatal("a Relying Party under another superior wasn't cached after the attack") + } +} + +// TestRegistrationCacheBoundsABranch: intermediates minted beneath one +// intermediate are each a new immediate superior, but share the branch +// the Trust Anchor vouched for. Once that branch is full, a new entry in +// it isn't cached, and nothing outside the branch is evicted for it. +func TestRegistrationCacheBoundsABranch(t *testing.T) { + c := newRegistrationCache() + valid := cacheTestNow.Add(time.Hour) + c.put("https://outside.example", cacheEntry("https://other-int.example", "https://other-int.example", valid), cacheTestNow) + n := 0 + for s := 0; n < maxCachedPerBranch; s++ { + sup := fmt.Sprintf("https://sub%d.evil-int.example", s) + for i := 0; i < maxCachedPerSuperior/2 && n < maxCachedPerBranch; i++ { + if !c.put(rpID(fmt.Sprintf("s%d-", s), i), cacheEntry(sup, "https://evil-int.example", valid), cacheTestNow) { + t.Fatalf("entry %d refused before the branch filled", n) + } + n++ + } + } + if got := c.branchCounts["https://evil-int.example"]; got != maxCachedPerBranch { + t.Fatalf("branch holds %d, want %d", got, maxCachedPerBranch) + } + if c.put("https://one-more.example", cacheEntry("https://new-sub.evil-int.example", "https://evil-int.example", valid), cacheTestNow) { + t.Fatal("an entry past its branch's cap was cached") + } + if _, ok := c.get("https://outside.example", cacheTestNow); !ok { + t.Fatal("an entry outside the full branch was evicted") + } + // Once the branch's entries expire, it accepts new ones again. + later := valid.Add(time.Second) + if !c.put("https://one-more.example", cacheEntry("https://new-sub.evil-int.example", "https://evil-int.example", later.Add(time.Hour)), later) { + t.Fatal("branch didn't accept a new entry after its old ones expired") + } +} + +// TestRegistrationCacheGlobalCapNeverEvictsValidEntries: when the whole +// cache is full of still-valid entries, a new one under a superior below +// its quota isn't cached; no other superior's entry is evicted for it. +func TestRegistrationCacheGlobalCapNeverEvictsValidEntries(t *testing.T) { + c := newRegistrationCache() + valid := cacheTestNow.Add(time.Hour) + for i := range maxCachedRegistrations { + // Directly under the Trust Anchor: no branch, so only the global + // cap applies. + c.put(rpID("ta", i), cacheEntry("https://ta.example", "", valid), cacheTestNow) + } + if c.len() != maxCachedRegistrations { + t.Fatalf("cache holds %d, want %d", c.len(), maxCachedRegistrations) + } + if c.put("https://new.example", cacheEntry("https://int.example", "https://int.example", valid), cacheTestNow) { + t.Fatal("a new entry was cached in a full cache with nothing expired") + } + if _, ok := c.entries[rpID("ta", 0)]; !ok { + t.Fatal("a still-valid entry was evicted") + } +} + +// TestRegistrationCacheTrustAnchorLeavesHaveNoQuota: Relying Parties +// directly under a Trust Anchor can't mint siblings, so they count only +// against the global cap, not against maxCachedPerSuperior. +func TestRegistrationCacheTrustAnchorLeavesHaveNoQuota(t *testing.T) { + c := newRegistrationCache() + valid := cacheTestNow.Add(time.Hour) + for i := range maxCachedPerSuperior + 10 { + if !c.put(rpID("ta", i), cacheEntry("https://ta.example", "", valid), cacheTestNow) { + t.Fatalf("Trust Anchor leaf %d refused below the global cap", i) + } + } + if got := c.bySuperior["https://ta.example"].Len(); got != maxCachedPerSuperior+10 { + t.Fatalf("Trust Anchor leaves cached = %d, want %d", got, maxCachedPerSuperior+10) + } +} + +// TestRegistrationCacheRefreshKeepsOneEntry: re-caching a client_id +// replaces its entry and moves it to the back of its superior's order, +// without counting it twice. +func TestRegistrationCacheRefreshKeepsOneEntry(t *testing.T) { + c := newRegistrationCache() + sup, br := "https://int.example", "https://int.example" + c.put("https://a.example", cacheEntry(sup, br, cacheTestNow.Add(time.Hour)), cacheTestNow) + c.put("https://b.example", cacheEntry(sup, br, cacheTestNow.Add(time.Hour)), cacheTestNow) + c.put("https://a.example", cacheEntry(sup, br, cacheTestNow.Add(2*time.Hour)), cacheTestNow) + if c.len() != 2 || c.branchCounts[br] != 2 || c.bySuperior[sup].Len() != 2 { + t.Fatalf("after refresh: len %d, branch %d, superior %d, want 2 each", c.len(), c.branchCounts[br], c.bySuperior[sup].Len()) + } + if got := c.bySuperior[sup].Front().Value.(fapi.ClientID); got != "https://b.example" { + t.Fatalf("oldest after refresh = %q, want b (a was refreshed)", got) + } + if got := c.entries["https://a.example"].entry.expiresAt; !got.Equal(cacheTestNow.Add(2 * time.Hour)) { + t.Fatalf("refreshed expiry = %v", got) + } +} + +// TestRegistrationCacheExpiredLookupReleasesQuota: an entry found +// expired on lookup is removed, freeing its superior's and branch's +// counts. +func TestRegistrationCacheExpiredLookupReleasesQuota(t *testing.T) { + c := newRegistrationCache() + sup, br := "https://int.example", "https://int.example" + c.put("https://a.example", cacheEntry(sup, br, cacheTestNow.Add(time.Minute)), cacheTestNow) + if _, ok := c.get("https://a.example", cacheTestNow.Add(2*time.Minute)); ok { + t.Fatal("expired entry returned") + } + if c.len() != 0 || c.branchCounts[br] != 0 || c.bySuperior[sup] != nil { + t.Fatalf("expired entry left counts: len %d, branch %d, superior list %v", c.len(), c.branchCounts[br], c.bySuperior[sup]) + } +} + +// TestChainPosition: the immediate superior is the chain's second +// entry, and the branch is the Trust Anchor's own subordinate, with no +// branch for a Relying Party directly under (or being) its Trust Anchor. +func TestChainPosition(t *testing.T) { + for _, tc := range []struct { + chain []string + superior, branch string + }{ + {nil, "", ""}, + {[]string{"ta"}, "ta", ""}, + {[]string{"rp", "ta"}, "ta", ""}, + {[]string{"rp", "int", "ta"}, "int", "int"}, + {[]string{"rp", "sub", "int", "ta"}, "sub", "int"}, + } { + sup, br := chainPosition(tc.chain) + if sup != tc.superior || br != tc.branch { + t.Errorf("chainPosition(%v) = %q, %q, want %q, %q", tc.chain, sup, br, tc.superior, tc.branch) + } + } +}