You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
260 lines
8.5 KiB
260 lines
8.5 KiB
package iam
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// STS API test constants
|
|
const (
|
|
TestSTSEndpoint = "http://localhost:8333"
|
|
)
|
|
|
|
// AssumeRoleWithWebIdentityResponse represents the STS response
|
|
type AssumeRoleWithWebIdentityTestResponse struct {
|
|
XMLName xml.Name `xml:"AssumeRoleWithWebIdentityResponse"`
|
|
Result struct {
|
|
Credentials struct {
|
|
AccessKeyId string `xml:"AccessKeyId"`
|
|
SecretAccessKey string `xml:"SecretAccessKey"`
|
|
SessionToken string `xml:"SessionToken"`
|
|
Expiration string `xml:"Expiration"`
|
|
} `xml:"Credentials"`
|
|
SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"`
|
|
} `xml:"AssumeRoleWithWebIdentityResult"`
|
|
}
|
|
|
|
// STSErrorResponse represents an STS error response
|
|
type STSErrorTestResponse struct {
|
|
XMLName xml.Name `xml:"ErrorResponse"`
|
|
Error struct {
|
|
Type string `xml:"Type"`
|
|
Code string `xml:"Code"`
|
|
Message string `xml:"Message"`
|
|
} `xml:"Error"`
|
|
RequestId string `xml:"RequestId"`
|
|
}
|
|
|
|
// TestAssumeRoleWithWebIdentityValidation tests input validation for the STS endpoint
|
|
func TestAssumeRoleWithWebIdentityValidation(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
if !isSTSEndpointRunning(t) {
|
|
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
|
}
|
|
|
|
t.Run("missing_web_identity_token", func(t *testing.T) {
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
"RoleSessionName": {"test-session"},
|
|
// WebIdentityToken is missing
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail without WebIdentityToken")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
|
})
|
|
|
|
t.Run("missing_role_arn", func(t *testing.T) {
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"WebIdentityToken": {"fake-jwt-token"},
|
|
"RoleSessionName": {"test-session"},
|
|
// RoleArn is missing
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail without RoleArn")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
|
})
|
|
|
|
t.Run("missing_role_session_name", func(t *testing.T) {
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"WebIdentityToken": {"fake-jwt-token"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
// RoleSessionName is missing
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail without RoleSessionName")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
|
})
|
|
|
|
t.Run("invalid_jwt_token", func(t *testing.T) {
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"WebIdentityToken": {"not-a-valid-jwt-token"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
"RoleSessionName": {"test-session"},
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should fail with AccessDenied since the JWT is invalid
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail with invalid JWT token")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Contains(t, []string{"AccessDenied", "InvalidParameterValue"}, errResp.Error.Code)
|
|
})
|
|
}
|
|
|
|
// TestAssumeRoleWithWebIdentityWithMockJWT tests the STS endpoint with mock JWTs
|
|
// This test requires the mock OIDC provider to be configured
|
|
func TestAssumeRoleWithWebIdentityWithMockJWT(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
if !isSTSEndpointRunning(t) {
|
|
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
|
}
|
|
|
|
// Create a test framework to get valid JWT tokens
|
|
framework := NewS3IAMTestFramework(t)
|
|
defer framework.Cleanup()
|
|
|
|
// Generate a test JWT using the framework
|
|
testUsername := "sts-test-user"
|
|
testRole := "readonly"
|
|
|
|
// Try to get a token - use Keycloak if available, otherwise generate a mock JWT
|
|
var token string
|
|
var err error
|
|
if framework.useKeycloak {
|
|
token, err = framework.getKeycloakToken(testUsername)
|
|
} else {
|
|
// Generate a mock JWT token with 1 hour validity
|
|
token, err = framework.generateJWTToken(testUsername, testRole, time.Hour)
|
|
}
|
|
if err != nil {
|
|
t.Skipf("Unable to generate test JWT (requires mock OIDC or Keycloak): %v", err)
|
|
}
|
|
|
|
t.Run("valid_jwt_token", func(t *testing.T) {
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"WebIdentityToken": {token},
|
|
"RoleArn": {"arn:aws:iam::role/" + testRole},
|
|
"RoleSessionName": {"integration-test-session"},
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
|
|
|
// Note: This may still fail if the role/trust policy is not configured
|
|
// In that case, we just verify the error is about trust policy, not token validation
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.NotEqual(t, "InvalidParameterValue", errResp.Error.Code,
|
|
"Token validation should not fail - error should be about trust policy")
|
|
} else {
|
|
var stsResp AssumeRoleWithWebIdentityTestResponse
|
|
err = xml.Unmarshal(body, &stsResp)
|
|
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
|
|
|
creds := stsResp.Result.Credentials
|
|
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
|
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
|
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
|
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
|
|
|
t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId)
|
|
}
|
|
})
|
|
|
|
t.Run("with_duration_seconds", func(t *testing.T) {
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"WebIdentityToken": {token},
|
|
"RoleArn": {"arn:aws:iam::role/" + testRole},
|
|
"RoleSessionName": {"integration-test-session"},
|
|
"DurationSeconds": {"3600"}, // 1 hour
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Verify the request is accepted (even if trust policy causes rejection)
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Should not fail with InvalidParameterValue for DurationSeconds
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.NotContains(t, errResp.Error.Message, "DurationSeconds",
|
|
"DurationSeconds parameter should be accepted")
|
|
}
|
|
})
|
|
}
|
|
|
|
// callSTSAPI is a helper to make STS API calls
|
|
func callSTSAPI(t *testing.T, params url.Values) (*http.Response, error) {
|
|
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
|
strings.NewReader(params.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
return client.Do(req)
|
|
}
|
|
|
|
// isSTSEndpointRunning checks if SeaweedFS STS endpoint is running
|
|
func isSTSEndpointRunning(t *testing.T) bool {
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(TestSTSEndpoint + "/status")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode == http.StatusOK
|
|
}
|