iam/oidc: drop single-use web-identity tokens and the jti replay cache (#12065)

* 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.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-10-25 12:04:59 -07:00
1 parent 515380c349
commit 6dd6ad512c
13 files changed
+110 -1944

No files matched your search

+6 -74
View File
@@ -53,12 +53,9 @@ type IAMManager struct {
oidcRetryAgain bool
// oidcRefreshMu serializes refreshes from reading the store to handing
// STS the result, so an older snapshot cannot replace a newer one.
oidcRefreshMu sync.Mutex
// oidcManagedProviders caches providers refreshOIDCProvidersFrom built,
// keyed by record identity, so an unchanged record reuses its provider:
// rebuilding would drop the provider's consumed-token (jti) replay cache
// and let an already-used token be presented again. Guarded by
// oidcRefreshMu.
// oidcManagedProviders caches the provider built per stored record so a
// refresh reuses unchanged providers and keeps their JWKS cache.
oidcRefreshMu sync.Mutex
oidcManagedProviders map[string]*oidcManagedProvider
oidcAuditSink OIDCProviderAuditSink
revocationStore SessionRevocationStore
@@ -953,10 +950,6 @@ func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCPro
m.oidcManagedProviders = make(map[string]*oidcManagedProvider)
}
seen := make(map[string]struct{}, len(records))
// Records for one issuer with identical validation settings share a single
// provider — and with it the consumed-token (jti) replay store — so a token
// spent through one account's record stays spent through the other's too.
sharedProviders := make(map[string]*oidc.OIDCProvider)
for _, rec := range records {
if rec == nil || rec.URL == "" {
continue
@@ -967,25 +960,10 @@ func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCPro
}
seen[key] = struct{}{}
fingerprint := oidcProviderFingerprint(rec)
configFingerprint := oidcProviderConfigFingerprint(rec)
var provider *oidc.OIDCProvider
cached := m.oidcManagedProviders[key]
if shared, ok := sharedProviders[configFingerprint]; ok {
provider = shared
if cached != nil && cached.provider != nil && cached.provider != shared {
// The record's own provider is superseded; its consumed
// tokens move to the shared store so none reopen for replay.
shared.MergeReplayStateFrom(cached.provider)
}
m.oidcManagedProviders[key] = &oidcManagedProvider{fingerprint: fingerprint, provider: provider}
if cached != nil && cached.provider != nil && cached.provider != shared && !m.oidcProviderStillReferenced(cached.provider) {
if err := cached.provider.Shutdown(ctx); err != nil {
glog.Warningf("shutdown superseded OIDC provider %s: %v", rec.ARN, err)
}
}
} else if cached != nil && cached.fingerprint == fingerprint {
var provider *oidc.OIDCProvider
if cached != nil && cached.fingerprint == fingerprint {
provider = cached.provider
sharedProviders[configFingerprint] = provider
} else {
var err error
provider, err = buildOIDCProviderFromRecord(rec)
@@ -994,20 +972,7 @@ func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCPro
glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err)
continue
}
if cached != nil && cached.provider != nil {
// The rebuild must keep rejecting tokens the replaced
// provider consumed; carry its replay store over, then stop
// its cleanup worker so only the new one sweeps the store —
// unless another record still references that provider.
provider.AdoptReplayStateFrom(cached.provider)
}
sharedProviders[configFingerprint] = provider
m.oidcManagedProviders[key] = &oidcManagedProvider{fingerprint: fingerprint, provider: provider}
if cached != nil && cached.provider != nil && !m.oidcProviderStillReferenced(cached.provider) {
if err := cached.provider.Shutdown(ctx); err != nil {
glog.Warningf("shutdown replaced OIDC provider %s: %v", rec.ARN, err)
}
}
}
// Multiple records may share an issuer when each is scoped to a
// different account; STS picks the right one at validation time
@@ -1017,16 +982,9 @@ func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCPro
Provider: provider,
})
}
for key, managed := range m.oidcManagedProviders {
for key := range m.oidcManagedProviders {
if _, ok := seen[key]; !ok {
delete(m.oidcManagedProviders, key)
// A removed record's provider may be shared by records that are
// still present; shut it down only when nothing references it.
if managed.provider != nil && !m.oidcProviderStillReferenced(managed.provider) {
if err := managed.provider.Shutdown(ctx); err != nil {
glog.Warningf("shutdown removed OIDC provider %s: %v", key, err)
}
}
}
}
m.stsService.SetIAMManagedOIDCProviders(byIssuer)
@@ -1040,17 +998,6 @@ type oidcManagedProvider struct {
provider *oidc.OIDCProvider
}
// oidcProviderStillReferenced reports whether any managed record still points
// at the provider, so a shared provider is not shut down with a removed record.
func (m *IAMManager) oidcProviderStillReferenced(provider *oidc.OIDCProvider) bool {
for _, managed := range m.oidcManagedProviders {
if managed.provider == provider {
return true
}
}
return false
}
// oidcProviderFingerprint identifies the record content a provider was built
// from, so a refresh rebuilds the provider only when that content changed.
func oidcProviderFingerprint(rec *OIDCProviderRecord) string {
@@ -1067,21 +1014,6 @@ func oidcProviderFingerprint(rec *OIDCProviderRecord) string {
return hex.EncodeToString(sum[:])
}
// oidcProviderConfigFingerprint identifies the validation settings a provider
// is built from — everything buildOIDCProviderFromRecord consumes except the
// record's name — so records that differ only in identity reuse one provider.
func oidcProviderConfigFingerprint(rec *OIDCProviderRecord) string {
key, _ := json.Marshal(&oidc.OIDCConfig{
Issuer: rec.URL,
ClientIDs: rec.ClientIDs,
Thumbprints: rec.Thumbprints,
AllowedPrincipalTagKeys: rec.AllowedPrincipalTagKeys,
PolicyClaim: rec.PolicyClaim,
})
sum := sha256.Sum256(key)
return hex.EncodeToString(sum[:])
}
// buildOIDCProviderFromRecord turns a stored record into a runtime
// OIDCProvider. The provider name is the ARN so re-registration is
// idempotent and never collides with static-config entries.
@@ -560,92 +560,47 @@ func TestAFailureDuringARetryIsNotDropped(t *testing.T) {
assert.GreaterOrEqual(t, store.successes(), 2, "the retry ended on its first success, dropping the refresh that failed while it ran")
}
// A refresh must not rebuild a provider whose record is unchanged: rebuilding
// would drop the provider's consumed-token (jti) replay cache and let an
// already-used token be presented again.
func TestRefreshReusesProviderWhileRecordIsUnchanged(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
// 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)
require.NoError(t, store.StoreProvider(ctx, "", &OIDCProviderRecord{
ARN: arn, URL: persistTestAPIIssuer, ClientIDs: []string{"aud"},
}))
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
first := mgr.oidcManagedProviders[arn].provider
require.NotNil(t, first)
createAPIProvider(t, mgr, persistTestAPIIssuer)
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
assert.Same(t, first, mgr.oidcManagedProviders[arn].provider,
"an unchanged record was rebuilt, dropping its replay cache")
mgr.oidcRefreshMu.Lock()
before := mgr.oidcManagedProviders[arn]
mgr.oidcRefreshMu.Unlock()
require.NotNil(t, before)
// A changed record must rebuild so the new configuration applies.
require.NoError(t, store.StoreProvider(ctx, "", &OIDCProviderRecord{
ARN: arn, URL: persistTestAPIIssuer, ClientIDs: []string{"aud", "aud2"},
}))
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
rebuilt := mgr.oidcManagedProviders[arn].provider
require.NotNil(t, rebuilt)
assert.NotSame(t, first, rebuilt, "a changed record kept the old provider")
createAPIProvider(t, mgr, persistTestStaticIssuer) // refreshes over an unrelated record
// Deleting the record drops the provider from the cache and the STS map.
require.NoError(t, store.DeleteProvider(ctx, "", arn))
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
assert.NotContains(t, mgr.oidcManagedProviders, arn)
assert.False(t, stsKnowsIssuer(t, mgr, persistTestAPIIssuer))
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")
}
// stsKnowsIssuerForAccount is stsKnowsIssuer through the account-scoped
// lookup used for providers that have no global record.
func stsKnowsIssuerForAccount(t *testing.T, mgr *IAMManager, issuer, accountID 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().ValidateWebIdentityTokenForAccount(context.Background(), tok, accountID)
require.Error(t, err, "an unsigned-by-provider token for %s was accepted", issuer)
return !strings.Contains(err.Error(), "no identity provider registered")
}
// Two records for one issuer with identical validation settings share a
// single provider — and with it the consumed-token (jti) replay store — so a
// token spent through one account's record stays spent through the other's.
// Removing one record of the pair must not shut the shared provider down.
func TestSameIssuerRecordsShareOneProvider(t *testing.T) {
ctx := context.Background()
store := NewMemoryOIDCProviderStore()
// 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)
arnA := arnOf(t, persistTestAPIIssuer) + "-account-a"
arnB := arnOf(t, persistTestAPIIssuer) + "-account-b"
for _, rec := range []*OIDCProviderRecord{
{ARN: arnA, URL: persistTestAPIIssuer, AccountID: "111111111111", ClientIDs: []string{"aud"}},
{ARN: arnB, URL: persistTestAPIIssuer, AccountID: "222222222222", ClientIDs: []string{"aud"}},
} {
require.NoError(t, store.StoreProvider(ctx, "", rec))
}
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
mgr.oidcRefreshMu.Lock()
before := mgr.oidcManagedProviders[arn].provider
mgr.oidcRefreshMu.Unlock()
shared := mgr.oidcManagedProviders[arnA].provider
require.NotNil(t, shared)
assert.Same(t, shared, mgr.oidcManagedProviders[arnB].provider,
"identical records for one issuer built separate replay stores")
require.NoError(t, mgr.UpdateOIDCProviderThumbprints(context.Background(), arn,
[]string{"0123456789abcdef0123456789abcdef01234567"}))
// Removing one record of the pair keeps the shared provider alive.
require.NoError(t, store.DeleteProvider(ctx, "", arnA))
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
assert.Same(t, shared, mgr.oidcManagedProviders[arnB].provider,
"a removed sibling record shut down the shared provider")
assert.True(t, stsKnowsIssuerForAccount(t, mgr, persistTestAPIIssuer, "222222222222"))
// The remaining record diverging rebuilds its own provider.
require.NoError(t, store.StoreProvider(ctx, "", &OIDCProviderRecord{
ARN: arnB, URL: persistTestAPIIssuer, AccountID: "222222222222", ClientIDs: []string{"b-aud"},
}))
require.NoError(t, mgr.refreshOIDCProvidersFrom(ctx, store))
assert.NotSame(t, shared, mgr.oidcManagedProviders[arnB].provider,
"a changed record kept the shared provider")
assert.True(t, stsKnowsIssuerForAccount(t, mgr, persistTestAPIIssuer, "222222222222"))
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")
}
-410
View File
@@ -1,410 +0,0 @@
package oidc
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
)
const (
// clockSkewTolerance matches the typical JWT library clock skew allowance (RFC 7519)
clockSkewTolerance = 5 * time.Minute
// defaultJTICacheSize is the default maximum number of JTI entries to cache
defaultJTICacheSize = 10000
// defaultCleanupInterval is how often to scan for expired JTI entries
defaultCleanupInterval = 5 * time.Minute
// eagerCleanupMinInterval rate limits opportunistic sweeps near capacity
eagerCleanupMinInterval = 5 * time.Second
// tokenFingerprintPrefix marks replay-cache keys derived from the token's
// own fingerprint, used when the token carries no usable jti claim
tokenFingerprintPrefix = "fp:"
)
// jtiEntry represents a cached JTI with metadata
type jtiEntry struct {
subject string
expiresAt time.Time
// uncounted marks an entry merged in past the capacity bound: it keeps
// rejecting replays but does not charge the count, so merged excess can
// neither reject new exchanges nor be double-counted when swept.
uncounted bool
}
// jtiReplayCache wraps the consumed-token store that backs JTI replay
// protection. Each consumed token identifier is held until the token's exp
// plus clock skew, and a second presentation of the same identifier is
// rejected with ErrProviderTokenReplayed.
//
// The cache is bounded by maxSize and uses automatic TTL-based expiration:
// a background worker sweeps expired entries, an eager sweep runs when the
// cache nears capacity, and the consume path reclaims expired slots before
// rejecting a fresh token for capacity.
//
// Thread Safety:
// All methods are safe for concurrent use. The consume path uses atomic
// LoadOrStore to prevent TOCTOU races.
type jtiReplayCache struct {
name string // owning provider name, for log messages
enabled atomic.Bool
maxSize atomic.Int64
lastSweep atomic.Int64 // unix nano of the last sweep; rate limits eager cleanups
// storeMu guards the store/count pair: replay-state handoffs (adoptFrom,
// mergeFrom) repoint them under the write lock while the consume path
// holds a read lock, so a consume can never land in a store the handoff
// already drained and abandoned.
// It is always acquired before sweepMu, never while holding it.
storeMu sync.RWMutex
store *sync.Map // map[string]*jtiEntry - atomic storage for consumed token keys
count *atomic.Int64 // entries in store; shared with the store
// mu guards the lifecycle of the cleanup worker: cleanupCancel and
// cleanupDone. The worker itself only touches store/count, so holding
// mu while waiting for it is safe.
mu sync.Mutex
cleanupCancel context.CancelFunc
cleanupDone chan struct{} // Closed when cleanup goroutine exits
// sweepMu serializes cache sweeps so at most one scan runs at a time.
sweepMu sync.Mutex
}
func newJTIReplayCache(name string) *jtiReplayCache {
return &jtiReplayCache{
name: name,
store: &sync.Map{},
count: &atomic.Int64{},
}
}
// configure applies the replay-protection settings: a previous worker is
// always stopped, whether the new configuration enables protection or
// disables it. The consumed-token store survives re-configuration: resetting
// it would let already-seen tokens be replayed against the refreshed provider.
func (c *jtiReplayCache) configure(enabled bool, maxSize int64) {
c.mu.Lock()
c.stopWorkerLocked()
if enabled {
if maxSize <= 0 {
maxSize = defaultJTICacheSize
}
// the bound must land before the flag: a consume that sees enabled
// with a zero bound would reject the first token it registers
c.maxSize.Store(maxSize)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
c.cleanupCancel = cancel
c.cleanupDone = done
c.enabled.Store(true)
c.mu.Unlock()
go c.cleanupLoop(ctx, done, defaultCleanupInterval)
glog.V(2).Infof("OIDC provider %q: JTI replay protection enabled (max size: %d)", c.name, maxSize)
} else {
c.enabled.Store(false)
c.mu.Unlock()
glog.Warningf("OIDC provider %q: JTI replay protection is DISABLED", c.name)
}
}
// stopWorkerLocked cancels the current cleanup goroutine and waits briefly
// for it to exit. Caller must hold mu.
func (c *jtiReplayCache) stopWorkerLocked() {
if c.cleanupCancel == nil {
return
}
c.cleanupCancel()
select {
case <-c.cleanupDone:
glog.V(3).Infof("OIDC provider %q: JTI cleanup goroutine exited", c.name)
case <-time.After(2 * time.Second):
glog.Warningf("OIDC provider %q: Timeout waiting for JTI cleanup goroutine", c.name)
}
c.cleanupCancel = nil
c.cleanupDone = nil
}
// consume marks a token as used and rejects a second presentation of it.
// jti is the token's jti claim (may be empty); tokenExpiresAt is its exp.
// A no-op while protection is disabled.
func (c *jtiReplayCache) consume(token, jti, subject string, tokenExpiresAt time.Time) error {
if !c.enabled.Load() {
return nil
}
key := jti
if key == "" {
// Providers that issue no jti cannot be tracked by claim, so the
// signed input keys the entry: a repeated presentation of the same
// claims is a replay even when the signature bytes differ (e.g.
// ECDSA s/N-s malleability).
signingInput := token
if i := strings.LastIndexByte(token, '.'); i > 0 {
signingInput = token[:i]
}
sum := sha256.Sum256([]byte(signingInput))
key = tokenFingerprintPrefix + hex.EncodeToString(sum[:])
}
if tokenExpiresAt.IsZero() {
// a cache slot is freed only by expiry, so a token without exp
// would hold it forever and eventually exhaust the bound
return fmt.Errorf("%w: missing exp claim", providers.ErrProviderMissingClaims)
}
expiresAt := tokenExpiresAt.Add(clockSkewTolerance)
if !time.Now().Before(expiresAt) {
return fmt.Errorf("%w: token expired", providers.ErrProviderTokenExpired)
}
entry := &jtiEntry{
subject: subject,
expiresAt: expiresAt,
}
// The read lock pins the store/count pair for the whole test-and-set
// so a replay-state handoff cannot repoint them mid-check and strand
// this consume in an abandoned store.
c.storeMu.RLock()
store, count := c.store, c.count
// Atomic test-and-set detects replays before any capacity work runs,
// so a replayed token cannot trigger cleanup scans.
if _, loaded := store.LoadOrStore(key, entry); loaded {
c.storeMu.RUnlock()
glog.Warningf("OIDC provider %q: Token replay detected (jti: %s)",
c.name, jtiLogKey(key))
return fmt.Errorf("%w: jti %s has already been used",
providers.ErrProviderTokenReplayed, jtiLogKey(key))
}
// The count is incremented as part of the insert, so concurrent
// validations cannot all slip under the limit: whoever pushes the
// count over evicts their own entry and is rejected.
newCount := count.Add(1)
maxSize := c.maxSize.Load()
if newCount > maxSize {
// Over the bound: expired entries may still occupy slots, so
// reclaim them with a synchronized sweep before rejecting.
c.sweepStore(store, count)
if count.Load() > maxSize {
if _, loaded := store.LoadAndDelete(key); loaded {
count.Add(-1)
}
c.storeMu.RUnlock()
glog.Errorf("OIDC provider %q: JTI cache FULL (%d entries) - rejecting token. "+
"This may indicate a DoS attack or insufficient cache size. Consider increasing JTICacheMaxSize.",
c.name, maxSize)
return fmt.Errorf("JTI cache capacity exceeded")
}
} else if newCount >= maxSize*9/10 {
glog.Warningf("OIDC provider %q: JTI cache at %d%% capacity (%d/%d), triggering eager cleanup",
c.name, (newCount*100)/maxSize, newCount, maxSize)
go c.performEagerCleanup()
}
c.storeMu.RUnlock()
glog.V(4).Infof("OIDC provider %q: Registered JTI %s (expires at %v, cache size: %d)",
c.name, jtiLogKey(key), expiresAt, newCount)
return nil
}
// evictExpired removes entries whose expiry has passed and returns how
// many it deleted. Caller must hold sweepMu.
func (c *jtiReplayCache) evictExpired(now time.Time, store *sync.Map, count *atomic.Int64) int {
removed := 0
store.Range(func(key, value interface{}) bool {
expiresAt := value.(*jtiEntry).expiresAt
if !expiresAt.IsZero() && now.After(expiresAt) {
// LoadAndDelete reports whether this call actually removed the
// entry, so concurrent deletes cannot decrement the count twice.
if v, loaded := store.LoadAndDelete(key); loaded {
if !v.(*jtiEntry).uncounted {
count.Add(-1)
}
removed++
}
}
return true
})
return removed
}
// state returns the cache's current store/count pair.
func (c *jtiReplayCache) state() (*sync.Map, *atomic.Int64) {
c.storeMu.RLock()
defer c.storeMu.RUnlock()
return c.store, c.count
}
// sweepStore runs one serialized expiry scan over the given store. It only
// takes sweepMu, so callers already holding storeMu may use it.
func (c *jtiReplayCache) sweepStore(store *sync.Map, count *atomic.Int64) {
c.sweepMu.Lock()
defer c.sweepMu.Unlock()
c.lastSweep.Store(time.Now().UnixNano())
if removed := c.evictExpired(time.Now(), store, count); removed > 0 {
glog.V(4).Infof("OIDC provider %q: Cleaned up %d expired JTI entries (remaining: %d)",
c.name, removed, count.Load())
}
}
// sweepExpired runs a scan that is guaranteed to complete before the
// caller proceeds; the periodic worker uses it so the scan always covers
// the store currently in effect, not one captured earlier.
func (c *jtiReplayCache) sweepExpired() {
store, count := c.state()
c.sweepStore(store, count)
}
// performEagerCleanup performs an opportunistic cleanup of expired JTI
// entries when the cache approaches capacity. At most one sweep runs at a
// time and no more often than eagerCleanupMinInterval, so a burst of
// requests near the limit cannot each turn into a full-cache scan.
func (c *jtiReplayCache) performEagerCleanup() {
if !c.enabled.Load() {
return
}
if time.Since(time.Unix(0, c.lastSweep.Load())) < eagerCleanupMinInterval {
return
}
store, count := c.state()
if !c.sweepMu.TryLock() {
return
}
defer c.sweepMu.Unlock()
c.lastSweep.Store(time.Now().UnixNano())
beforeCount := count.Load()
if removed := c.evictExpired(time.Now(), store, count); removed > 0 {
glog.Infof("OIDC provider %q: Eager cleanup removed %d expired JTI entries (%d -> %d)",
c.name, removed, beforeCount, count.Load())
} else {
glog.Warningf("OIDC provider %q: Eager cleanup found no expired entries to remove. "+
"Cache may be filled with valid long-lived tokens. Consider increasing JTICacheMaxSize.",
c.name)
}
}
// cleanupLoop periodically removes expired JTI entries from the cache.
// This prevents memory growth by removing JTIs after their tokens have
// expired. Runs until ctx is cancelled; done is closed on exit.
func (c *jtiReplayCache) cleanupLoop(ctx context.Context, done chan struct{}, interval time.Duration) {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
glog.V(3).Infof("OIDC provider %q: JTI cleanup goroutine started (interval: %v)", c.name, interval)
for {
select {
case <-ctx.Done():
glog.V(3).Infof("OIDC provider %q: JTI cleanup goroutine stopping", c.name)
return
case <-ticker.C:
c.sweepExpired()
}
}
}
// stop gracefully stops the cleanup worker.
// Should be called when the provider is being shut down to prevent goroutine
// leaks.
func (c *jtiReplayCache) stop(ctx context.Context) error {
c.mu.Lock()
cancel, done := c.cleanupCancel, c.cleanupDone
c.mu.Unlock()
if cancel == nil {
return nil
}
cancel()
select {
case <-done:
glog.V(2).Infof("OIDC provider %q: Cleanup goroutine shutdown complete", c.name)
case <-ctx.Done():
glog.Warningf("OIDC provider %q: Shutdown timeout waiting for cleanup goroutine: %v", c.name, ctx.Err())
return ctx.Err()
}
c.mu.Lock()
if c.cleanupDone == done {
c.cleanupCancel = nil
c.cleanupDone = nil
}
c.mu.Unlock()
return nil
}
// adoptFrom takes over the consumed-token store of another cache, so tokens
// it already saw stay rejected across the rebuild. The caller must shut down
// the replaced provider; only one cleanup worker may sweep the shared store
// at a time. Call before the new provider serves validators; sweepMu
// excludes its own cleanup worker during the swap.
func (c *jtiReplayCache) adoptFrom(old *jtiReplayCache) {
if old == nil || old == c {
return
}
c.storeMu.Lock()
c.sweepMu.Lock()
c.store, c.count = old.store, old.count
c.sweepMu.Unlock()
c.storeMu.Unlock()
}
// mergeFrom hands this cache's store to another cache and then folds the
// other's consumed-token entries in, so rejections the other recorded keep
// holding when its records move to a shared provider. Repointing first means
// tokens the other still consumes — in-flight exchanges or ones routed to it
// before the provider map updates — land in the shared store instead of a
// store this merge already drained. Unlike adoptFrom it swaps nothing of its
// own, so it is safe on a cache that is already serving validators.
func (c *jtiReplayCache) mergeFrom(other *jtiReplayCache) {
if other == nil || other == c {
return
}
c.storeMu.RLock()
defer c.storeMu.RUnlock()
store, count := c.store, c.count
other.storeMu.Lock()
oldStore := other.store
if oldStore == store {
other.storeMu.Unlock()
return
}
other.store, other.count = store, count
other.storeMu.Unlock()
c.sweepMu.Lock()
defer c.sweepMu.Unlock()
now := time.Now()
oldStore.Range(func(key, value interface{}) bool {
entry := value.(*jtiEntry)
if expiresAt := entry.expiresAt; !expiresAt.IsZero() && now.After(expiresAt) {
return true
}
// A consumed token is never dropped to fit the bound: merged entries
// stay but do not charge the count, so a store filled past the bound
// by the merge cannot reject new exchanges while it drains by expiry.
store.LoadOrStore(key, &jtiEntry{subject: entry.subject, expiresAt: entry.expiresAt, uncounted: true})
return true
})
}
// jtiLogKey redacts a token identifier for logs and error strings: a stable
// fingerprint that still lets operators correlate repeated presentations.
func jtiLogKey(jti string) string {
sum := sha256.Sum256([]byte(jti))
return hex.EncodeToString(sum[:8])
}
-11
View File
@@ -75,17 +75,6 @@ func (m *MockOIDCProvider) Authenticate(ctx context.Context, token string) (*pro
return identity, nil
}
// AuthenticateSingleUse stays on the mock path: the embedded provider's
// method would bypass the mock's test data and demand a real JWKS.
func (m *MockOIDCProvider) AuthenticateSingleUse(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
return m.Authenticate(ctx, token)
}
// ValidateTokenSingleUse stays on the mock path; see AuthenticateSingleUse.
func (m *MockOIDCProvider) ValidateTokenSingleUse(ctx context.Context, token string) (*providers.TokenClaims, error) {
return m.ValidateToken(ctx, token)
}
// ValidateToken validates tokens using test data
func (m *MockOIDCProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
if !m.initialized {
+8 -120
View File
@@ -27,20 +27,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/security"
)
// OIDCProvider implements OpenID Connect authentication with JTI replay protection.
//
// Replay Protection:
// The provider validates the 'jti' (JWT ID) claim to prevent token replay attacks.
// Each JTI is stored in memory until the token expires. If a token with a duplicate
// JTI is presented, it is rejected with ErrProviderTokenReplayed.
//
// The JTI cache is bounded by JTICacheMaxSize and uses automatic TTL-based expiration.
// A token without a usable jti claim is tracked by the token's own fingerprint
// instead, so a repeated presentation is still detected as a replay.
//
// Thread Safety:
// All methods are safe for concurrent use. The JTI cache uses atomic LoadOrStore
// operations to prevent TOCTOU race conditions.
// OIDCProvider implements OpenID Connect authentication
type OIDCProvider struct {
name string
config *OIDCConfig
@@ -57,25 +44,6 @@ type OIDCProvider struct {
jwksFetchedAt time.Time
resolvedJWKSUri string
discoveryFailed bool
// jti wraps the consumed-token cache behind the replay protection. It is
// allocated by NewOIDCProvider or lazily by replayCache, and survives
// re-initialization, so a config refresh cannot reopen already-consumed
// tokens for replay. jtiOnce makes a zero-value OIDCProvider safe.
jti *jtiReplayCache
jtiOnce sync.Once
}
// replayCache returns the consumed-token cache, allocating it for providers
// built without NewOIDCProvider so the delegated operations never panic on a
// nil cache.
func (p *OIDCProvider) replayCache() *jtiReplayCache {
p.jtiOnce.Do(func() {
if p.jti == nil {
p.jti = newJTIReplayCache(p.name)
}
})
return p.jti
}
// OIDCConfig holds OIDC provider configuration
@@ -139,13 +107,6 @@ type OIDCConfig struct {
// TLSInsecureSkipVerify controls whether to skip TLS verification.
// WARNING: Should only be used in development/testing environments. Never use in production.
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"`
// JTIReplayProtectionEnabled enables token replay protection via JTI tracking (default: true)
// Use pointer to distinguish between "not set" (nil = default true) and "explicitly false"
JTIReplayProtectionEnabled *bool `json:"jtiReplayProtectionEnabled,omitempty"`
// JTICacheMaxSize sets maximum number of JTI entries to cache (default: 10000)
JTICacheMaxSize int `json:"jtiCacheMaxSize,omitempty"`
}
// PrincipalTagsClaim is the AWS-defined namespace claim that carries
@@ -358,7 +319,6 @@ func NewOIDCProvider(name string) *OIDCProvider {
return &OIDCProvider{
name: name,
httpClient: &http.Client{Timeout: 30 * time.Second, CheckRedirect: refuseDowngradeRedirect},
jti: newJTIReplayCache(name),
}
}
@@ -462,6 +422,9 @@ func (p *OIDCProvider) Initialize(config interface{}) error {
transport := &http.Transport{
TLSClientConfig: tlsConfig,
// A replaced provider drops its transport without a close; bound
// idle connections so they cannot linger with their goroutines.
IdleConnTimeout: 90 * time.Second,
}
p.httpClient = &http.Client{
Timeout: 30 * time.Second,
@@ -469,17 +432,6 @@ func (p *OIDCProvider) Initialize(config interface{}) error {
CheckRedirect: refuseDowngradeRedirect,
}
// Configure JTI replay protection last, so a config failure above cannot
// leave a running worker behind. A previous worker is always stopped,
// whether the new configuration enables protection or disables it.
// The consumed-token store survives re-initialization: resetting it would
// let already-seen tokens be replayed against the refreshed provider.
jtiEnabled := true
if oidcConfig.JTIReplayProtectionEnabled != nil {
jtiEnabled = *oidcConfig.JTIReplayProtectionEnabled
}
p.replayCache().configure(jtiEnabled, int64(oidcConfig.JTICacheMaxSize))
// For testing, we'll skip the actual OIDC client initialization
return nil
}
@@ -530,18 +482,6 @@ func (p *OIDCProvider) allowedAudiences() []string {
// Authenticate authenticates a user with an OIDC token
func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
return p.authenticate(ctx, token, false)
}
// AuthenticateSingleUse authenticates and consumes the token so a second
// presentation is rejected as a replay. The web-identity exchange uses it,
// where a token buys a session exactly once; callers that validate the same
// token on every request use Authenticate.
func (p *OIDCProvider) AuthenticateSingleUse(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
return p.authenticate(ctx, token, true)
}
func (p *OIDCProvider) authenticate(ctx context.Context, token string, singleUse bool) (*providers.ExternalIdentity, error) {
if !p.initialized {
return nil, fmt.Errorf("provider not initialized")
}
@@ -551,7 +491,7 @@ func (p *OIDCProvider) authenticate(ctx context.Context, token string, singleUse
}
// Validate token and get claims
claims, err := p.validateToken(ctx, token, singleUse)
claims, err := p.ValidateToken(ctx, token)
if err != nil {
return nil, err
}
@@ -718,16 +658,6 @@ func (p *OIDCProvider) getUserInfoWithToken(ctx context.Context, userID, accessT
// ValidateToken validates an OIDC JWT token
func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
return p.validateToken(ctx, token, false)
}
// ValidateTokenSingleUse validates and consumes the token: a second
// presentation of it is rejected as a replay. See AuthenticateSingleUse.
func (p *OIDCProvider) ValidateTokenSingleUse(ctx context.Context, token string) (*providers.TokenClaims, error) {
return p.validateToken(ctx, token, true)
}
func (p *OIDCProvider) validateToken(ctx context.Context, token string, singleUse bool) (*providers.TokenClaims, error) {
if !p.initialized {
return nil, fmt.Errorf("provider not initialized")
}
@@ -754,7 +684,8 @@ func (p *OIDCProvider) validateToken(ctx context.Context, token string, singleUs
return nil, fmt.Errorf("failed to get public key: %v", err)
}
// Parse and validate token with proper signature verification
// Parse and validate token with proper signature verification. exp is
// required: without it a leaked token could mint STS sessions forever.
claims := jwt.MapClaims{}
validatedToken, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
@@ -764,7 +695,7 @@ func (p *OIDCProvider) validateToken(ctx context.Context, token string, singleUs
default:
return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"])
}
})
}, jwt.WithExpirationRequired())
if err != nil {
// Use JWT library's typed errors for robust error checking
@@ -860,16 +791,6 @@ func (p *OIDCProvider) validateToken(ctx context.Context, token string, singleUs
tokenClaims.Claims[key] = value
}
// Consume the token for replay protection. The check runs only for
// single-use callers: a bearer token is meant to be presented on every
// request, so validating it must not consume it.
if singleUse {
jti, _ := claims["jti"].(string)
if err := p.replayCache().consume(token, jti, subject, tokenClaims.ExpiresAt); err != nil {
return nil, err
}
}
return tokenClaims, nil
}
@@ -1298,36 +1219,3 @@ func (p *OIDCProvider) mapUserInfoToIdentity(userInfo map[string]interface{}) *p
return identity
}
// Shutdown gracefully stops the JTI cleanup goroutine.
// Should be called when the provider is being shut down to prevent goroutine leaks.
func (p *OIDCProvider) Shutdown(ctx context.Context) error {
return p.replayCache().stop(ctx)
}
// AdoptReplayStateFrom takes over the consumed-token store of the provider it
// replaces, so tokens it already saw stay rejected across the rebuild. The
// caller must shut down the replaced provider; only one cleanup worker may
// sweep the shared store at a time. Call before the new provider serves
// validators.
func (p *OIDCProvider) AdoptReplayStateFrom(old *OIDCProvider) {
if old == nil || old == p || old.jti == nil {
return
}
p.replayCache().adoptFrom(old.jti)
}
// MergeReplayStateFrom hands this provider's store to another provider and
// then folds the other's consumed-token entries in, so rejections the other
// recorded keep holding when its records move to a shared provider. Repointing
// first means tokens the other still consumes — in-flight exchanges or ones
// routed to it before the provider map updates — land in the shared store
// instead of a store this merge already drained. Unlike AdoptReplayStateFrom
// it swaps nothing of its own, so it is safe on a provider that is already
// serving validators.
func (p *OIDCProvider) MergeReplayStateFrom(other *OIDCProvider) {
if other == nil || other == p {
return
}
p.replayCache().mergeFrom(other.replayCache())
}
File diff suppressed because it is too large. Load diff
-3
View File
@@ -19,7 +19,4 @@ var (
// ErrProviderMissingClaims indicates that required claims are missing from the token
ErrProviderMissingClaims = errors.New("provider: missing required claims")
// ErrProviderTokenReplayed is returned when a token with duplicate JTI is detected
ErrProviderTokenReplayed = errors.New("provider: token has been replayed")
)
+15 -17
View File
@@ -42,23 +42,21 @@ const (
// Configuration Field Names
const (
ConfigFieldFilerAddress = "filerAddress"
ConfigFieldBasePath = "basePath"
ConfigFieldIssuer = "issuer"
ConfigFieldClientID = "clientId"
ConfigFieldClientIDs = "clientIds"
ConfigFieldThumbprints = "thumbprints"
ConfigFieldAllowedPrincipalTagKeys = "allowedPrincipalTagKeys"
ConfigFieldPolicyClaim = "policyClaim"
ConfigFieldClientSecret = "clientSecret"
ConfigFieldJWKSUri = "jwksUri"
ConfigFieldScopes = "scopes"
ConfigFieldUserInfoUri = "userInfoUri"
ConfigFieldRedirectUri = "redirectUri"
ConfigFieldTLSCACert = "tlsCaCert"
ConfigFieldTLSInsecureSkipVerify = "tlsInsecureSkipVerify"
ConfigFieldJTIReplayProtectionEnabled = "jtiReplayProtectionEnabled"
ConfigFieldJTICacheMaxSize = "jtiCacheMaxSize"
ConfigFieldFilerAddress = "filerAddress"
ConfigFieldBasePath = "basePath"
ConfigFieldIssuer = "issuer"
ConfigFieldClientID = "clientId"
ConfigFieldClientIDs = "clientIds"
ConfigFieldThumbprints = "thumbprints"
ConfigFieldAllowedPrincipalTagKeys = "allowedPrincipalTagKeys"
ConfigFieldPolicyClaim = "policyClaim"
ConfigFieldClientSecret = "clientSecret"
ConfigFieldJWKSUri = "jwksUri"
ConfigFieldScopes = "scopes"
ConfigFieldUserInfoUri = "userInfoUri"
ConfigFieldRedirectUri = "redirectUri"
ConfigFieldTLSCACert = "tlsCaCert"
ConfigFieldTLSInsecureSkipVerify = "tlsInsecureSkipVerify"
)
// Error Messages
-48
View File
@@ -103,51 +103,3 @@ func TestLookupOIDCProviderForAccountFallsBackToStatic(t *testing.T) {
t.Fatalf("expected static-config provider as last resort, got %v ok=%v", got, ok)
}
}
// shutdownTrackingProvider records Shutdown calls so tests can observe which
// replaced providers the swap released.
type shutdownTrackingProvider struct {
stubIdentityProvider
shutdownCalls int32
}
func (s *shutdownTrackingProvider) Shutdown(context.Context) error {
s.shutdownCalls++
return nil
}
// A provider dropped from the managed map owns a background JTI cleanup
// worker; replacing the map must shut it down, while providers that stay in
// the map keep running.
func TestSetIAMManagedOIDCProvidersShutsDownDroppedProviders(t *testing.T) {
dropped := &shutdownTrackingProvider{stubIdentityProvider{name: "dropped"}, 0}
kept := &shutdownTrackingProvider{stubIdentityProvider{name: "kept"}, 0}
added := &shutdownTrackingProvider{stubIdentityProvider{name: "added"}, 0}
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
"https://a.example.com": {{Provider: dropped}},
"https://b.example.com": {{Provider: kept}, {AccountID: "111111111111", Provider: kept}},
})
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
"https://b.example.com": {{Provider: kept}},
"https://c.example.com": {{Provider: added}},
})
if dropped.shutdownCalls != 1 {
t.Fatalf("dropped provider was shut down %d times, want 1", dropped.shutdownCalls)
}
if kept.shutdownCalls != 0 {
t.Fatalf("kept provider was shut down %d times, want 0", kept.shutdownCalls)
}
if added.shutdownCalls != 0 {
t.Fatalf("newly added provider was shut down %d times, want 0", added.shutdownCalls)
}
// Clearing the map releases the remaining providers.
s.SetIAMManagedOIDCProviders(nil)
if kept.shutdownCalls != 1 || added.shutdownCalls != 1 {
t.Fatalf("clearing the map shut down kept=%d added=%d, want 1 each", kept.shutdownCalls, added.shutdownCalls)
}
}
-16
View File
@@ -1,7 +1,6 @@
package sts
import (
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -155,21 +154,6 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{})
config.TLSInsecureSkipVerify = tlsInsecureSkipVerify
}
if enabled, ok := configMap[ConfigFieldJTIReplayProtectionEnabled].(bool); ok {
config.JTIReplayProtectionEnabled = &enabled
}
switch maxSize := configMap[ConfigFieldJTICacheMaxSize].(type) {
case float64:
config.JTICacheMaxSize = int(maxSize)
case int:
config.JTICacheMaxSize = maxSize
case json.Number:
if n, err := maxSize.Int64(); err == nil {
config.JTICacheMaxSize = int(n)
}
}
// Convert claims mapping
if claimsMapInterface, ok := configMap["claimsMapping"]; ok {
claimsMap, err := f.convertToStringMap(claimsMapInterface)
-31
View File
@@ -1,31 +0,0 @@
package sts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertToOIDCConfigCarriesJTISettings(t *testing.T) {
factory := NewProviderFactory()
config, err := factory.convertToOIDCConfig(map[string]interface{}{
"issuer": "https://issuer.example",
"clientId": "client",
"jtiReplayProtectionEnabled": false,
"jtiCacheMaxSize": float64(42),
})
require.NoError(t, err)
require.NotNil(t, config.JTIReplayProtectionEnabled)
assert.False(t, *config.JTIReplayProtectionEnabled)
assert.Equal(t, 42, config.JTICacheMaxSize)
config, err = factory.convertToOIDCConfig(map[string]interface{}{
"issuer": "https://issuer.example",
"clientId": "client",
})
require.NoError(t, err)
assert.Nil(t, config.JTIReplayProtectionEnabled)
assert.Zero(t, config.JTICacheMaxSize)
}
+6 -50
View File
@@ -522,35 +522,8 @@ func (s *STSService) SetIAMManagedOIDCProviders(byIssuer map[string][]ScopedOIDC
}
}
s.iamManagedOIDCMu.Lock()
old := s.iamManagedOIDCByIssuer
s.iamManagedOIDCByIssuer = cp
s.iamManagedOIDCMu.Unlock()
// Providers dropped by the swap may own background workers (the JTI
// cleanup goroutine); stop them so a replaced provider does not leak.
kept := make(map[providers.IdentityProvider]struct{})
for _, scoped := range cp {
for _, sp := range scoped {
kept[sp.Provider] = struct{}{}
}
}
stopped := make(map[providers.IdentityProvider]struct{})
for _, scoped := range old {
for _, sp := range scoped {
if _, ok := kept[sp.Provider]; ok {
continue
}
if _, ok := stopped[sp.Provider]; ok {
continue
}
stopped[sp.Provider] = struct{}{}
if sd, ok := sp.Provider.(interface{ Shutdown(context.Context) error }); ok {
if err := sd.Shutdown(context.Background()); err != nil {
glog.Warningf("OIDC provider %q shutdown after replacement: %v", sp.Provider.Name(), err)
}
}
}
}
}
// lookupOIDCProviderForAccount returns the provider that should validate
@@ -624,7 +597,7 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
// caller passed a legacy or claim-based ARN, in which case lookup falls
// back to a global (account-less) record only.
roleAccountID := utils.ParseRoleARN(request.RoleArn).AccountID
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken, roleAccountID, true)
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken, roleAccountID)
if err != nil {
return nil, fmt.Errorf("failed to validate web identity token: %w", err)
}
@@ -1029,7 +1002,7 @@ func (s *STSService) validateAssumeRoleWithWebIdentityRequest(request *AssumeRol
// validateWebIdentityToken validates the web identity token with strict issuer-to-provider mapping
// SECURITY: JWT tokens with a specific issuer claim MUST only be validated by the provider for that issuer
// SECURITY: This method only accepts JWT tokens. Non-JWT authentication must use AssumeRoleWithCredentials with explicit ProviderName.
func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAccountID string, singleUse bool) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
// Try to extract issuer from JWT token for strict validation
issuer, err := s.extractIssuerFromJWT(token)
if err != nil {
@@ -1052,10 +1025,8 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAc
return nil, nil, fmt.Errorf("no identity provider registered for issuer: %s", issuer)
}
// Authenticate with the correct provider for this issuer. Only the STS
// exchange authenticates single-use: a bearer token is presented on every
// request, so consuming it here would reject all but the first one.
identity, err := authenticateOIDCProvider(ctx, provider, token, singleUse)
// Authenticate with the correct provider for this issuer
identity, err := provider.Authenticate(ctx, token)
if err != nil {
// Map provider errors to STS errors using errors.Is() for robust error checking
// This eliminates fragile string matching and provides reliable error classification
@@ -1087,29 +1058,14 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAc
// only, then static-config); call ValidateWebIdentityTokenForAccount when the assumed-role account
// is known.
func (s *STSService) ValidateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
return s.validateWebIdentityToken(ctx, token, "", false)
return s.validateWebIdentityToken(ctx, token, "")
}
// ValidateWebIdentityTokenForAccount mirrors ValidateWebIdentityToken but
// scopes the IAM-managed provider lookup to roleAccountID. Pass "" for
// callers that don't yet know the account (e.g. claim-based mode).
func (s *STSService) ValidateWebIdentityTokenForAccount(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
return s.validateWebIdentityToken(ctx, token, roleAccountID, false)
}
// authenticateOIDCProvider authenticates the token, consuming it for replay
// protection when the caller is the single-use STS exchange and the provider
// supports it. Other providers and non-exchange callers authenticate without
// consuming.
func authenticateOIDCProvider(ctx context.Context, provider providers.IdentityProvider, token string, singleUse bool) (*providers.ExternalIdentity, error) {
if singleUse {
if su, ok := provider.(interface {
AuthenticateSingleUse(context.Context, string) (*providers.ExternalIdentity, error)
}); ok {
return su.AuthenticateSingleUse(ctx, token)
}
}
return provider.Authenticate(ctx, token)
return s.validateWebIdentityToken(ctx, token, roleAccountID)
}
// extractIssuerFromJWT extracts the issuer (iss) claim from a JWT token without verification
+19 -7
View File
@@ -7,12 +7,10 @@ import (
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
@@ -99,7 +97,6 @@ func TestAssumeRoleWithWebIdentityOverHTTP(t *testing.T) {
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
handlers := NewSTSHandlers(manager.GetSTSService(), iam)
var jtiCounter atomic.Int64
signOIDCToken := func(t *testing.T, subject string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
@@ -108,10 +105,6 @@ func TestAssumeRoleWithWebIdentityOverHTTP(t *testing.T) {
"aud": "test-client",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
// a unique jti per token: the exchange consumes it, so two
// subtests minting the same claims in one second would otherwise
// collide on the token fingerprint and read as a replay
"jti": fmt.Sprintf("%s-%d", subject, jtiCounter.Add(1)),
})
token.Header["kid"] = "web-identity-test-key"
signed, err := token.SignedString(key)
@@ -181,6 +174,25 @@ func TestAssumeRoleWithWebIdentityOverHTTP(t *testing.T) {
})
}
// Kubernetes workload identity re-presents the same projected token until
// ~80% of its lifetime, so one token must exchange more than once.
t.Run("the same token exchanges twice", func(t *testing.T) {
form := url.Values{
"Action": {"AssumeRoleWithWebIdentity"},
"Version": {"2011-06-15"},
"RoleArn": {trustedRoleArn},
"RoleSessionName": {"web-session-reuse"},
"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
}
for attempt := 1; attempt <= 2; attempt++ {
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handlers.HandleSTSRequest(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "attempt %d: %s", attempt, rec.Body.String())
}
})
t.Run("inline session policy is reported in PackedPolicySize", func(t *testing.T) {
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
form := url.Values{