Files
seaweedfs/weed/iam/oidc/mock_provider.go
T
Chris Lu 6dd6ad512c 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.
2026-10-25 12:04:59 -07:00

220 lines
6.2 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
}
// 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,
})
}