From f40687b34ee2cd40ae513380feacf70e938c24e8 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 17 Sep 2026 23:50:16 -0700 Subject: [PATCH] s3: tighten STS session token handling (#11383) * s3api: test that a session token must not reveal its credential * sts: derive secret access key with HMAC keyed on the signing key * s3api: stop accepting STS session tokens as bearer credentials * security: reject STS session tokens on filer and admin gRPC auth * test: sign s3/iam framework requests with the session credential * s3api: exercise the real auth pipeline in the end-to-end harness --- test/s3/iam/s3_iam_framework.go | 82 ++++++------ test/s3/iam/s3_iam_group_test.go | 13 +- weed/iam/sts/credential_prefix_test.go | 4 +- weed/iam/sts/parent_user_test.go | 2 +- weed/iam/sts/session_claims.go | 25 ++-- weed/iam/sts/session_claims_test.go | 18 +-- weed/iam/sts/sts_service.go | 15 ++- weed/iam/sts/token_utils.go | 28 ++-- weed/s3api/auth_sts_session_token_test.go | 121 ++++++++++++++++++ weed/s3api/auth_sts_v4_test.go | 2 +- weed/s3api/s3_end_to_end_test.go | 42 +++--- weed/s3api/s3_iam_group_role_abac_test.go | 4 +- weed/s3api/s3_iam_middleware.go | 51 +------- weed/s3api/s3_jwt_auth_test.go | 16 ++- weed/s3api/s3api_server_grpc.go | 3 + weed/s3api/s3api_sts.go | 4 +- weed/security/jwt.go | 6 + weed/server/filer_server_handlers.go | 4 + weed/server/filer_server_handlers_iam_grpc.go | 3 + 19 files changed, 278 insertions(+), 165 deletions(-) create mode 100644 weed/s3api/auth_sts_session_token_test.go diff --git a/test/s3/iam/s3_iam_framework.go b/test/s3/iam/s3_iam_framework.go index cdb5a38ea..87659c3c0 100644 --- a/test/s3/iam/s3_iam_framework.go +++ b/test/s3/iam/s3_iam_framework.go @@ -2,9 +2,12 @@ package iam import ( "context" + "crypto/hmac" cryptorand "crypto/rand" "crypto/rsa" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" @@ -354,12 +357,12 @@ func (t *BearerTokenTransport) extractPrincipalFromJWT(tokenString string) strin } // generateSTSSessionToken creates a session token using the actual STS service for proper validation -func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string, validDuration time.Duration, account string, customClaims map[string]interface{}) (string, error) { +func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string, validDuration time.Duration, account string, customClaims map[string]interface{}) (string, *credentials.Credentials, error) { now := time.Now() signingKeyB64 := "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc=" signingKey, err := base64.StdEncoding.DecodeString(signingKeyB64) if err != nil { - return "", fmt.Errorf("failed to decode signing key: %v", err) + return "", nil, fmt.Errorf("failed to decode signing key: %v", err) } // Generate a session ID that would be created by the STS service @@ -404,10 +407,19 @@ func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string, token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims) tokenString, err := token.SignedString(signingKey) if err != nil { - return "", err + return "", nil, err } - return tokenString, nil + accessKeyHash := sha256.Sum256([]byte("access-key:" + sessionId)) + mac := hmac.New(sha256.New, signingKey) + mac.Write([]byte("secret-key:" + sessionId)) + creds := credentials.NewStaticCredentials( + "ASIA"+hex.EncodeToString(accessKeyHash[:8]), + base64.StdEncoding.EncodeToString(mac.Sum(nil)), + tokenString, + ) + + return tokenString, creds, nil } // CreateS3ClientWithJWT creates an S3 client authenticated with a JWT token for the specified role @@ -417,36 +429,34 @@ func (f *S3IAMTestFramework) CreateS3ClientWithJWT(username, roleName string) (* // CreateS3ClientWithCustomClaims creates an S3 client with specific account ID and custom claims func (f *S3IAMTestFramework) CreateS3ClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*s3.S3, error) { - var token string - var err error + var httpClient *http.Client + creds := credentials.AnonymousCredentials if f.useKeycloak && claims == nil && account == "" { // Use real Keycloak authentication if no custom requirements - token, err = f.getKeycloakToken(username) + token, err := f.getKeycloakToken(username) if err != nil { return nil, fmt.Errorf("failed to get Keycloak token: %v", err) } + httpClient = &http.Client{ + Transport: &BearerTokenTransport{ + Token: token, + }, + } } else { // Generate STS session token (mock mode or custom requirements) - token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims) + var err error + _, creds, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims) if err != nil { return nil, fmt.Errorf("failed to generate STS session token: %v", err) } } - // Create custom HTTP client with Bearer token transport - httpClient := &http.Client{ - Transport: &BearerTokenTransport{ - Token: token, - }, - } - sess, err := session.NewSession(&aws.Config{ - Region: aws.String(TestRegion), - Endpoint: aws.String(TestS3Endpoint), - HTTPClient: httpClient, - // Use anonymous credentials to avoid AWS signature generation - Credentials: credentials.AnonymousCredentials, + Region: aws.String(TestRegion), + Endpoint: aws.String(TestS3Endpoint), + HTTPClient: httpClient, + Credentials: creds, DisableSSL: aws.Bool(true), S3ForcePathStyle: aws.Bool(true), }) @@ -487,7 +497,7 @@ func (f *S3IAMTestFramework) CreateS3ClientWithInvalidJWT() (*s3.S3, error) { // CreateS3ClientWithExpiredJWT creates an S3 client with an expired JWT token func (f *S3IAMTestFramework) CreateS3ClientWithExpiredJWT(username, roleName string) (*s3.S3, error) { // Generate expired STS session token (expired 1 hour ago) - token, err := f.generateSTSSessionToken(username, roleName, -time.Hour, "", nil) + token, _, err := f.generateSTSSessionToken(username, roleName, -time.Hour, "", nil) if err != nil { return nil, fmt.Errorf("failed to generate expired STS session token: %v", err) } @@ -942,36 +952,34 @@ func (f *S3IAMTestFramework) CreateIAMClientWithJWT(username, roleName string) ( // CreateIAMClientWithCustomClaims creates an IAM client with specific account ID and custom claims func (f *S3IAMTestFramework) CreateIAMClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*iam.IAM, error) { - var token string - var err error + var httpClient *http.Client + creds := credentials.AnonymousCredentials if f.useKeycloak && claims == nil && account == "" { // Use real Keycloak authentication if no custom requirements - token, err = f.getKeycloakToken(username) + token, err := f.getKeycloakToken(username) if err != nil { return nil, fmt.Errorf("failed to get Keycloak token: %v", err) } + httpClient = &http.Client{ + Transport: &BearerTokenTransport{ + Token: token, + }, + } } else { // Generate STS session token (mock mode or custom requirements) - token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims) + var err error + _, creds, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims) if err != nil { return nil, fmt.Errorf("failed to generate STS session token: %v", err) } } - // Create custom HTTP client with Bearer token transport - httpClient := &http.Client{ - Transport: &BearerTokenTransport{ - Token: token, - }, - } - sess, err := session.NewSession(&aws.Config{ - Region: aws.String(TestRegion), - Endpoint: aws.String(TestS3Endpoint), - HTTPClient: httpClient, - // Use anonymous credentials to avoid AWS signature generation - Credentials: credentials.AnonymousCredentials, + Region: aws.String(TestRegion), + Endpoint: aws.String(TestS3Endpoint), + HTTPClient: httpClient, + Credentials: creds, DisableSSL: aws.Bool(true), }) if err != nil { diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index bcb9d895e..e71121af0 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -13,6 +13,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" + v4 "github.com/aws/aws-sdk-go/aws/signer/v4" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/s3" "github.com/stretchr/testify/assert" @@ -693,23 +694,25 @@ type ListGroupsResponse struct { func callIAMAPIAuthenticated(_ *testing.T, framework *S3IAMTestFramework, action string, params url.Values) (*http.Response, error) { params.Set("Action", action) + body := params.Encode() req, err := http.NewRequest(http.MethodPost, TestIAMEndpoint+"/", - strings.NewReader(params.Encode())) + strings.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - token, err := framework.generateSTSSessionToken("admin-user", "TestAdminRole", time.Hour, "", nil) + _, creds, err := framework.generateSTSSessionToken("admin-user", "TestAdminRole", time.Hour, "", nil) if err != nil { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &BearerTokenTransport{Token: token}, + if _, err := v4.NewSigner(creds).Sign(req, strings.NewReader(body), "iam", TestRegion, time.Now()); err != nil { + return nil, err } + + client := &http.Client{Timeout: 30 * time.Second} return client.Do(req) } diff --git a/weed/iam/sts/credential_prefix_test.go b/weed/iam/sts/credential_prefix_test.go index bc44801e5..b7f3758f1 100644 --- a/weed/iam/sts/credential_prefix_test.go +++ b/weed/iam/sts/credential_prefix_test.go @@ -17,7 +17,7 @@ func TestTemporaryCredentialPrefix(t *testing.T) { sessionId := "test-session-for-prefix" expiration := time.Now().Add(time.Hour) - credGen := NewCredentialGenerator() + credGen := testCredGen cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration) assert.NoError(t, err) @@ -37,7 +37,7 @@ func TestTemporaryCredentialFormat(t *testing.T) { sessionId := "format-test-session" expiration := time.Now().Add(time.Hour) - credGen := NewCredentialGenerator() + credGen := testCredGen cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration) assert.NoError(t, err) diff --git a/weed/iam/sts/parent_user_test.go b/weed/iam/sts/parent_user_test.go index af1a0c89e..128186fb5 100644 --- a/weed/iam/sts/parent_user_test.go +++ b/weed/iam/sts/parent_user_test.go @@ -66,7 +66,7 @@ func TestSessionClaimsRoundTripParentUser(t *testing.T) { WithRoleInfo("arn:aws:iam::123:role/r", "arn:aws:sts::123:assumed-role/r/s", "arn:aws:sts::123:assumed-role/r/s"). WithParentUser(parent) - info := claims.ToSessionInfo() + info := claims.ToSessionInfo(testCredGen) if info.ParentUser != parent { t.Fatalf("ParentUser lost on round-trip: got %q want %q", info.ParentUser, parent) } diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go index 68ddd0c71..e1449e52d 100644 --- a/weed/iam/sts/session_claims.go +++ b/weed/iam/sts/session_claims.go @@ -3,7 +3,6 @@ package sts import ( "crypto/sha256" "encoding/base64" - "fmt" "strings" "time" @@ -54,11 +53,6 @@ func ResolveIdentityClaim(ctx map[string]interface{}) string { return "" } -// defaultCredentialGenerator is a reusable instance for generating temporary credentials -// Reusing a single instance across all calls to ToSessionInfo() reduces allocation overhead -// since this method may be called frequently during signature verification -var defaultCredentialGenerator = NewCredentialGenerator() - // STSSessionClaims represents comprehensive session information embedded in JWT tokens // This eliminates the need for separate session storage by embedding all session // metadata directly in the token itself - enabling true stateless operation @@ -122,21 +116,20 @@ func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSess // ToSessionInfo converts JWT claims back to SessionInfo structure // This enables seamless integration with existing code expecting SessionInfo -func (c *STSSessionClaims) ToSessionInfo() *SessionInfo { +func (c *STSSessionClaims) ToSessionInfo(credGen *CredentialGenerator) *SessionInfo { var expiresAt time.Time if c.ExpiresAt != nil { expiresAt = c.ExpiresAt.Time } - // Generate temporary credentials from the session ID - // This is deterministic based on the session ID, so the same credentials are regenerated - credentials, err := defaultCredentialGenerator.GenerateTemporaryCredentials(c.SessionId, expiresAt) - if err != nil { - // Log the error with context - credential generation failure is important for debugging - errMsg := fmt.Errorf("generate temporary credentials for session %s: %w", c.SessionId, err) - glog.Warningf("Failed to generate credentials for STS session: %v", errMsg) - // Return session info without credentials - validation will catch this as invalid - credentials = nil + var credentials *Credentials + if credGen != nil { + creds, err := credGen.GenerateTemporaryCredentials(c.SessionId, expiresAt) + if err != nil { + glog.Warningf("Failed to generate credentials for STS session %s: %v", c.SessionId, err) + } else { + credentials = creds + } } return &SessionInfo{ diff --git a/weed/iam/sts/session_claims_test.go b/weed/iam/sts/session_claims_test.go index 007f2404b..aa4da831e 100644 --- a/weed/iam/sts/session_claims_test.go +++ b/weed/iam/sts/session_claims_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/require" ) +var testCredGen = NewCredentialGenerator([]byte("test-signing-key")) + // TestSTSSessionClaimsToSessionInfo tests the ToSessionInfo conversion func TestSTSSessionClaimsToSessionInfo(t *testing.T) { sessionId := "test-session-123" @@ -24,7 +26,7 @@ func TestSTSSessionClaimsToSessionInfo(t *testing.T) { WithIdentityProvider("oidc", "user-123", "https://issuer.example.com"). WithMaxDuration(time.Hour) - sessionInfo := claims.ToSessionInfo() + sessionInfo := claims.ToSessionInfo(testCredGen) // Verify basic claims are converted assert.Equal(t, sessionId, sessionInfo.SessionId) @@ -52,11 +54,11 @@ func TestSTSSessionClaimsToSessionInfoCredentialGeneration(t *testing.T) { expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) claims1 := NewSTSSessionClaims(sessionId, issuer, expiresAt) - sessionInfo1 := claims1.ToSessionInfo() + sessionInfo1 := claims1.ToSessionInfo(testCredGen) // Create another claims object with the same session ID and expiration claims2 := NewSTSSessionClaims(sessionId, issuer, expiresAt) - sessionInfo2 := claims2.ToSessionInfo() + sessionInfo2 := claims2.ToSessionInfo(testCredGen) // Verify that both have valid credentials assert.NotNil(t, sessionInfo1.Credentials, "credentials should be populated") @@ -104,7 +106,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) { WithRequestContext(requestContext). WithMaxDuration(2 * time.Hour) - sessionInfo := claims.ToSessionInfo() + sessionInfo := claims.ToSessionInfo(testCredGen) // Verify all fields are preserved assert.Equal(t, sessionId, sessionInfo.SessionId) @@ -130,7 +132,7 @@ func TestSTSSessionClaimsToSessionInfoEmptyFields(t *testing.T) { // Create claims with minimal fields claims := NewSTSSessionClaims(sessionId, issuer, expiresAt) - sessionInfo := claims.ToSessionInfo() + sessionInfo := claims.ToSessionInfo(testCredGen) // Verify basic fields are preserved assert.Equal(t, sessionId, sessionInfo.SessionId) @@ -177,7 +179,7 @@ func TestSTSSessionClaimsToSessionInfoCredentialExpiration(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { claims := NewSTSSessionClaims(sessionId, issuer, tc.expiresAt) - sessionInfo := claims.ToSessionInfo() + sessionInfo := claims.ToSessionInfo(testCredGen) assert.NotNil(t, sessionInfo.Credentials) // Check expiration within 1 second due to timing precision (symmetric tolerance) @@ -211,7 +213,7 @@ func TestSessionInfoIntegration(t *testing.T) { WithIdentityProvider("test-provider", "user-id", "https://test.example.com") // Convert to SessionInfo - sessionInfo := claims.ToSessionInfo() + sessionInfo := claims.ToSessionInfo(testCredGen) // Verify the session info has valid credentials assert.NotNil(t, sessionInfo.Credentials) @@ -238,7 +240,7 @@ func TestSecretAccessKeyDeterminism(t *testing.T) { expiration := time.Now().Add(time.Hour) // Generate credentials multiple times with the same session ID - credGen := NewCredentialGenerator() + credGen := testCredGen cred1, err := credGen.GenerateTemporaryCredentials(sessionId, expiration) assert.NoError(t, err) diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index 320665da8..cb41d4d16 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -94,6 +94,7 @@ type STSService struct { providers map[string]providers.IdentityProvider issuerToProvider map[string]providers.IdentityProvider // Efficient issuer-based provider lookup tokenGenerator *TokenGenerator + credGenerator *CredentialGenerator trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation // iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view @@ -128,6 +129,11 @@ func (s *STSService) GetTokenGenerator() *TokenGenerator { return s.tokenGenerator } +// GetCredentialGenerator returns the credential generator used by the STS service. +func (s *STSService) GetCredentialGenerator() *CredentialGenerator { + return s.credGenerator +} + // STSConfig holds STS service configuration type STSConfig struct { // TokenDuration is the default duration for issued tokens @@ -325,6 +331,7 @@ func (s *STSService) Initialize(config *STSConfig) error { // Initialize token generator for stateless JWT operations s.tokenGenerator = NewTokenGenerator(config.SigningKey, config.Issuer) + s.credGenerator = NewCredentialGenerator(config.SigningKey) // Load identity providers from configuration if err := s.loadProvidersFromConfig(config); err != nil { @@ -606,8 +613,7 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass return nil, fmt.Errorf("failed to generate session ID: %w", err) } - credGenerator := NewCredentialGenerator() - credentials, err := credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt) + credentials, err := s.credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt) if err != nil { return nil, fmt.Errorf("failed to generate credentials: %w", err) } @@ -771,8 +777,7 @@ func (s *STSService) issueSession(roleArn, roleSessionName, sessionPolicy string return nil, fmt.Errorf("failed to generate session ID: %w", err) } - credGenerator := NewCredentialGenerator() - tempCredentials, err := credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt) + tempCredentials, err := s.credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt) if err != nil { return nil, fmt.Errorf("failed to generate credentials: %w", err) } @@ -909,7 +914,7 @@ func (s *STSService) ValidateSessionToken(ctx context.Context, sessionToken stri // Convert JWT claims back to SessionInfo // All session information is embedded in the JWT token itself - return claims.ToSessionInfo(), nil + return claims.ToSessionInfo(s.credGenerator), nil } // NOTE: Session revocation is not supported in the stateless JWT design. diff --git a/weed/iam/sts/token_utils.go b/weed/iam/sts/token_utils.go index b7a51cdf3..ace730271 100644 --- a/weed/iam/sts/token_utils.go +++ b/weed/iam/sts/token_utils.go @@ -1,6 +1,7 @@ package sts import ( + "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" @@ -163,11 +164,15 @@ type SessionTokenClaims struct { } // CredentialGenerator generates AWS-compatible temporary credentials -type CredentialGenerator struct{} +type CredentialGenerator struct { + signingKey []byte +} -// NewCredentialGenerator creates a new credential generator -func NewCredentialGenerator() *CredentialGenerator { - return &CredentialGenerator{} +// NewCredentialGenerator creates a new credential generator. The signing key +// keys the secret access key derivation so it cannot be recomputed from the +// session id embedded in the session token. +func NewCredentialGenerator(signingKey []byte) *CredentialGenerator { + return &CredentialGenerator{signingKey: signingKey} } // GenerateTemporaryCredentials creates temporary AWS credentials @@ -203,16 +208,13 @@ func (c *CredentialGenerator) generateTemporaryAccessKeyId(sessionId string) (st return "ASIA" + hex.EncodeToString(hash[:8]), nil // AWS format: ASIA + 16 chars } -// generateSecretAccessKey generates a deterministic secret access key based on sessionId -// This ensures the same secret key is regenerated from the JWT claims during signature verification +// generateSecretAccessKey derives the secret access key from the session id +// keyed on the STS signing key. Issuance and the regeneration in ToSessionInfo +// agree on the value, but it cannot be recomputed from the token alone. func (c *CredentialGenerator) generateSecretAccessKey(sessionId string) (string, error) { - // Create deterministic secret key based on session ID (not random!) - // This is critical for STS because: - // 1. AssumeRoleWithWebIdentity generates the secret key once - // 2. During signature verification, ToSessionInfo() regenerates credentials from JWT - // 3. Both must generate the same secret key for signature verification to succeed - hash := sha256.Sum256([]byte("secret-key:" + sessionId)) - return base64.StdEncoding.EncodeToString(hash[:]), nil + mac := hmac.New(sha256.New, c.signingKey) + mac.Write([]byte("secret-key:" + sessionId)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil } // generateSessionTokenId generates a session token identifier diff --git a/weed/s3api/auth_sts_session_token_test.go b/weed/s3api/auth_sts_session_token_test.go new file mode 100644 index 000000000..7fe322f9c --- /dev/null +++ b/weed/s3api/auth_sts_session_token_test.go @@ -0,0 +1,121 @@ +package s3api + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// sessionIdFromToken reads the session id out of a session token (or a +// presigned URL's X-Amz-Security-Token) without any key. +func sessionIdFromToken(t *testing.T, token string) string { + t.Helper() + parts := strings.Split(token, ".") + require.Len(t, parts, 3) + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims map[string]interface{} + require.NoError(t, json.Unmarshal(payload, &claims)) + sid, ok := claims["sid"].(string) + require.True(t, ok, "session token should carry a sid claim") + return sid +} + +// issueTestSession mints an STS session the way AssumeRoleWithWebIdentity does. +func issueTestSession(t *testing.T, stsService *sts.STSService, config *sts.STSConfig) (*sts.SessionInfo, string) { + t.Helper() + sessionId, err := sts.GenerateSessionId() + require.NoError(t, err) + claims := sts.NewSTSSessionClaims(sessionId, config.Issuer, time.Now().Add(time.Hour)). + WithSessionName("alice-session"). + WithRoleInfo("arn:aws:iam::role/AppRole", + "arn:aws:sts::assumed-role/AppRole/alice-session", + "arn:aws:sts::assumed-role/AppRole/alice-session") + token, err := sts.NewTokenGenerator(config.SigningKey, config.Issuer).GenerateJWTWithClaims(claims) + require.NoError(t, err) + sessionInfo, err := stsService.ValidateSessionToken(context.Background(), token) + require.NoError(t, err) + return sessionInfo, token +} + +// testIdentityFromSessionToken builds the identity for a validated STS session +// token. Session tokens are not bearer credentials; tests use this to reach +// the authorization layer the way a verified SigV4 request would. +func testIdentityFromSessionToken(t *testing.T, s3iam *S3IAMIntegration, sessionToken string) *IAMIdentity { + t.Helper() + sessionInfo, err := s3iam.stsService.ValidateSessionToken(context.Background(), sessionToken) + require.NoError(t, err) + claims := make(map[string]interface{}, len(sessionInfo.RequestContext)+4) + for k, v := range sessionInfo.RequestContext { + claims[k] = v + } + claims["sub"] = sessionInfo.Subject + claims["role"] = sessionInfo.RoleArn + claims["principal"] = sessionInfo.Principal + claims["snam"] = sessionInfo.SessionName + return &IAMIdentity{ + Name: sessionInfo.Subject, + Principal: sessionInfo.Principal, + SessionToken: sessionToken, + Account: &Account{ + DisplayName: sessionInfo.SessionName, + EmailAddress: sessionInfo.Subject + "@seaweedfs.local", + Id: sessionInfo.Subject, + }, + Claims: claims, + } +} + +// A presigned URL discloses the session token in X-Amz-Security-Token. Whoever +// holds it must not be able to reconstruct the temporary credential or turn it +// into a standalone credential. +func TestSTSSessionTokenDoesNotRevealCredential(t *testing.T) { + stsService, config := setupTestSTSService(t) + iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory") + s3iam := &S3IAMIntegration{stsService: stsService, enabled: true} + iam.SetIAMIntegration(s3iam) + + sessionInfo, sessionToken := issueTestSession(t, stsService, config) + + // control: the issued credential verifies + req, err := newTestRequest(http.MethodGet, "https://example.com/reports/data.csv", 0, nil) + require.NoError(t, err) + req.Header.Set("X-Amz-Security-Token", sessionToken) + require.NoError(t, signRequestV4(req, sessionInfo.Credentials.AccessKeyId, sessionInfo.Credentials.SecretAccessKey)) + _, errCode := iam.reqSignatureV4Verify(req) + require.Equal(t, s3err.ErrNone, errCode) + + // attack: recompute the credential from the token's public claims + sid := sessionIdFromToken(t, sessionToken) + akHash := sha256.Sum256([]byte("access-key:" + sid)) + accessKey := "ASIA" + hex.EncodeToString(akHash[:8]) + skHash := sha256.Sum256([]byte("secret-key:" + sid)) + secretKey := base64.StdEncoding.EncodeToString(skHash[:]) + + forged, err := newTestRequest(http.MethodDelete, "https://example.com/reports/payroll.csv", 0, nil) + require.NoError(t, err) + forged.Header.Set("X-Amz-Security-Token", sessionToken) + require.NoError(t, signRequestV4(forged, accessKey, secretKey)) + _, errCode = iam.reqSignatureV4Verify(forged) + require.NotEqual(t, s3err.ErrNone, errCode, + "a request signed with a credential derived from the session token must not verify") + + // attack: replay the token itself as a bearer credential + bearerReq := httptest.NewRequest(http.MethodDelete, "/reports/payroll.csv", http.NoBody) + bearerReq.Header.Set("Authorization", "Bearer "+sessionToken) + _, errCode = s3iam.AuthenticateJWT(context.Background(), bearerReq) + require.NotEqual(t, s3err.ErrNone, errCode, + "an STS session token must not authenticate as a bearer token") +} diff --git a/weed/s3api/auth_sts_v4_test.go b/weed/s3api/auth_sts_v4_test.go index e38403888..5fd7ac2b6 100644 --- a/weed/s3api/auth_sts_v4_test.go +++ b/weed/s3api/auth_sts_v4_test.go @@ -98,7 +98,7 @@ func TestAuthorizeWithIAMSessionTokenExtraction(t *testing.T) { // preserved when converting to credentials for authorization. func TestSTSSessionTokenIntoCredentials(t *testing.T) { // Create a credential generator and session claims - credGen := sts.NewCredentialGenerator() + credGen := sts.NewCredentialGenerator([]byte("test-signing-key")) sessionId := "test-session-123" expiresAt := time.Now().Add(time.Hour) diff --git a/weed/s3api/s3_end_to_end_test.go b/weed/s3api/s3_end_to_end_test.go index cc600d43b..bf246a2ae 100644 --- a/weed/s3api/s3_end_to_end_test.go +++ b/weed/s3api/s3_end_to_end_test.go @@ -114,13 +114,12 @@ func TestS3EndToEndWithJWT(t *testing.T) { }) require.NoError(t, err, "Failed to assume role %s", tt.roleArn) - jwtToken := response.Credentials.SessionToken - require.NotEmpty(t, jwtToken, "JWT token should not be empty") + require.NotEmpty(t, response.Credentials.SessionToken, "session token should not be empty") // Execute S3 operations for i, operation := range tt.s3Operations { t.Run(fmt.Sprintf("%s_%s", tt.name, operation.Operation), func(t *testing.T) { - allowed := executeS3OperationWithJWT(t, s3Server, operation, jwtToken) + allowed := executeS3OperationWithJWT(t, s3Server, operation, response.Credentials) expected := tt.expectedResults[i] if expected { @@ -153,8 +152,6 @@ func TestS3MultipartUploadWithJWT(t *testing.T) { }) require.NoError(t, err) - jwtToken := response.Credentials.SessionToken - // Test multipart upload workflow tests := []struct { name string @@ -205,7 +202,7 @@ func TestS3MultipartUploadWithJWT(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - allowed := executeS3OperationWithJWT(t, s3Server, tt.operation, jwtToken) + allowed := executeS3OperationWithJWT(t, s3Server, tt.operation, response.Credentials) if tt.expected { assert.True(t, allowed, "Multipart operation %s should be allowed", tt.operation.Operation) } else { @@ -304,10 +301,7 @@ func TestS3ListObjectsV2PrefixCondition(t *testing.T) { require.NotEmpty(t, sessionToken) // Authenticate to get IAM identity - authReq := httptest.NewRequest("GET", "/examples", http.NoBody) - authReq.Header.Set("Authorization", "Bearer "+sessionToken) - identity, errCode := s3IAMIntegration.AuthenticateJWT(ctx, authReq) - require.Equal(t, s3err.ErrNone, errCode, "Authentication should succeed") + identity := testIdentityFromSessionToken(t, s3IAMIntegration, sessionToken) tests := []struct { name string @@ -434,10 +428,7 @@ func TestS3CreateBucketWithAttachedPolicy(t *testing.T) { }) require.NoError(t, err) - authReq := httptest.NewRequest("PUT", "/ml-models", http.NoBody) - authReq.Header.Set("Authorization", "Bearer "+response.Credentials.SessionToken) - identity, errCode := s3IAMIntegration.AuthenticateJWT(ctx, authReq) - require.Equal(t, s3err.ErrNone, errCode) + identity := testIdentityFromSessionToken(t, s3IAMIntegration, response.Credentials.SessionToken) tests := []struct { name string @@ -538,8 +529,6 @@ func TestS3PerformanceWithIAM(t *testing.T) { }) require.NoError(t, err) - jwtToken := response.Credentials.SessionToken - // Benchmark multiple GET requests numRequests := 100 start := time.Now() @@ -552,7 +541,7 @@ func TestS3PerformanceWithIAM(t *testing.T) { Operation: "GetObject", } - executeS3OperationWithJWT(t, s3Server, operation, jwtToken) + executeS3OperationWithJWT(t, s3Server, operation, response.Credentials) } duration := time.Since(start) @@ -629,11 +618,13 @@ func setupCompleteS3IAMSystem(t *testing.T) (http.Handler, *integration.IAMManag t.Skip("Could not create S3 IAM integration") } + iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory") + iam.SetIAMIntegration(s3IAMIntegration) + // Add a simple test endpoint that we can use to verify IAM functionality router.HandleFunc("/test-auth", func(w http.ResponseWriter, r *http.Request) { - // Test JWT authentication - identity, errCode := s3IAMIntegration.AuthenticateJWT(r.Context(), r) - if errCode != s3err.ErrNone { + identity, errCode, _ := iam.authenticateRequestInternal(r) + if errCode != s3err.ErrNone || identity == nil { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Authentication failed")) return @@ -655,7 +646,7 @@ func setupCompleteS3IAMSystem(t *testing.T) (http.Handler, *integration.IAMManag } // Test authorization with appropriate action - authErrCode := s3IAMIntegration.AuthorizeAction(r.Context(), identity, action, "test-bucket", "test-object", r) + authErrCode := iam.authorizeWithIAM(r, identity, action, "test-bucket", "test-object") if authErrCode != s3err.ErrNone { w.WriteHeader(http.StatusForbidden) w.Write([]byte("Authorization failed")) @@ -900,10 +891,11 @@ func setupS3IPRestrictedRole(ctx context.Context, manager *integration.IAMManage }) } -func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3Operation, jwtToken string) bool { +func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3Operation, creds *sts.Credentials) bool { // Use our simplified test endpoint for IAM validation with the correct HTTP method - req := httptest.NewRequest(operation.Method, "/test-auth", nil) - req.Header.Set("Authorization", "Bearer "+jwtToken) + req, err := newTestRequest(operation.Method, "http://example.com/test-auth", 0, nil) + require.NoError(t, err) + req.Header.Set("X-Amz-Security-Token", creds.SessionToken) req.Header.Set("Content-Type", "application/octet-stream") // Set source IP if specified @@ -912,6 +904,8 @@ func executeS3OperationWithJWT(t *testing.T, s3Server http.Handler, operation S3 req.RemoteAddr = operation.SourceIP + ":12345" } + require.NoError(t, signRequestV4(req, creds.AccessKeyId, creds.SecretAccessKey)) + // Execute request recorder := httptest.NewRecorder() s3Server.ServeHTTP(recorder, req) diff --git a/weed/s3api/s3_iam_group_role_abac_test.go b/weed/s3api/s3_iam_group_role_abac_test.go index c3ea4244d..9c7e344a1 100644 --- a/weed/s3api/s3_iam_group_role_abac_test.go +++ b/weed/s3api/s3_iam_group_role_abac_test.go @@ -220,9 +220,7 @@ func groupsRolesJWT(t *testing.T, subject string, groups, roles []string) string } func authenticateSessionToken(t *testing.T, s3iam *S3IAMIntegration, sessionToken string) (*IAMIdentity, s3err.ErrorCode) { - req := httptest.NewRequest("GET", "/", http.NoBody) - req.Header.Set("Authorization", "Bearer "+sessionToken) - return s3iam.AuthenticateJWT(context.Background(), req) + return testIdentityFromSessionToken(t, s3iam, sessionToken), s3err.ErrNone } func authorizeGet(ctx context.Context, s3iam *S3IAMIntegration, identity *IAMIdentity, bucket string) bool { diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 77b01b7a2..e508dca80 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -189,51 +189,12 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ }, s3err.ErrNone } - // This is an STS-issued token - validate with STS service - // ValidateSessionToken performs cryptographic verification and extraction of trusted claims - sessionInfo, err := s3iam.stsService.ValidateSessionToken(ctx, sessionToken) - if err != nil { - glog.V(3).Infof("STS session validation failed: %v", err) - return nil, s3err.ErrAccessDenied - } - - // Create claims map starting with request context (which holds custom claims) - claims := make(map[string]interface{}) - if sessionInfo.RequestContext != nil { - for k, v := range sessionInfo.RequestContext { - claims[k] = v - } - } - - // Add standard claims - claims["sub"] = sessionInfo.Subject - claims["role"] = sessionInfo.RoleArn - claims["principal"] = sessionInfo.Principal - claims["snam"] = sessionInfo.SessionName - - // Create IAM identity from VALIDATED session info - // We use the trusted data returned by the STS service, not the unverified token claims - identity := &IAMIdentity{ - Name: sessionInfo.Subject, - Principal: sessionInfo.Principal, - SessionToken: sessionToken, - Account: &Account{ - DisplayName: sessionInfo.SessionName, - EmailAddress: sessionInfo.Subject + "@seaweedfs.local", - Id: sessionInfo.Subject, - }, - Claims: claims, - } - // ParentUser is set only for OIDC-federated sessions. Resolve the audit - // identity claim from the original request context (not the local claims - // map, whose sub was overwritten with the opaque session subject above) so - // the bearer path surfaces the same authoritative OIDC identity as SigV4. - if sessionInfo.ParentUser != "" { - identity.IdentityClaim = sts.ResolveIdentityClaim(sessionInfo.RequestContext) - } - - glog.V(3).Infof("JWT authentication successful for principal: %s", identity.Principal) - return identity, s3err.ErrNone + // STS session tokens authenticate SigV4 requests via proof of possession + // (signature with the derived secret). As bearer tokens they would turn + // every presigned URL, which carries the token in X-Amz-Security-Token, + // into a standalone credential. + glog.V(3).Infof("Rejected STS session token presented as bearer token") + return nil, s3err.ErrAccessDenied } // ValidateSessionToken checks the validity of an STS session token diff --git a/weed/s3api/s3_jwt_auth_test.go b/weed/s3api/s3_jwt_auth_test.go index a3705862c..fd4ffc7ea 100644 --- a/weed/s3api/s3_jwt_auth_test.go +++ b/weed/s3api/s3_jwt_auth_test.go @@ -100,9 +100,9 @@ func TestJWTAuthenticationFlow(t *testing.T) { // Test each operation for _, op := range tt.testOperations { t.Run(string(op.Action), func(t *testing.T) { - // Test JWT authentication - identity, errCode := testJWTAuthentication(t, iamServer, jwtToken) - require.Equal(t, s3err.ErrNone, errCode, "JWT authentication should succeed") + // Test session authentication via SigV4 proof of possession + identity, errCode := testSessionAuthentication(t, iamServer, response.Credentials) + require.Equal(t, s3err.ErrNone, errCode, "Session authentication should succeed") require.NotNil(t, identity) // Test authorization with appropriate role based on test case @@ -537,6 +537,16 @@ func setupTestIPRestrictedRole(ctx context.Context, manager *integration.IAMMana }) } +// testSessionAuthentication authenticates STS temporary credentials the way a +// real client does: a SigV4-signed request carrying the session token. +func testSessionAuthentication(t *testing.T, iam *IdentityAccessManagement, creds *sts.Credentials) (*Identity, s3err.ErrorCode) { + req, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil) + require.NoError(t, err) + req.Header.Set("X-Amz-Security-Token", creds.SessionToken) + require.NoError(t, signRequestV4(req, creds.AccessKeyId, creds.SecretAccessKey)) + return iam.reqSignatureV4Verify(req) +} + func testJWTAuthentication(t *testing.T, iam *IdentityAccessManagement, token string) (*Identity, s3err.ErrorCode) { // Create test request with JWT req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody) diff --git a/weed/s3api/s3api_server_grpc.go b/weed/s3api/s3api_server_grpc.go index 65a7b60d5..61aecf679 100644 --- a/weed/s3api/s3api_server_grpc.go +++ b/weed/s3api/s3api_server_grpc.go @@ -46,6 +46,9 @@ func (s3a *S3ApiServer) checkAdminAuth(ctx context.Context) error { if err != nil || parsed == nil || !parsed.Valid { return status.Error(codes.Unauthenticated, "invalid admin token") } + if claims, ok := parsed.Claims.(*security.SeaweedFilerAdminClaims); !ok || claims.SessionId != "" { + return status.Error(codes.Unauthenticated, "invalid admin token") + } return nil } diff --git a/weed/s3api/s3api_sts.go b/weed/s3api/s3api_sts.go index 1baddb3b0..66f0ecaa3 100644 --- a/weed/s3api/s3api_sts.go +++ b/weed/s3api/s3api_sts.go @@ -802,7 +802,7 @@ func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Re } // Generate temporary credentials - stsCredGen := sts.NewCredentialGenerator() + stsCredGen := h.stsService.GetCredentialGenerator() stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration) if err != nil { h.writeSTSErrorResponse(w, r, STSErrInternalError, @@ -921,7 +921,7 @@ func (h *STSHandlers) prepareSTSCredentials(ctx context.Context, roleArn, roleSe } // Generate temporary credentials (deterministic based on sessionId) - stsCredGen := sts.NewCredentialGenerator() + stsCredGen := h.stsService.GetCredentialGenerator() stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration) if err != nil { return STSCredentials{}, nil, fmt.Errorf("failed to generate temporary credentials: %w", err) diff --git a/weed/security/jwt.go b/weed/security/jwt.go index 22fbacf54..47f15e784 100644 --- a/weed/security/jwt.go +++ b/weed/security/jwt.go @@ -31,6 +31,9 @@ type SeaweedFileIdClaims struct { type SeaweedFilerClaims struct { AllowedPrefixes []string `json:"allowed_prefixes,omitempty"` AllowedMethods []string `json:"allowed_methods,omitempty"` + // SessionId is present only on STS session tokens; a token carrying it is + // an S3 session credential, not a filer API credential. + SessionId string `json:"sid,omitempty"` jwt.RegisteredClaims } @@ -44,6 +47,9 @@ type SeaweedFilerClaims struct { // RegisteredClaims. Extra JSON fields in the payload are silently ignored by // encoding/json, which is the desired behaviour here (forward-compat). type SeaweedFilerAdminClaims struct { + // SessionId is present only on STS session tokens; a token carrying it is + // an S3 session credential, not a filer admin credential. + SessionId string `json:"sid,omitempty"` jwt.RegisteredClaims } diff --git a/weed/server/filer_server_handlers.go b/weed/server/filer_server_handlers.go index 1ef55de43..6fd9bfbf3 100644 --- a/weed/server/filer_server_handlers.go +++ b/weed/server/filer_server_handlers.go @@ -265,6 +265,10 @@ func (fs *FilerServer) authenticateFilerJwt(r *http.Request, isWrite bool) (*sec glog.V(1).Infof("jwt claims not of type *SeaweedFilerClaims from %s", r.RemoteAddr) return nil, false } + if claims.SessionId != "" { + glog.V(1).Infof("jwt is an STS session token, not a filer credential, from %s", r.RemoteAddr) + return nil, false + } if len(claims.AllowedMethods) > 0 { hasMethod := false diff --git a/weed/server/filer_server_handlers_iam_grpc.go b/weed/server/filer_server_handlers_iam_grpc.go index 175ce9ade..7142d53d9 100644 --- a/weed/server/filer_server_handlers_iam_grpc.go +++ b/weed/server/filer_server_handlers_iam_grpc.go @@ -65,6 +65,9 @@ func (s *IamGrpcServer) checkAdminAuth(ctx context.Context) error { if err != nil || parsed == nil || !parsed.Valid { return status.Error(codes.Unauthenticated, "invalid admin token") } + if claims, ok := parsed.Claims.(*security.SeaweedFilerAdminClaims); !ok || claims.SessionId != "" { + return status.Error(codes.Unauthenticated, "invalid admin token") + } return nil }