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) + } + } +}