package sts import ( "context" "testing" "time" "github.com/golang-jwt/jwt/v5" "github.com/seaweedfs/seaweedfs/weed/iam/oidc" "github.com/seaweedfs/seaweedfs/weed/iam/providers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Test-only constants for mock providers const ( ProviderTypeMock = "mock" ) // createMockOIDCProvider creates a mock OIDC provider for testing // This is only available in test builds func createMockOIDCProvider(name string, config map[string]interface{}) (providers.IdentityProvider, error) { // Convert config to OIDC format factory := NewProviderFactory() oidcConfig, err := factory.convertToOIDCConfig(config) if err != nil { return nil, err } // Set default values for mock provider if not provided if oidcConfig.Issuer == "" { oidcConfig.Issuer = "http://localhost:9999" } provider := oidc.NewMockOIDCProvider(name) if err := provider.Initialize(oidcConfig); err != nil { return nil, err } // Set up default test data for the mock provider provider.SetupDefaultTestData() return provider, nil } // createMockJWT creates a test JWT token with the specified issuer for mock provider testing func createMockJWT(t *testing.T, issuer, subject string) string { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "iss": issuer, "sub": subject, "aud": "test-client", "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), }) tokenString, err := token.SignedString([]byte("test-signing-key")) require.NoError(t, err) return tokenString } // TestCrossInstanceTokenUsage verifies that tokens generated by one STS instance // can be used and validated by other STS instances in a distributed environment func TestCrossInstanceTokenUsage(t *testing.T) { ctx := context.Background() // Dummy filer address for testing // Common configuration that would be shared across all instances in production sharedConfig := &STSConfig{ TokenDuration: FlexibleDuration{time.Hour}, MaxSessionLength: FlexibleDuration{12 * time.Hour}, Issuer: "distributed-sts-cluster", // SAME across all instances SigningKey: []byte(TestSigningKey32Chars), // SAME across all instances Providers: []*ProviderConfig{ { Name: "company-oidc", Type: ProviderTypeOIDC, Enabled: true, Config: map[string]interface{}{ ConfigFieldIssuer: "https://sso.company.com/realms/production", ConfigFieldClientID: "seaweedfs-cluster", ConfigFieldJWKSUri: "https://sso.company.com/realms/production/protocol/openid-connect/certs", }, }, }, } // Create multiple STS instances simulating different S3 gateway instances instanceA := NewSTSService() // e.g., s3-gateway-1 instanceB := NewSTSService() // e.g., s3-gateway-2 instanceC := NewSTSService() // e.g., s3-gateway-3 // Initialize all instances with IDENTICAL configuration err := instanceA.Initialize(sharedConfig) require.NoError(t, err, "Instance A should initialize") err = instanceB.Initialize(sharedConfig) require.NoError(t, err, "Instance B should initialize") err = instanceC.Initialize(sharedConfig) require.NoError(t, err, "Instance C should initialize") // Set up mock trust policy validator for all instances (required for STS testing) mockValidator := &MockTrustPolicyValidator{} instanceA.SetTrustPolicyValidator(mockValidator) instanceB.SetTrustPolicyValidator(mockValidator) instanceC.SetTrustPolicyValidator(mockValidator) // Manually register mock provider for testing (not available in production) mockProviderConfig := map[string]interface{}{ ConfigFieldIssuer: "http://test-mock:9999", ConfigFieldClientID: TestClientID, } mockProviderA, err := createMockOIDCProvider("test-mock", mockProviderConfig) require.NoError(t, err) mockProviderB, err := createMockOIDCProvider("test-mock", mockProviderConfig) require.NoError(t, err) mockProviderC, err := createMockOIDCProvider("test-mock", mockProviderConfig) require.NoError(t, err) instanceA.RegisterProvider(mockProviderA) instanceB.RegisterProvider(mockProviderB) instanceC.RegisterProvider(mockProviderC) // Test 1: Token generated on Instance A can be validated on Instance B & C t.Run("cross_instance_token_validation", func(t *testing.T) { // Generate session token on Instance A sessionId := TestSessionID expiresAt := time.Now().Add(time.Hour) tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt) require.NoError(t, err, "Instance A should generate token") // Validate token on Instance B claimsFromB, err := instanceB.tokenGenerator.ValidateSessionToken(tokenFromA) require.NoError(t, err, "Instance B should validate token from Instance A") assert.Equal(t, sessionId, claimsFromB.SessionId, "Session ID should match") // Validate same token on Instance C claimsFromC, err := instanceC.tokenGenerator.ValidateSessionToken(tokenFromA) require.NoError(t, err, "Instance C should validate token from Instance A") assert.Equal(t, sessionId, claimsFromC.SessionId, "Session ID should match") // All instances should extract identical claims assert.Equal(t, claimsFromB.SessionId, claimsFromC.SessionId) assert.Equal(t, claimsFromB.ExpiresAt.Unix(), claimsFromC.ExpiresAt.Unix()) assert.Equal(t, claimsFromB.IssuedAt.Unix(), claimsFromC.IssuedAt.Unix()) }) // Test 2: Complete assume role flow across instances t.Run("cross_instance_assume_role_flow", func(t *testing.T) { // Step 1: User authenticates and assumes role on Instance A // Create a valid JWT token for the mock provider mockToken := createMockJWT(t, "http://test-mock:9999", "test-user") assumeRequest := &AssumeRoleWithWebIdentityRequest{ RoleArn: "arn:seaweed:iam::role/CrossInstanceTestRole", WebIdentityToken: mockToken, // JWT token for mock provider RoleSessionName: "cross-instance-test-session", DurationSeconds: int64ToPtr(3600), } // Instance A processes assume role request responseFromA, err := instanceA.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, err, "Instance A should process assume role") sessionToken := responseFromA.Credentials.SessionToken accessKeyId := responseFromA.Credentials.AccessKeyId secretAccessKey := responseFromA.Credentials.SecretAccessKey // Verify response structure assert.NotEmpty(t, sessionToken, "Should have session token") assert.NotEmpty(t, accessKeyId, "Should have access key ID") assert.NotEmpty(t, secretAccessKey, "Should have secret access key") assert.NotNil(t, responseFromA.AssumedRoleUser, "Should have assumed role user") // Step 2: Use session token on Instance B (different instance) sessionInfoFromB, err := instanceB.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Instance B should validate session token from Instance A") assert.Equal(t, assumeRequest.RoleSessionName, sessionInfoFromB.SessionName) assert.Equal(t, assumeRequest.RoleArn, sessionInfoFromB.RoleArn) // Step 3: Use same session token on Instance C (yet another instance) sessionInfoFromC, err := instanceC.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Instance C should validate session token from Instance A") // All instances should return identical session information assert.Equal(t, sessionInfoFromB.SessionId, sessionInfoFromC.SessionId) assert.Equal(t, sessionInfoFromB.SessionName, sessionInfoFromC.SessionName) assert.Equal(t, sessionInfoFromB.RoleArn, sessionInfoFromC.RoleArn) assert.Equal(t, sessionInfoFromB.Subject, sessionInfoFromC.Subject) assert.Equal(t, sessionInfoFromB.Provider, sessionInfoFromC.Provider) }) // Test 3: Session revocation across instances t.Run("cross_instance_session_revocation", func(t *testing.T) { // Create session on Instance A mockToken := createMockJWT(t, "http://test-mock:9999", "test-user") assumeRequest := &AssumeRoleWithWebIdentityRequest{ RoleArn: "arn:seaweed:iam::role/RevocationTestRole", WebIdentityToken: mockToken, RoleSessionName: "revocation-test-session", } response, err := instanceA.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, err) sessionToken := response.Credentials.SessionToken // Verify token works on Instance B _, err = instanceB.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Token should be valid on Instance B initially") // Validate session on Instance C to verify cross-instance token compatibility _, err = instanceC.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Instance C should be able to validate session token") // In a stateless JWT system, tokens remain valid on all instances since they're self-contained // No revocation is possible without breaking the stateless architecture _, err = instanceA.ValidateSessionToken(ctx, sessionToken) assert.NoError(t, err, "Token should still be valid on Instance A (stateless system)") // Verify token is still valid on Instance B _, err = instanceB.ValidateSessionToken(ctx, sessionToken) assert.NoError(t, err, "Token should still be valid on Instance B (stateless system)") }) // Test 4: Provider consistency across instances t.Run("provider_consistency_affects_token_generation", func(t *testing.T) { // All instances should have same providers and be able to process same OIDC tokens providerNamesA := instanceA.getProviderNames() providerNamesB := instanceB.getProviderNames() providerNamesC := instanceC.getProviderNames() assert.ElementsMatch(t, providerNamesA, providerNamesB, "Instance A and B should have same providers") assert.ElementsMatch(t, providerNamesB, providerNamesC, "Instance B and C should have same providers") // All instances should be able to process same web identity token testToken := createMockJWT(t, "http://test-mock:9999", "test-user") // Try to assume role with same token on different instances assumeRequest := &AssumeRoleWithWebIdentityRequest{ RoleArn: "arn:seaweed:iam::role/ProviderTestRole", WebIdentityToken: testToken, RoleSessionName: "provider-consistency-test", } // Should work on any instance responseA, errA := instanceA.AssumeRoleWithWebIdentity(ctx, assumeRequest) responseB, errB := instanceB.AssumeRoleWithWebIdentity(ctx, assumeRequest) responseC, errC := instanceC.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, errA, "Instance A should process OIDC token") require.NoError(t, errB, "Instance B should process OIDC token") require.NoError(t, errC, "Instance C should process OIDC token") // All should return valid responses (sessions will have different IDs but same structure) assert.NotEmpty(t, responseA.Credentials.SessionToken) assert.NotEmpty(t, responseB.Credentials.SessionToken) assert.NotEmpty(t, responseC.Credentials.SessionToken) }) } // TestSTSDistributedConfigurationRequirements tests the configuration requirements // for cross-instance token compatibility func TestSTSDistributedConfigurationRequirements(t *testing.T) { _ = "localhost:8888" // Dummy filer address for testing (not used in these tests) t.Run("same_signing_key_required", func(t *testing.T) { // Instance A with signing key 1 configA := &STSConfig{ TokenDuration: FlexibleDuration{time.Hour}, MaxSessionLength: FlexibleDuration{12 * time.Hour}, Issuer: "test-sts", SigningKey: []byte("signing-key-1-32-characters-long"), } // Instance B with different signing key configB := &STSConfig{ TokenDuration: FlexibleDuration{time.Hour}, MaxSessionLength: FlexibleDuration{12 * time.Hour}, Issuer: "test-sts", SigningKey: []byte("signing-key-2-32-characters-long"), // DIFFERENT! } instanceA := NewSTSService() instanceB := NewSTSService() err := instanceA.Initialize(configA) require.NoError(t, err) err = instanceB.Initialize(configB) require.NoError(t, err) // Generate token on Instance A sessionId := "test-session" expiresAt := time.Now().Add(time.Hour) tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt) require.NoError(t, err) // Instance A should validate its own token _, err = instanceA.tokenGenerator.ValidateSessionToken(tokenFromA) assert.NoError(t, err, "Instance A should validate own token") // Instance B should REJECT token due to different signing key _, err = instanceB.tokenGenerator.ValidateSessionToken(tokenFromA) assert.Error(t, err, "Instance B should reject token with different signing key") assert.Contains(t, err.Error(), "invalid token", "Should be signature validation error") }) t.Run("same_issuer_required", func(t *testing.T) { sharedSigningKey := []byte("shared-signing-key-32-characters-lo") // Instance A with issuer 1 configA := &STSConfig{ TokenDuration: FlexibleDuration{time.Hour}, MaxSessionLength: FlexibleDuration{12 * time.Hour}, Issuer: "sts-cluster-1", SigningKey: sharedSigningKey, } // Instance B with different issuer configB := &STSConfig{ TokenDuration: FlexibleDuration{time.Hour}, MaxSessionLength: FlexibleDuration{12 * time.Hour}, Issuer: "sts-cluster-2", // DIFFERENT! SigningKey: sharedSigningKey, } instanceA := NewSTSService() instanceB := NewSTSService() err := instanceA.Initialize(configA) require.NoError(t, err) err = instanceB.Initialize(configB) require.NoError(t, err) // Generate token on Instance A sessionId := "test-session" expiresAt := time.Now().Add(time.Hour) tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt) require.NoError(t, err) // Instance B should REJECT token due to different issuer _, err = instanceB.tokenGenerator.ValidateSessionToken(tokenFromA) assert.Error(t, err, "Instance B should reject token with different issuer") assert.Contains(t, err.Error(), "invalid issuer", "Should be issuer validation error") }) t.Run("identical_configuration_required", func(t *testing.T) { // Identical configuration identicalConfig := &STSConfig{ TokenDuration: FlexibleDuration{time.Hour}, MaxSessionLength: FlexibleDuration{12 * time.Hour}, Issuer: "production-sts-cluster", SigningKey: []byte("production-signing-key-32-chars-l"), } // Create multiple instances with identical config instances := make([]*STSService, 5) for i := 0; i < 5; i++ { instances[i] = NewSTSService() err := instances[i].Initialize(identicalConfig) require.NoError(t, err, "Instance %d should initialize", i) } // Generate token on Instance 0 sessionId := "multi-instance-test" expiresAt := time.Now().Add(time.Hour) token, err := instances[0].tokenGenerator.GenerateSessionToken(sessionId, expiresAt) require.NoError(t, err) // All other instances should validate the token for i := 1; i < 5; i++ { claims, err := instances[i].tokenGenerator.ValidateSessionToken(token) require.NoError(t, err, "Instance %d should validate token", i) assert.Equal(t, sessionId, claims.SessionId, "Instance %d should extract correct session ID", i) } }) } // TestSTSRealWorldDistributedScenarios tests realistic distributed deployment scenarios func TestSTSRealWorldDistributedScenarios(t *testing.T) { ctx := context.Background() t.Run("load_balanced_s3_gateway_scenario", func(t *testing.T) { // Simulate real production scenario: // 1. User authenticates with OIDC provider // 2. User calls AssumeRoleWithWebIdentity on S3 Gateway 1 // 3. User makes S3 requests that hit S3 Gateway 2 & 3 via load balancer // 4. All instances should handle the session token correctly productionConfig := &STSConfig{ TokenDuration: FlexibleDuration{2 * time.Hour}, MaxSessionLength: FlexibleDuration{24 * time.Hour}, Issuer: "seaweedfs-production-sts", SigningKey: []byte("prod-signing-key-32-characters-lon"), Providers: []*ProviderConfig{ { Name: "corporate-oidc", Type: "oidc", Enabled: true, Config: map[string]interface{}{ "issuer": "https://sso.company.com/realms/production", "clientId": "seaweedfs-prod-cluster", "clientSecret": "supersecret-prod-key", "scopes": []string{"openid", "profile", "email", "groups"}, }, }, }, } // Create 3 S3 Gateway instances behind load balancer gateway1 := NewSTSService() gateway2 := NewSTSService() gateway3 := NewSTSService() err := gateway1.Initialize(productionConfig) require.NoError(t, err) err = gateway2.Initialize(productionConfig) require.NoError(t, err) err = gateway3.Initialize(productionConfig) require.NoError(t, err) // Set up mock trust policy validator for all gateway instances mockValidator := &MockTrustPolicyValidator{} gateway1.SetTrustPolicyValidator(mockValidator) gateway2.SetTrustPolicyValidator(mockValidator) gateway3.SetTrustPolicyValidator(mockValidator) // Manually register mock provider for testing (not available in production) mockProviderConfig := map[string]interface{}{ ConfigFieldIssuer: "http://test-mock:9999", ConfigFieldClientID: "test-client-id", } mockProvider1, err := createMockOIDCProvider("test-mock", mockProviderConfig) require.NoError(t, err) mockProvider2, err := createMockOIDCProvider("test-mock", mockProviderConfig) require.NoError(t, err) mockProvider3, err := createMockOIDCProvider("test-mock", mockProviderConfig) require.NoError(t, err) gateway1.RegisterProvider(mockProvider1) gateway2.RegisterProvider(mockProvider2) gateway3.RegisterProvider(mockProvider3) // Step 1: User authenticates and hits Gateway 1 for AssumeRole mockToken := createMockJWT(t, "http://test-mock:9999", "production-user") assumeRequest := &AssumeRoleWithWebIdentityRequest{ RoleArn: "arn:seaweed:iam::role/ProductionS3User", WebIdentityToken: mockToken, // JWT token from mock provider RoleSessionName: "user-production-session", DurationSeconds: int64ToPtr(7200), // 2 hours } stsResponse, err := gateway1.AssumeRoleWithWebIdentity(ctx, assumeRequest) require.NoError(t, err, "Gateway 1 should handle AssumeRole") sessionToken := stsResponse.Credentials.SessionToken accessKey := stsResponse.Credentials.AccessKeyId secretKey := stsResponse.Credentials.SecretAccessKey // Step 2: User makes S3 requests that hit different gateways via load balancer // Simulate S3 request validation on Gateway 2 sessionInfo2, err := gateway2.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Gateway 2 should validate session from Gateway 1") assert.Equal(t, "user-production-session", sessionInfo2.SessionName) assert.Equal(t, "arn:seaweed:iam::role/ProductionS3User", sessionInfo2.RoleArn) // Simulate S3 request validation on Gateway 3 sessionInfo3, err := gateway3.ValidateSessionToken(ctx, sessionToken) require.NoError(t, err, "Gateway 3 should validate session from Gateway 1") assert.Equal(t, sessionInfo2.SessionId, sessionInfo3.SessionId, "Should be same session") // Step 3: Verify credentials are consistent assert.Equal(t, accessKey, stsResponse.Credentials.AccessKeyId, "Access key should be consistent") assert.Equal(t, secretKey, stsResponse.Credentials.SecretAccessKey, "Secret key should be consistent") // Step 4: Session expiration should be honored across all instances assert.True(t, sessionInfo2.ExpiresAt.After(time.Now()), "Session should not be expired") assert.True(t, sessionInfo3.ExpiresAt.After(time.Now()), "Session should not be expired") // Step 5: Token should be identical when parsed claims2, err := gateway2.tokenGenerator.ValidateSessionToken(sessionToken) require.NoError(t, err) claims3, err := gateway3.tokenGenerator.ValidateSessionToken(sessionToken) require.NoError(t, err) assert.Equal(t, claims2.SessionId, claims3.SessionId, "Session IDs should match") assert.Equal(t, claims2.ExpiresAt.Unix(), claims3.ExpiresAt.Unix(), "Expiration should match") }) } // Helper function to convert int64 to pointer func int64ToPtr(i int64) *int64 { return &i }