mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-01 01:40:44 +01:00
* iam/oidc: drop single-use web-identity tokens and the jti replay cache Reverts the single-use semantics added in #11936 (and the #12017 refactor): AssumeRoleWithWebIdentity consumed each web-identity token's jti into a bounded replay cache, so a second exchange of the same token was rejected until exp + skew. AWS STS accepts a web-identity token repeatedly until it expires, and Kubernetes workload identity depends on that: the kubelet keeps a projected ServiceAccount token until ~80% of its lifetime, so pod restarts, co-located containers sharing the token file, repeated CLI invocations, and pre-rotation SDK refreshes all re-present the same jti and were refused on 4.50. The replay machinery also carried a real maintenance cost — a bounded concurrent cache, fingerprint-keyed entries for jti-less tokens, adopt/merge handoffs so provider rebuilds could not reopen consumed tokens, and provider-reuse bookkeeping in the refresh path that existed only to preserve that state. Removing it restores AWS semantics and deletes the whole apparatus. Refs #12063. Alternative to the per-provider opt-out in #12064. * iam: reuse unchanged OIDC providers on refresh refreshOIDCProvidersFrom rebuilt every managed provider on any store change, so an unrelated write discarded each provider's JWKS cache and an unreachable issuer could then fail exchanges the cached keys would have allowed. Keep a per-record provider cache keyed on the record content fingerprint and only rebuild when the record changes. * iam/oidc: require exp in ValidateToken Dropping the single-use check also removed the implicit bound on expiry-less tokens: a signed web-identity token without exp could mint STS sessions indefinitely. Require the exp claim, matching AWS AssumeRoleWithWebIdentity which rejects tokens without one. * iam/oidc: bound idle connections on the provider transport A provider replaced on refresh drops its http.Transport without a close; give it an IdleConnTimeout so pooled connections and their goroutines cannot linger. * s3api: cover web-identity token reuse over HTTP Sign one token and exchange that exact string twice, directly covering the Kubernetes projected-token reuse this change restores. * test(iam): refresh reuses an unchanged record's provider Covers the provider-reuse cache: an unrelated record's refresh keeps a provider's JWKS state, a changed record rebuilds it.
607 lines
24 KiB
Go
607 lines
24 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// persistentTestStore stands in for a store that outlives the process and may
|
|
// be shared (the filer store): anything but *MemoryOIDCProviderStore is
|
|
// treated as persistent.
|
|
type persistentTestStore struct{ *MemoryOIDCProviderStore }
|
|
|
|
const (
|
|
persistTestStaticIssuer = "https://static.example"
|
|
persistTestAPIIssuer = "https://api.example"
|
|
)
|
|
|
|
// persistTestConfig is an IAM config whose file defines the given OIDC
|
|
// issuers.
|
|
func persistTestConfig(issuers ...string) *IAMConfig {
|
|
var providers []*sts.ProviderConfig
|
|
for i, issuer := range issuers {
|
|
providers = append(providers, &sts.ProviderConfig{
|
|
Name: "static-" + string(rune('a'+i)),
|
|
Type: sts.ProviderTypeOIDC,
|
|
Enabled: true,
|
|
Config: map[string]interface{}{"issuer": issuer, "clientId": "aud"},
|
|
})
|
|
}
|
|
return &IAMConfig{
|
|
STS: &sts.STSConfig{
|
|
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
|
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
|
Issuer: "test-sts",
|
|
SigningKey: []byte("test-signing-key-32-characters-long"),
|
|
Providers: providers,
|
|
},
|
|
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
|
Roles: &RoleStoreConfig{StoreType: "memory"},
|
|
}
|
|
}
|
|
|
|
// startServer initializes a manager as an S3 server would at boot, with the
|
|
// given config file providers and OIDC provider store.
|
|
func startServer(t *testing.T, store OIDCProviderStore, issuers ...string) *IAMManager {
|
|
t.Helper()
|
|
cfg := persistTestConfig(issuers...)
|
|
mgr := NewIAMManager()
|
|
require.NoError(t, mgr.Initialize(cfg, func() string { return "localhost:8888" }))
|
|
mgr.installOIDCProviderStore(store, cfg.STS)
|
|
return mgr
|
|
}
|
|
|
|
func arnOf(t *testing.T, issuer string) string {
|
|
t.Helper()
|
|
arn, err := DeriveOIDCProviderARN("", issuer)
|
|
require.NoError(t, err)
|
|
return arn
|
|
}
|
|
|
|
func listedARNs(t *testing.T, mgr *IAMManager) map[string]bool {
|
|
t.Helper()
|
|
recs, err := mgr.ListOIDCProviders(context.Background())
|
|
require.NoError(t, err)
|
|
out := map[string]bool{}
|
|
for _, r := range recs {
|
|
out[r.ARN] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
func storedARNs(t *testing.T, store OIDCProviderStore) map[string]bool {
|
|
t.Helper()
|
|
recs, err := store.ListProviders(context.Background(), "")
|
|
require.NoError(t, err)
|
|
out := map[string]bool{}
|
|
for _, r := range recs {
|
|
out[r.ARN] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
func createAPIProvider(t *testing.T, mgr *IAMManager, issuer string) {
|
|
t.Helper()
|
|
require.NoError(t, mgr.CreateOIDCProvider(context.Background(), &OIDCProviderRecord{
|
|
ARN: arnOf(t, issuer), URL: issuer, ClientIDs: []string{"aud"},
|
|
}))
|
|
}
|
|
|
|
// stsKnowsIssuer reports whether STS resolves a provider for the issuer. The
|
|
// token is not validly signed; the only question is which error comes back.
|
|
func stsKnowsIssuer(t *testing.T, mgr *IAMManager, issuer string) bool {
|
|
t.Helper()
|
|
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"iss": issuer, "sub": "probe", "aud": "aud", "exp": time.Now().Add(time.Hour).Unix(),
|
|
}).SignedString([]byte("not-the-providers-key"))
|
|
require.NoError(t, err)
|
|
_, _, err = mgr.GetSTSService().ValidateWebIdentityToken(context.Background(), tok)
|
|
require.Error(t, err, "an unsigned-by-provider token for %s was accepted", issuer)
|
|
return !strings.Contains(err.Error(), "no identity provider registered")
|
|
}
|
|
|
|
// A persistent store never receives the config file's providers: it outlives
|
|
// the file and may be shared with servers whose files differ.
|
|
func TestPersistentStoreNeverHoldsConfigFileProviders(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store, persistTestStaticIssuer)
|
|
|
|
assert.Empty(t, storedARNs(t, store), "a config-file provider was written to the persistent store")
|
|
assert.True(t, listedARNs(t, mgr)[arnOf(t, persistTestStaticIssuer)], "the IAM API no longer lists the config-file provider")
|
|
rec, err := mgr.GetOIDCProvider(context.Background(), arnOf(t, persistTestStaticIssuer))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, persistTestStaticIssuer, rec.URL)
|
|
assert.True(t, stsKnowsIssuer(t, mgr, persistTestStaticIssuer), "STS stopped trusting the config-file provider")
|
|
}
|
|
|
|
// Servers sharing a store, with different config files, must not remove or
|
|
// replace each other's providers — including a zero-config server.
|
|
func TestServersSharingAStoreKeepEachOthersProviders(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
configured := startServer(t, store, persistTestStaticIssuer)
|
|
createAPIProvider(t, configured, persistTestAPIIssuer)
|
|
|
|
zeroConfig := startServer(t, store)
|
|
|
|
assert.True(t, storedARNs(t, store)[arnOf(t, persistTestAPIIssuer)], "a peer's start removed an API-created provider")
|
|
assert.True(t, listedARNs(t, configured)[arnOf(t, persistTestStaticIssuer)], "the configured server lost its config-file provider")
|
|
assert.False(t, listedARNs(t, zeroConfig)[arnOf(t, persistTestStaticIssuer)], "a server lists a provider only its peer's config file defines")
|
|
assert.True(t, stsKnowsIssuer(t, zeroConfig, persistTestAPIIssuer), "the peer does not trust the shared API-created provider")
|
|
assert.False(t, stsKnowsIssuer(t, zeroConfig, persistTestStaticIssuer), "the peer trusts a provider only another server's config file defines")
|
|
}
|
|
|
|
// Removing a provider from the config file revokes it at the next start.
|
|
func TestRemovingAProviderFromTheConfigFileRevokesIt(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
startServer(t, store, persistTestStaticIssuer)
|
|
|
|
restarted := startServer(t, store) // the file no longer lists it
|
|
|
|
assert.False(t, listedARNs(t, restarted)[arnOf(t, persistTestStaticIssuer)])
|
|
assert.False(t, stsKnowsIssuer(t, restarted, persistTestStaticIssuer), "a provider removed from the config file is still trusted")
|
|
}
|
|
|
|
// The API cannot change a provider the config file defines, nor create one
|
|
// with its ARN; the file is where it changes.
|
|
func TestConfigFileProvidersCannotBeChangedThroughTheAPI(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store, persistTestStaticIssuer)
|
|
ctx := context.Background()
|
|
arn := arnOf(t, persistTestStaticIssuer)
|
|
|
|
for name, err := range map[string]error{
|
|
"add client ID": mgr.AddClientIDToOIDCProvider(ctx, arn, "other"),
|
|
"remove client ID": mgr.RemoveClientIDFromOIDCProvider(ctx, arn, "aud"),
|
|
"update thumbprint": mgr.UpdateOIDCProviderThumbprints(ctx, arn, []string{"9e99a48a9960b14926bb7f3b02e22da2b0ab7280"}),
|
|
"tag": mgr.TagOIDCProvider(ctx, arn, map[string]string{"k": "v"}),
|
|
"untag": mgr.UntagOIDCProvider(ctx, arn, []string{"k"}),
|
|
"delete": mgr.DeleteOIDCProvider(ctx, arn),
|
|
} {
|
|
assert.ErrorIs(t, err, ErrOIDCProviderStatic, name)
|
|
}
|
|
err := mgr.CreateOIDCProvider(ctx, &OIDCProviderRecord{ARN: arn, URL: persistTestStaticIssuer, ClientIDs: []string{"aud"}})
|
|
assert.ErrorIs(t, err, ErrOIDCProviderAlreadyExists)
|
|
assert.Empty(t, storedARNs(t, store), "a refused change still wrote to the store")
|
|
}
|
|
|
|
// Providers created through the API on an earlier boot, or on a peer, are
|
|
// trusted at startup rather than after the next change.
|
|
func TestStartupLoadsStoredProvidersIntoSTS(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
createAPIProvider(t, startServer(t, store), persistTestAPIIssuer)
|
|
|
|
restarted := startServer(t, store)
|
|
assert.True(t, stsKnowsIssuer(t, restarted, persistTestAPIIssuer))
|
|
}
|
|
|
|
// An in-memory store keeps its behaviour: the config file's providers are
|
|
// records in it, as before.
|
|
func TestInMemoryStoreStillHoldsConfigFileProviders(t *testing.T) {
|
|
store := NewMemoryOIDCProviderStore()
|
|
startServer(t, store, persistTestStaticIssuer)
|
|
assert.True(t, storedARNs(t, store)[arnOf(t, persistTestStaticIssuer)])
|
|
}
|
|
|
|
// unreachableThenReadyStore fails its first reads, as a filer that is not up
|
|
// yet when the S3 server starts.
|
|
type unreachableThenReadyStore struct {
|
|
*MemoryOIDCProviderStore
|
|
mu sync.Mutex
|
|
failsLeft int
|
|
}
|
|
|
|
func (s *unreachableThenReadyStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
|
|
s.mu.Lock()
|
|
if s.failsLeft > 0 {
|
|
s.failsLeft--
|
|
s.mu.Unlock()
|
|
return nil, errors.New("filer unavailable")
|
|
}
|
|
s.mu.Unlock()
|
|
return s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
|
|
}
|
|
|
|
// A store that cannot be read at startup is retried: the metadata
|
|
// subscription only reports later changes, so without a retry the providers
|
|
// already stored would stay unknown to STS.
|
|
func TestStartupLoadRetriesUntilTheStoreIsReadable(t *testing.T) {
|
|
saved := oidcHydrateRetry
|
|
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 5*time.Millisecond
|
|
t.Cleanup(func() { oidcHydrateRetry = saved })
|
|
|
|
seeded := NewMemoryOIDCProviderStore()
|
|
require.NoError(t, seeded.StoreProvider(context.Background(), "", &OIDCProviderRecord{
|
|
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
|
|
}))
|
|
mgr := startServer(t, &unreachableThenReadyStore{MemoryOIDCProviderStore: seeded, failsLeft: 3})
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for !stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("STS never learned the stored provider after the store became readable")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// A provider stored under a config-file provider's ARN takes precedence, as it
|
|
// does in STS; the API then changes the stored one, and deleting it brings the
|
|
// config-file provider back.
|
|
func TestStoredProviderTakesPrecedenceOverTheConfigFileOne(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
configured := startServer(t, store, persistTestStaticIssuer)
|
|
peer := startServer(t, store)
|
|
arn := arnOf(t, persistTestStaticIssuer)
|
|
require.NoError(t, peer.CreateOIDCProvider(ctx, &OIDCProviderRecord{ARN: arn, URL: persistTestStaticIssuer, ClientIDs: []string{"stored"}}))
|
|
|
|
rec, err := configured.GetOIDCProvider(ctx, arn)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"stored"}, rec.ClientIDs, "the config-file provider hides the stored one")
|
|
assert.NoError(t, configured.AddClientIDToOIDCProvider(ctx, arn, "more"), "the stored provider cannot be changed")
|
|
|
|
require.NoError(t, configured.DeleteOIDCProvider(ctx, arn))
|
|
rec, err = configured.GetOIDCProvider(ctx, arn)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"aud"}, rec.ClientIDs, "deleting the stored provider did not bring the config-file one back")
|
|
}
|
|
|
|
// A store installed through SetOIDCProviderStore behaves like one installed
|
|
// at startup: stored providers load into STS and config-file providers stay
|
|
// visible to the IAM API.
|
|
func TestSetOIDCProviderStoreInstallsLikeStartup(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
require.NoError(t, store.StoreProvider(context.Background(), "", &OIDCProviderRecord{
|
|
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
|
|
}))
|
|
|
|
cfg := persistTestConfig(persistTestStaticIssuer)
|
|
mgr := NewIAMManager()
|
|
require.NoError(t, mgr.Initialize(cfg, func() string { return "localhost:8888" }))
|
|
mgr.SetOIDCProviderStore(store)
|
|
|
|
assert.True(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "a stored provider was not trusted after install")
|
|
assert.True(t, listedARNs(t, mgr)[arnOf(t, persistTestStaticIssuer)], "the IAM API no longer lists the config-file provider")
|
|
assert.ErrorIs(t, mgr.DeleteOIDCProvider(context.Background(), arnOf(t, persistTestStaticIssuer)), ErrOIDCProviderStatic)
|
|
}
|
|
|
|
// A config-file provider reports no creation time: a time taken at startup
|
|
// would change with every restart.
|
|
func TestConfigFileProvidersReportNoCreationTime(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store, persistTestStaticIssuer)
|
|
rec, err := mgr.GetOIDCProvider(context.Background(), arnOf(t, persistTestStaticIssuer))
|
|
require.NoError(t, err)
|
|
assert.True(t, rec.CreatedAt.IsZero())
|
|
}
|
|
|
|
// countingUnreadableStore never becomes readable and counts the attempts.
|
|
type countingUnreadableStore struct {
|
|
*MemoryOIDCProviderStore
|
|
mu sync.Mutex
|
|
reads int
|
|
}
|
|
|
|
func (s *countingUnreadableStore) ListProviders(context.Context, string) ([]*OIDCProviderRecord, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.reads++
|
|
return nil, errors.New("filer unavailable")
|
|
}
|
|
|
|
func (s *countingUnreadableStore) readCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.reads
|
|
}
|
|
|
|
// Installing another store stops the previous one's startup retry.
|
|
func TestInstallingAnotherStoreStopsThePreviousRetry(t *testing.T) {
|
|
saved := oidcHydrateRetry
|
|
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, time.Millisecond
|
|
t.Cleanup(func() { oidcHydrateRetry = saved })
|
|
|
|
first := &countingUnreadableStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, first)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for first.readCount() <= 1 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("precondition: the first store is never retried")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
mgr.installOIDCProviderStore(&persistentTestStore{NewMemoryOIDCProviderStore()}, persistTestConfig().STS)
|
|
time.Sleep(10 * time.Millisecond) // let an in-flight attempt finish
|
|
settled := first.readCount()
|
|
time.Sleep(30 * time.Millisecond)
|
|
assert.Equal(t, settled, first.readCount(), "the superseded store is still being retried")
|
|
}
|
|
|
|
// blockingListStore blocks the first ListProviders call after arm, having
|
|
// already read its snapshot, until release is closed.
|
|
type blockingListStore struct {
|
|
*MemoryOIDCProviderStore
|
|
mu sync.Mutex
|
|
armed bool
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func (s *blockingListStore) arm() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.armed, s.entered, s.release = true, make(chan struct{}), make(chan struct{})
|
|
}
|
|
|
|
func (s *blockingListStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
|
|
records, err := s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
|
|
s.mu.Lock()
|
|
block := s.armed
|
|
s.armed = false
|
|
s.mu.Unlock()
|
|
if block {
|
|
close(s.entered)
|
|
<-s.release
|
|
}
|
|
return records, err
|
|
}
|
|
|
|
// A refresh that read the store before a provider was deleted cannot leave the
|
|
// deleted provider trusted by finishing after the deletion's own refresh.
|
|
func TestAnOlderRefreshCannotRestoreADeletedProvider(t *testing.T) {
|
|
store := &blockingListStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store)
|
|
createAPIProvider(t, mgr, persistTestAPIIssuer)
|
|
require.True(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "precondition: the provider is trusted")
|
|
|
|
store.arm()
|
|
stale := make(chan struct{})
|
|
go func() {
|
|
defer close(stale)
|
|
_ = mgr.RefreshOIDCProvidersFromStore(context.Background())
|
|
}()
|
|
<-store.entered // the stale refresh holds a snapshot with the provider
|
|
|
|
deleted := make(chan error, 1)
|
|
go func() { deleted <- mgr.DeleteOIDCProvider(context.Background(), arnOf(t, persistTestAPIIssuer)) }()
|
|
// Serialized, the deletion's refresh waits for the stale one; otherwise
|
|
// let it finish first, which is the ordering that went wrong.
|
|
var deleteErr error
|
|
select {
|
|
case deleteErr = <-deleted:
|
|
deleted = nil
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
close(store.release)
|
|
<-stale
|
|
if deleted != nil {
|
|
deleteErr = <-deleted
|
|
}
|
|
require.NoError(t, deleteErr)
|
|
assert.False(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "a refresh older than the deletion left the deleted provider trusted")
|
|
}
|
|
|
|
// A refresh that fails is retried until the store answers. A change event reports each mutation once, so
|
|
// a subscription refresh that found the filer unreachable — mid-restart, say — left the provider set stale
|
|
// until some unrelated later change; a peer's new provider stayed untrusted and a deleted one trusted.
|
|
func TestAFailedRefreshIsRetriedUntilTheStoreAnswers(t *testing.T) {
|
|
saved := oidcHydrateRetry
|
|
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 5*time.Millisecond
|
|
t.Cleanup(func() { oidcHydrateRetry = saved })
|
|
|
|
store := &unreachableThenReadyStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store)
|
|
require.NoError(t, store.StoreProvider(context.Background(), "", &OIDCProviderRecord{
|
|
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
|
|
}))
|
|
store.mu.Lock()
|
|
store.failsLeft = 3
|
|
store.mu.Unlock()
|
|
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()), "precondition: the refresh fails")
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for !stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("a failed refresh was never retried: the stored provider stays untrusted until an unrelated change")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// Failures during an outage start ONE retry, not one per event: a filer that is down for a while produces a
|
|
// change event per mutation, and each would otherwise add a goroutine polling the same store. Once the store
|
|
// answers and the retry ends, a later failure starts a new one.
|
|
func TestFailedRefreshesShareOneRetry(t *testing.T) {
|
|
saved := oidcHydrateRetry
|
|
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, time.Millisecond
|
|
t.Cleanup(func() { oidcHydrateRetry = saved })
|
|
|
|
store := &unreachableThenReadyStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store)
|
|
store.mu.Lock()
|
|
store.failsLeft = 1 << 30
|
|
store.mu.Unlock()
|
|
for range 20 {
|
|
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()))
|
|
}
|
|
mgr.oidcRetryMu.Lock()
|
|
started := mgr.oidcRetrySeq
|
|
mgr.oidcRetryMu.Unlock()
|
|
assert.Equal(t, uint64(1), started, "twenty failed refreshes started more than one retry")
|
|
|
|
store.mu.Lock()
|
|
store.failsLeft = 0
|
|
store.mu.Unlock()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
mgr.oidcRetryMu.Lock()
|
|
running := mgr.oidcRetryGen != 0
|
|
mgr.oidcRetryMu.Unlock()
|
|
if !running {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the retry never ended after the store answered")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
store.mu.Lock()
|
|
store.failsLeft = 1
|
|
store.mu.Unlock()
|
|
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()))
|
|
mgr.oidcRetryMu.Lock()
|
|
assert.Equal(t, uint64(2), mgr.oidcRetrySeq, "a failure after the retry ended starts a new one")
|
|
mgr.oidcRetryMu.Unlock()
|
|
}
|
|
|
|
// flakyCountingStore fails while fail is set and counts the lists that succeed.
|
|
type flakyCountingStore struct {
|
|
*MemoryOIDCProviderStore
|
|
mu sync.Mutex
|
|
fail bool
|
|
ok int
|
|
}
|
|
|
|
func (s *flakyCountingStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
|
|
s.mu.Lock()
|
|
if s.fail {
|
|
s.mu.Unlock()
|
|
return nil, errors.New("filer unavailable")
|
|
}
|
|
s.ok++
|
|
s.mu.Unlock()
|
|
return s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
|
|
}
|
|
|
|
func (s *flakyCountingStore) set(fail bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.fail = fail
|
|
}
|
|
|
|
func (s *flakyCountingStore) successes() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.ok
|
|
}
|
|
|
|
// A refresh of store A that fails as store B is installed must not start a retry for A: nothing would cancel
|
|
// it, and when A answered it would replace B's providers (a removed one trusted again, B's own untrusted).
|
|
func TestAFailedRefreshOfASupersededStoreStartsNoRetry(t *testing.T) {
|
|
a := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, a)
|
|
mgr.SetOIDCProviderStore(&persistentTestStore{NewMemoryOIDCProviderStore()})
|
|
mgr.oidcRetryMu.Lock()
|
|
before := mgr.oidcRetrySeq
|
|
mgr.oidcRetryMu.Unlock()
|
|
|
|
mgr.startOIDCRetry(a) // the late call of A's failed refresh, after B's install
|
|
|
|
mgr.oidcRetryMu.Lock()
|
|
defer mgr.oidcRetryMu.Unlock()
|
|
assert.Equal(t, before, mgr.oidcRetrySeq, "a retry was started for the store that was replaced")
|
|
assert.Zero(t, mgr.oidcRetryGen)
|
|
}
|
|
|
|
// A refresh that listed store A before B was installed must not hand STS A's providers afterwards.
|
|
func TestASupersededSnapshotIsNotApplied(t *testing.T) {
|
|
a := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
require.NoError(t, a.StoreProvider(context.Background(), "", &OIDCProviderRecord{
|
|
ARN: arnOf(t, persistTestAPIIssuer), URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
|
|
}))
|
|
b := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
require.NoError(t, b.StoreProvider(context.Background(), "", &OIDCProviderRecord{
|
|
ARN: arnOf(t, persistTestStaticIssuer), URL: persistTestStaticIssuer, ClientIDs: []string{"aud"},
|
|
}))
|
|
mgr := startServer(t, a)
|
|
mgr.SetOIDCProviderStore(b)
|
|
|
|
assert.Error(t, mgr.refreshOIDCProvidersFrom(context.Background(), a), "a snapshot of the replaced store was applied")
|
|
assert.False(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer), "the replaced store's provider is trusted")
|
|
assert.True(t, stsKnowsIssuer(t, mgr, persistTestStaticIssuer), "the current store's provider is not")
|
|
}
|
|
|
|
// A refresh that fails while a retry runs is not dropped: the retry may already have listed an older snapshot,
|
|
// so after its success it runs once more and picks up whatever the failed refresh would have seen.
|
|
func TestAFailureDuringARetryIsNotDropped(t *testing.T) {
|
|
saved := oidcHydrateRetry
|
|
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 2*time.Millisecond
|
|
t.Cleanup(func() { oidcHydrateRetry = saved })
|
|
|
|
store := &flakyCountingStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore(), fail: true}
|
|
mgr := startServer(t, store) // the startup load fails: a retry is running
|
|
require.Error(t, mgr.RefreshOIDCProvidersFromStore(context.Background()), "a later refresh fails while it runs")
|
|
store.set(false)
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
mgr.oidcRetryMu.Lock()
|
|
running := mgr.oidcRetryGen != 0
|
|
mgr.oidcRetryMu.Unlock()
|
|
if !running {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the retry never ended")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
assert.GreaterOrEqual(t, store.successes(), 2, "the retry ended on its first success, dropping the refresh that failed while it ran")
|
|
}
|
|
|
|
// A refresh caused by an unrelated record must not rebuild a provider whose
|
|
// own record is unchanged: rebuilding drops its JWKS cache, so a briefly
|
|
// unreachable issuer would fail exchanges its cached keys still allow.
|
|
func TestAnUnrelatedRefreshKeepsAProvidersRuntimeState(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store)
|
|
arn := arnOf(t, persistTestAPIIssuer)
|
|
createAPIProvider(t, mgr, persistTestAPIIssuer)
|
|
|
|
mgr.oidcRefreshMu.Lock()
|
|
before := mgr.oidcManagedProviders[arn]
|
|
mgr.oidcRefreshMu.Unlock()
|
|
require.NotNil(t, before)
|
|
|
|
createAPIProvider(t, mgr, persistTestStaticIssuer) // refreshes over an unrelated record
|
|
|
|
mgr.oidcRefreshMu.Lock()
|
|
after := mgr.oidcManagedProviders[arn]
|
|
mgr.oidcRefreshMu.Unlock()
|
|
require.NotNil(t, after)
|
|
assert.Same(t, before.provider, after.provider, "an unchanged record's provider was rebuilt")
|
|
}
|
|
|
|
// A record whose validation settings change must get a new provider: the
|
|
// cached runtime cannot keep serving settings the store no longer holds.
|
|
func TestAChangedRecordRebuildsItsProvider(t *testing.T) {
|
|
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
|
|
mgr := startServer(t, store)
|
|
arn := arnOf(t, persistTestAPIIssuer)
|
|
createAPIProvider(t, mgr, persistTestAPIIssuer)
|
|
|
|
mgr.oidcRefreshMu.Lock()
|
|
before := mgr.oidcManagedProviders[arn].provider
|
|
mgr.oidcRefreshMu.Unlock()
|
|
|
|
require.NoError(t, mgr.UpdateOIDCProviderThumbprints(context.Background(), arn,
|
|
[]string{"0123456789abcdef0123456789abcdef01234567"}))
|
|
|
|
mgr.oidcRefreshMu.Lock()
|
|
after := mgr.oidcManagedProviders[arn]
|
|
mgr.oidcRefreshMu.Unlock()
|
|
require.NotNil(t, after)
|
|
assert.NotSame(t, before, after.provider, "a changed record kept its provider")
|
|
}
|