mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 22:40:44 +01:00
* iam/oidc: reject reused jti claims with a bounded replay cache * iam/sts: pass jti replay settings through convertToOIDCConfig * iam/oidc: keep consumed jtis and stop the cleanup worker across re-init Re-initializing a provider reset the JTI store, so an already-used token could be presented again after a config refresh; the store is now allocated once and survives Initialize. The cleanup worker is stopped on every re-init (previously only when protection stayed enabled), is started only after the rest of Initialize has succeeded, and Shutdown no longer consumes a permanent sync.Once, so a re-initialized worker can be stopped again. * iam/oidc: enforce the jti replay cache bound for every token Tokens without a usable jti claim skipped the cache entirely and stayed reusable; they are now keyed by the token's own fingerprint so a repeat presentation is still a replay. Tokens without exp previously failed as expired on first use; their entry now gets a bounded retention. The replay test-and-set now runs before any capacity work so replayed tokens cannot each trigger a full-cache scan, the entry count is incremented as part of the insert so concurrent validations cannot overshoot the bound, and a guaranteed serialized sweep runs before a capacity rejection while opportunistic sweeps are rate limited and confined to one at a time. Raw token identifiers are no longer written to logs or errors, a stable fingerprint is logged instead. * iam/sts: shut down providers dropped by the managed-provider swap Each managed OIDC provider runs a background JTI cleanup worker, but SetIAMManagedOIDCProviders replaced the provider map without stopping the old providers, so every refresh leaked goroutines. Providers absent from the new map are now shut down; providers that stay keep running. * iam/integration: reuse providers across refreshes for unchanged records Every refresh rebuilt providers from their records, so a refreshed provider forgot the JTIs it had already consumed and an already-used token could be presented again. Records are now fingerprinted and an unchanged record reuses its provider, keeping the consumed-token cache; changed records are rebuilt, removed records are dropped, and the swap in STS releases the workers of whatever is no longer referenced. * iam/oidc: consume replay entries only on the single-use exchange path Bearer authentication validates the same token on every request, so consuming it in shared ValidateToken/Authenticate made every token single-use and rejected all but the first request. The replay check now runs only for the STS exchange via AuthenticateSingleUse; bearer paths validate without consuming. A token without exp also no longer gets a bounded replay entry that ages out while the token stays valid: its entry is kept until the capacity bound evicts it, so it can never become usable again. * iam/oidc: keep the mock provider on the mock path for single-use calls * iam/oidc: require exp on single-use tokens A cache slot frees only by expiry, so an expiry-less entry would hold it forever; a burst of distinct tokens could exhaust the bound permanently and block every later exchange. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: give each web-identity test token a unique jti Subtests minted tokens with identical claims inside one second, so the exchange saw the same token fingerprint twice and the second call read as a replay. Each exchange now carries its own jti. * iam/oidc: carry the consumed-token replay store across provider rebuilds A record change rebuilds the managed provider with a fresh JTI store, so a token consumed before the rebuild could be presented again until it expired. The new provider adopts the replaced provider's replay store before it is published, and the replaced provider is shut down so only one cleanup worker sweeps the shared store. * iam/oidc: stop the cleanup worker of providers dropped from the store A refresh removed the provider object from the managed cache but left its JTI cleanup goroutine running for the life of the process. * iam/oidc: merge a provider's consumed replay entries into another's store MergeReplayStateFrom folds one provider's consumed-token entries into another's, skipping already-expired ones. Unlike AdoptReplayStateFrom it swaps no pointers, so it is safe on a provider still serving validators. * iam/integration: share one provider and replay store across identical issuer records Records for the same issuer URL with identical validation settings now reuse a single provider, so a token consumed through one account's record is rejected through the other's — previously each record owned a separate jti store and a spent token could be replayed cross-account. A superseded provider's consumed entries merge into the shared store so no rejection is lost, and a removed or rebuilt provider is only shut down once no remaining record references it. * iam/oidc: hand the superseded provider's live replay store to the shared one * iam/oidc: carry merged replay entries without charging the capacity bound * iam/oidc: key the jti-less replay fingerprint on the signed input Hashing the whole token lets ECDSA signature malleability (s -> N-s) mint a second cache key for identical claims, so a consumed token could be replayed for another exchange. Key the fallback on header.payload instead. * iam/oidc: publish the jti cache bound before the enabled flag Initialize stored jtiEnabled ahead of jtiMaxSize, so a provider flipped from disabled to enabled exposed enabled=true with a zero bound for a window; a single-use validation landing there would compute newCount > maxSize and reject with "JTI cache capacity exceeded". Store the bound first so the flag is the last thing published. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
231 lines
6.7 KiB
Go
231 lines
6.7 KiB
Go
// This file contains mock OIDC provider implementations for testing only.
|
|
// These should NOT be used in production environments.
|
|
|
|
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
|
)
|
|
|
|
// MockOIDCProvider is a mock implementation for testing
|
|
type MockOIDCProvider struct {
|
|
*OIDCProvider
|
|
TestTokens map[string]*providers.TokenClaims
|
|
TestUsers map[string]*providers.ExternalIdentity
|
|
}
|
|
|
|
// NewMockOIDCProvider creates a mock OIDC provider for testing
|
|
func NewMockOIDCProvider(name string) *MockOIDCProvider {
|
|
return &MockOIDCProvider{
|
|
OIDCProvider: NewOIDCProvider(name),
|
|
TestTokens: make(map[string]*providers.TokenClaims),
|
|
TestUsers: make(map[string]*providers.ExternalIdentity),
|
|
}
|
|
}
|
|
|
|
// AddTestToken adds a test token with expected claims
|
|
func (m *MockOIDCProvider) AddTestToken(token string, claims *providers.TokenClaims) {
|
|
m.TestTokens[token] = claims
|
|
}
|
|
|
|
// AddTestUser adds a test user with expected identity
|
|
func (m *MockOIDCProvider) AddTestUser(userID string, identity *providers.ExternalIdentity) {
|
|
m.TestUsers[userID] = identity
|
|
}
|
|
|
|
// Authenticate overrides the parent Authenticate method to use mock data
|
|
func (m *MockOIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
|
|
if !m.initialized {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if token == "" {
|
|
return nil, fmt.Errorf("token cannot be empty")
|
|
}
|
|
|
|
// Validate token using mock validation
|
|
claims, err := m.ValidateToken(ctx, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Map claims to external identity
|
|
email, _ := claims.GetClaimString("email")
|
|
displayName, _ := claims.GetClaimString("name")
|
|
groups, _ := claims.GetClaimStringSlice("groups")
|
|
rawRoles, _ := claims.GetClaimStringSlice("roles")
|
|
|
|
identity := &providers.ExternalIdentity{
|
|
UserID: claims.Subject,
|
|
Email: email,
|
|
DisplayName: displayName,
|
|
Groups: groups,
|
|
Roles: rawRoles,
|
|
Provider: m.name,
|
|
}
|
|
if !claims.ExpiresAt.IsZero() {
|
|
identity.TokenExpiration = &claims.ExpiresAt
|
|
}
|
|
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 {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if token == "" {
|
|
return nil, fmt.Errorf("token cannot be empty")
|
|
}
|
|
|
|
// Special test tokens
|
|
if token == "expired_token" {
|
|
return nil, fmt.Errorf("token has expired")
|
|
}
|
|
if token == "invalid_token" {
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
// Try to parse as JWT token first
|
|
if len(token) > 20 && strings.Count(token, ".") >= 2 {
|
|
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
|
if err == nil {
|
|
if jwtClaims, ok := parsedToken.Claims.(jwt.MapClaims); ok {
|
|
issuer, _ := jwtClaims["iss"].(string)
|
|
subject, _ := jwtClaims["sub"].(string)
|
|
audience, _ := jwtClaims["aud"].(string)
|
|
|
|
// Verify the issuer matches our configuration
|
|
if issuer == m.config.Issuer && subject != "" {
|
|
// Extract expiration and issued at times
|
|
var expiresAt, issuedAt time.Time
|
|
if exp, ok := jwtClaims["exp"].(float64); ok {
|
|
expiresAt = time.Unix(int64(exp), 0)
|
|
}
|
|
if iat, ok := jwtClaims["iat"].(float64); ok {
|
|
issuedAt = time.Unix(int64(iat), 0)
|
|
}
|
|
|
|
// Carry the token's claims through like the real provider, keeping
|
|
// the synthesized defaults for tests that rely on them
|
|
claimsMap := make(map[string]interface{}, len(jwtClaims)+2)
|
|
for k, v := range jwtClaims {
|
|
claimsMap[k] = v
|
|
}
|
|
if _, ok := claimsMap["email"]; !ok {
|
|
claimsMap["email"] = subject + "@test-domain.com"
|
|
}
|
|
if _, ok := claimsMap["name"]; !ok {
|
|
claimsMap["name"] = "Test User " + subject
|
|
}
|
|
|
|
return &providers.TokenClaims{
|
|
Subject: subject,
|
|
Issuer: issuer,
|
|
Audience: audience,
|
|
ExpiresAt: expiresAt,
|
|
IssuedAt: issuedAt,
|
|
Claims: claimsMap,
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check test tokens
|
|
if claims, exists := m.TestTokens[token]; exists {
|
|
return claims, nil
|
|
}
|
|
|
|
// Default test token for basic testing
|
|
if token == "valid_test_token" {
|
|
return &providers.TokenClaims{
|
|
Subject: "test-user-id",
|
|
Issuer: m.config.Issuer,
|
|
Audience: m.config.ClientID,
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
IssuedAt: time.Now(),
|
|
Claims: map[string]interface{}{
|
|
"email": "test@example.com",
|
|
"name": "Test User",
|
|
"groups": []string{"developers", "users"},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("unknown test token: %s", token)
|
|
}
|
|
|
|
// GetUserInfo returns test user info
|
|
func (m *MockOIDCProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
|
if !m.initialized {
|
|
return nil, fmt.Errorf("provider not initialized")
|
|
}
|
|
|
|
if userID == "" {
|
|
return nil, fmt.Errorf("user ID cannot be empty")
|
|
}
|
|
|
|
// Check test users
|
|
if identity, exists := m.TestUsers[userID]; exists {
|
|
return identity, nil
|
|
}
|
|
|
|
// Default test user
|
|
return &providers.ExternalIdentity{
|
|
UserID: userID,
|
|
Email: userID + "@example.com",
|
|
DisplayName: "Test User " + userID,
|
|
Provider: m.name,
|
|
}, nil
|
|
}
|
|
|
|
// SetupDefaultTestData configures common test data
|
|
func (m *MockOIDCProvider) SetupDefaultTestData() {
|
|
// Create default token claims
|
|
defaultClaims := &providers.TokenClaims{
|
|
Subject: "test-user-123",
|
|
Issuer: "https://test-issuer.com",
|
|
Audience: "test-client-id",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
IssuedAt: time.Now(),
|
|
Claims: map[string]interface{}{
|
|
"email": "testuser@example.com",
|
|
"name": "Test User",
|
|
"groups": []string{"developers"},
|
|
},
|
|
}
|
|
|
|
// Add multiple token variants for compatibility
|
|
m.AddTestToken("valid_token", defaultClaims)
|
|
m.AddTestToken("valid-oidc-token", defaultClaims) // For integration tests
|
|
m.AddTestToken("valid_test_token", defaultClaims) // For STS tests
|
|
|
|
// Add default test users
|
|
m.AddTestUser("test-user-123", &providers.ExternalIdentity{
|
|
UserID: "test-user-123",
|
|
Email: "testuser@example.com",
|
|
DisplayName: "Test User",
|
|
Groups: []string{"developers"},
|
|
Provider: m.name,
|
|
})
|
|
}
|